Compare commits

...
186 Commits
Author SHA1 Message Date
devx-ci-bot f3685b9029 release: v0.23.0 2026-06-27 18:06:01 +00:00
emil 2e5470236e DEVX-85: feat: add devx-lint-dockerfiles to devx.mak, alias setup-image 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 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
devx-ci-bot 37f867f6d8 release: v0.22.1 2026-06-27 16:44:03 +00:00
emil 233a0bc055 DEVX-83: fix: fail lint-dockerfiles when hadolint is missing 2026-06-27 16:43:22 +00:00
gitea-actions-bot 1a28f5dcc5 chore: update badge URLs to commit 7471d490 [skip ci] 2026-06-27 16:17:12 +00:00
emil 3af60af439 DEVX-82: fix: checkout release tag in publish job 2026-06-27 16:16:25 +00:00
gitea-actions-bot e181104e1c chore: update badge URLs to commit 6a571fb4 [skip ci] 2026-06-27 15:06:45 +00:00
gitea-actions-bot ee1826fb34 chore: update badge URLs to commit 477726f7 [skip ci] 2026-06-27 15:06:17 +00:00
devx-ci-bot 9170546a31 release: v0.22.0 2026-06-27 15:06:08 +00:00
emil 91f59076a1 DEVX-81: feat: document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table 2026-06-27 15:05:37 +00:00
gitea-actions-bot ca310fe442 chore: update badge URLs to commit c8ba6417 [skip ci] 2026-06-27 15:00:55 +00:00
emil decba5ec77 DEVX-80: chore: update badge URLs to commit 20bcfe96 [skip ci] 2026-06-27 14:56:57 +00:00
gitea-actions-bot 082df1d893 chore: update badge URLs to commit 20bcfe96 [skip ci] 2026-06-27 13:47:51 +00:00
gitea-actions-bot 08f58e551b chore: update badge URLs to commit 8203528c [skip ci] 2026-06-27 13:47:06 +00:00
devx-ci-bot 66bace57d9 release: v0.21.2 2026-06-27 13:46:57 +00:00
emil 5c8d74015e DEVX-79: fix: gate auto-merge on release-dry-run and unmask failures 2026-06-27 13:46:16 +00:00
gitea-actions-bot 48eec986f6 chore: update badge URLs to commit 3a45f8e1 [skip ci] 2026-06-27 13:28:35 +00:00
emil 1fc1cfb23f DEVX-78: style: compact devx-setup-image to pass checkmake maxbodylength (5 lines) 2026-06-27 13:27:41 +00:00
gitea-actions-bot f24b6914f6 chore: update badge URLs to commit b687262f [skip ci] 2026-06-27 13:24:52 +00:00
gitea-actions-bot 84d02ca221 chore: update badge URLs to commit 6043e937 [skip ci] 2026-06-27 13:24:13 +00:00
devx-ci-bot 18ac8f4ba9 release: v0.21.1 2026-06-27 13:24:07 +00:00
emil 9fb9be9c35 DEVX-77: fix: devx-setup-image configures Gitea PyPI registry and shows pip errors 2026-06-27 13:23:29 +00:00
gitea-actions-bot 9a46723391 chore: update badge URLs to commit f7a68467 [skip ci] 2026-06-27 13:05:31 +00:00
devx-ci-bot 5828d3f07b release: v0.21.0 2026-06-27 13:04:50 +00:00
gitea-actions-bot 4232f4baee chore: update badge URLs to commit 88ae1247 [skip ci] 2026-06-27 13:04:46 +00:00
emil a9fd1a47af DEVX-76: feat: add --auto-login to publish, extract configure_tea_login to gitea_cli 2026-06-27 13:04:06 +00:00
gitea-actions-bot ecd10241fb chore: update badge URLs to commit 022fdc32 [skip ci] 2026-06-27 08:02:52 +00:00
emil 5fb497d108 DEVX-75: fix: remove tag fallback step from release workflow 2026-06-27 08:02:04 +00:00
gitea-actions-bot 7d4c32c761 chore: update badge URLs to commit 894ff869 [skip ci] 2026-06-27 07:47:37 +00:00
emil cb8af53c26 DEVX-74: fix: publish job uses setup-release for build + tea login 2026-06-27 07:46:55 +00:00
gitea-actions-bot 954ede87a7 chore: update badge URLs to commit 5e80905f [skip ci] 2026-06-27 07:43:15 +00:00
gitea-actions-bot eb30faf027 chore: update badge URLs to commit 2979764a [skip ci] 2026-06-27 07:42:40 +00:00
devx-ci-bot 6c4157b5c6 release: v0.20.3 2026-06-27 07:42:30 +00:00
emil ea7ddb2036 DEVX-73: fix: release publish failures and duplicate release commits 2026-06-27 07:41:50 +00:00
gitea-actions-bot ef63ada2f0 chore: update badge URLs to commit bc706211 [skip ci] 2026-06-27 07:21:27 +00:00
gitea-actions-bot cdd5f5a8da chore: update badge URLs to commit a0473322 [skip ci] 2026-06-27 07:20:49 +00:00
devx-ci-bot 63ae375b4b release: v0.20.2 2026-06-27 07:20:10 +00:00
emil 8862ea4639 DEVX-72: ci: add hadolint Dockerfile linter to CI 2026-06-27 07:19:31 +00:00
gitea-actions-bot 8ca0a1b208 chore: update badge URLs to commit 55d2bb5c [skip ci] 2026-06-27 03:08:47 +00:00
emil 670f5a099a DEVX-71: ci: use pre-built tier images in CI workflows 2026-06-27 03:08:06 +00:00
gitea-actions-bot a5277a0790 chore: update badge URLs to commit 177dda82 [skip ci] 2026-06-27 02:42:47 +00:00
devx-ci-bot 203d16b19d release: v0.20.2 2026-06-27 04:41:07 +02:00
gitea-actions-bot 12871fb343 chore: update badge URLs to commit 917159e3 [skip ci] 2026-06-27 04:40:59 +02:00
emil a585ef09b6 DEVX-70: fix: correct sed substitution in ci-full Dockerfile 2026-06-27 02:38:53 +00:00
gitea-actions-bot 92aea7df10 chore: update badge URLs to commit d8f48949 [skip ci] 2026-06-27 04:29:12 +02:00
gitea-actions-bot ee3ad74634 chore: update badge URLs to commit ac4f73ef [skip ci] 2026-06-27 04:27:29 +02:00
devx-ci-bot 626ea67b28 release: v0.20.1 2026-06-27 02:26:36 +00:00
emil 87c3fa6634 DEVX-69: fix: correct image references in tier Dockerfiles 2026-06-27 02:24:24 +00:00
gitea-actions-bot 048d161192 chore: update badge URLs to commit d600d21c [skip ci] 2026-06-27 01:37:07 +00:00
gitea-actions-bot 762eee4a55 chore: update badge URLs to commit d0ea4c99 [skip ci] 2026-06-27 03:36:02 +02:00
devx-ci-bot d84958fab7 release: v0.20.0 2026-06-27 01:35:31 +00:00
emil c0238e75df DEVX-68: feat: add pre-built Docker runner images and tested image build/push tools 2026-06-27 01:34:05 +00:00
gitea-actions-bot d4ddbd7e7a chore: update badge URLs to commit fd23e5b2 [skip ci] 2026-06-27 00:01:53 +00:00
gitea-actions-bot 08b993fc4a chore: update badge URLs to commit ebe8b016 [skip ci] 2026-06-27 01:58:56 +02:00
devx-ci-bot bee730a52f release: v0.19.3 2026-06-26 23:58:24 +00:00
emil d0a4a774a0 DEVX-67: refactor: make molecule weights configurable via pyproject.toml 2026-06-26 23:55:33 +00:00
gitea-actions-bot 882f9805ed chore: update badge URLs to commit a45dd92c [skip ci] 2026-06-27 01:40:17 +02:00
gitea-actions-bot a85e0baaea chore: update badge URLs to commit e0f5da9a [skip ci] 2026-06-27 01:39:01 +02:00
devx-ci-bot 663572768b release: v0.19.2 2026-06-27 01:38:41 +02:00
emil 1f2533872d DEVX-66: fix: calibrate molecule weights from actual CI execution times 2026-06-26 23:36:58 +00:00
gitea-actions-bot cb7e9dbc7e chore: update badge URLs to commit 0ab8d43a [skip ci] 2026-06-26 21:39:38 +02:00
devx-ci-bot f5081e10b1 release: v0.19.1 2026-06-26 21:36:46 +02:00
gitea-actions-bot cabc0d1adc chore: update badge URLs to commit 816fbbbb [skip ci] 2026-06-26 19:36:16 +00:00
emil 7c1ecd6ff9 DEVX-65: refactor: consolidate publish.yml into post-merge.yml 2026-06-26 19:34:45 +00:00
gitea-actions-bot 5063f659bc chore: update badge URLs to commit f977a79e [skip ci] 2026-06-26 21:04:44 +02:00
gitea-actions-bot 96c77a0ba4 chore: update badge URLs to commit c9a5f623 [skip ci] 2026-06-26 19:03:18 +00:00
devx-ci-bot 40dd578d89 release: v0.19.0
Publish Release / publish (push) Waiting to run
2026-06-26 19:03:14 +00:00
emil 7ea9b4a96b DEVX-64: feat: add skip_ref_prefixes config to check_agent_docs 2026-06-26 19:01:39 +00:00
gitea-actions-bot 08ceaf484f chore: update badge URLs to commit c003531a [skip ci] 2026-06-26 20:52:05 +02:00
gitea-actions-bot fb6b0fda1d chore: update badge URLs to commit c5811a72 [skip ci] 2026-06-26 18:50:24 +00:00
devx-ci-bot b81a418d07 release: v0.18.0
Publish Release / publish (push) Waiting to run
2026-06-26 18:50:20 +00:00
emil 58261f7d1a DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references 2026-06-26 18:48:43 +00:00
gitea-actions-bot 85e38f37fd chore: update badge URLs to commit 0a0adc8d [skip ci] 2026-06-26 18:00:16 +00:00
gitea-actions-bot 08b573e2ed chore: update badge URLs to commit 77b32b69 [skip ci] 2026-06-26 17:58:35 +00:00
devx-ci-bot e45a546c16 release: v0.17.0
Publish Release / publish (push) Waiting to run
2026-06-26 17:58:32 +00:00
emil 41c631d5f5 DEVX-62: feat: weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release 2026-06-26 17:57:03 +00:00
gitea-actions-bot e271c79e93 chore: update badge URLs to commit e2c9e22d [skip ci] 2026-06-26 16:14:52 +00:00
gitea-actions-bot f4305821f1 chore: update badge URLs to commit 2e58ef07 [skip ci] 2026-06-26 16:14:11 +00:00
devx-ci-bot e4f40223d2 release: v0.16.0
Publish Release / publish (push) Waiting to run
2026-06-26 18:12:38 +02:00
emil 06e80516d4 DEVX-61: optimise slow unit tests and handle missing tea binary in TeaCLI 2026-06-26 16:09:54 +00:00
gitea-actions-bot f1adf22c3e chore: update badge URLs to commit 097082dd [skip ci] 2026-06-26 17:06:00 +02:00
emil 91216da1a4 DEVX-61: feat: single-source-of-truth config via [tool.devx] in pyproject.toml 2026-06-26 15:04:11 +00:00
gitea-actions-bot f9836208df chore: update badge URLs to commit e913bce4 [skip ci] 2026-06-26 16:32:11 +02:00
gitea-actions-bot 0f0f0b683a chore: update badge URLs to commit 3bc02ab2 [skip ci] 2026-06-26 14:31:58 +00:00
devx-ci-bot 54f687f1bf release: v0.15.0
Publish Release / publish (push) Waiting to run
2026-06-26 16:30:45 +02:00
emil 44c906a5e6 DEVX-60: feat: add create-task, create-pr, pre-push-check tools and devx.mak fragment 2026-06-26 14:29:47 +00:00
gitea-actions-bot a3d528f802 chore: update badge URLs to commit 6d2607dd [skip ci] 2026-06-26 02:20:46 +02:00
gitea-actions-bot e3a7afc0b0 chore: update badge URLs to commit a1b92efe [skip ci] 2026-06-26 02:20:43 +02:00
devx-ci-bot 700d3b55c6 release: v0.14.2
Publish Release / publish (push) Waiting to run
2026-06-26 00:19:13 +00:00
emil 701363d935 DEVX-59: fix: make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY 2026-06-26 00:17:55 +00:00
gitea-actions-bot ddb2d43b4e chore: update badge URLs to commit 3fb76ae0 [skip ci] 2026-06-26 01:34:46 +02:00
gitea-actions-bot fe6373b682 chore: update badge URLs to commit 27c68d16 [skip ci] 2026-06-25 23:34:31 +00:00
devx-ci-bot 33434d5750 release: v0.14.1
Publish Release / publish (push) Waiting to run
2026-06-26 01:33:23 +02:00
emil dfcd33c35b DEVX-58: fix: handle 'already a release' error idempotently in publish 2026-06-25 23:32:28 +00:00
gitea-actions-bot 0aefe1f028 chore: update badge URLs to commit f3c14a15 [skip ci] 2026-06-25 23:24:58 +00:00
gitea-actions-bot 891b0b5dba chore: update badge URLs to commit e89db952 [skip ci] 2026-06-25 23:24:49 +00:00
devx-ci-bot 8f15e5402b release: v0.14.0
Publish Release / publish (push) Waiting to run
2026-06-26 01:23:45 +02:00
emil 9060cd7b1e DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag 2026-06-25 23:22:41 +00:00
gitea-actions-bot f687ab5aa3 chore: update badge URLs to commit c31f8a46 [skip ci] 2026-06-26 00:59:13 +02:00
gitea-actions-bot 4738b594b2 chore: update badge URLs to commit a398ba43 [skip ci] 2026-06-25 22:58:46 +00:00
devx-ci-bot f6e9f2013b release: v0.13.0
Publish Release / publish (push) Waiting to run
2026-06-25 22:57:37 +00:00
emil 8450f33e88 DEVX-56: feat: add --force flag to classify_changes, fix api_clients coverage 2026-06-25 22:56:31 +00:00
gitea-actions-bot 904812dfae chore: update badge URLs to commit 525d3b70 [skip ci] 2026-06-26 00:11:59 +02:00
emil 95384c26e1 DEVX-55: fix: revert squash-merge format to use colon after task ID 2026-06-25 22:09:46 +00:00
gitea-actions-bot c10b759f6b chore: update badge URLs to commit 47d60f8b [skip ci] 2026-06-25 21:55:13 +00:00
gitea-actions-bot 4c818b32ce chore: update badge URLs to commit 9c9fe0f1 [skip ci] 2026-06-25 23:54:43 +02:00
devx-ci-bot bbf09c07df release: v0.1.0 2026-06-25 21:53:29 +00:00
emil faff67aa6a DEVX-54: fix: squash-merge format uses space not colon after task ID 2026-06-25 21:51:36 +00:00
gitea-actions-bot 3e4dfcadb7 chore: update badge URLs to commit 1c7678eb [skip ci] 2026-06-25 23:41:29 +02:00
gitea-actions-bot 2385747bed chore: update badge URLs to commit cb9e5b47 [skip ci] 2026-06-25 23:41:22 +02:00
devx-ci-bot 3625bf2872 release: v0.12.5
Publish Release / publish (push) Waiting to run
2026-06-25 23:40:08 +02:00
emil 8affccfa35 DEVX-53: fix: make PyPI publish failures non-fatal 2026-06-25 21:39:12 +00:00
gitea-actions-bot 7ae85b6955 chore: update badge URLs to commit e5e2b54b [skip ci] 2026-06-25 23:28:38 +02:00
gitea-actions-bot f702286779 chore: update badge URLs to commit 0ed104f4 [skip ci] 2026-06-25 21:28:25 +00:00
devx-ci-bot d4b58fa86f release: v0.12.4
Publish Release / publish (push) Waiting to run
2026-06-25 23:27:13 +02:00
emil 44c6c42ede DEVX-52: fix: guarantee Gitea release for every tag 2026-06-25 21:26:13 +00:00
gitea-actions-bot 14c585971d chore: update badge URLs to commit dfe5ee1e [skip ci] 2026-06-25 23:04:02 +02:00
emil 3e2342c347 DEVX-51: fix: pass REPO_TOKEN to setup-release so tea login is configured 2026-06-25 21:02:00 +00:00
gitea-actions-bot f3d5b0ff45 chore: update badge URLs to commit d5507246 [skip ci] 2026-06-25 20:56:25 +00:00
devx-ci-bot 75e36897cc release: v0.12.3 [skip ci] 2026-06-25 20:55:23 +00:00
emil 0eef69a902 DEVX-50: refactor: remove JUnit reporting from devx 2026-06-25 20:54:21 +00:00
gitea-actions-bot f9130884d1 chore: update badge URLs to commit 7341990c [skip ci] 2026-06-25 19:32:07 +00:00
devx-ci-bot ba002c2e72 release: v0.12.2 [skip ci] 2026-06-25 21:30:48 +02:00
emil af610d22ec DEVX-49: fix: remove auto-rebase from auto-merge to prevent CI feedback loop 2026-06-25 19:29:45 +00:00
gitea-actions-bot 69a585db2f chore: update badge URLs to commit 1b6a8d99 [skip ci] 2026-06-25 19:14:36 +02:00
devx-ci-bot 700df828ba release: v0.12.1 [skip ci] 2026-06-25 19:13:16 +02:00
emil 4df0602157 DEVX-48: fix: use heredoc syntax for multi-line $GITHUB_ENV values 2026-06-25 17:12:20 +00:00
gitea-actions-bot d7d90fe165 chore: update badge URLs to commit 9a9a53ef [skip ci] 2026-06-25 01:06:19 +02:00
devx-ci-bot 22c2d7c925 release: v0.12.0 [skip ci] 2026-06-24 23:05:23 +00:00
emil b4350751f1 DEVX-47: feat: add Polish as officially supported language 2026-06-24 23:04:20 +00:00
gitea-actions-bot a987b63da7 chore: update badge URLs to commit 85b1ae90 [skip ci] 2026-06-24 22:50:19 +00:00
emil 82d613e23b DEVX-46: docs: add pyproject.toml dependency and pip.conf instructions for devx 2026-06-24 22:48:06 +00:00
gitea-actions-bot 6b6c9d40f1 chore: update badge URLs to commit 90c2559f [skip ci] 2026-06-25 00:35:25 +02:00
emil d17854296a DEVX-45: docs: remove stale .taskid file fallback references 2026-06-24 22:33:35 +00:00
gitea-actions-bot 2fff7ed271 chore: update badge URLs to commit c2065ac1 [skip ci] 2026-06-25 00:28:01 +02:00
devx-ci-bot 0a4d66ab5c release: v0.11.1 [skip ci] 2026-06-25 00:25:51 +02:00
emil 14c9200179 DEVX-44: fix: add build/twine to ci deps, activate venv in notify_failure 2026-06-24 22:24:33 +00:00
gitea-actions-bot 85ae272b1f chore: update badge URLs to commit 389da217 [skip ci] 2026-06-24 22:18:25 +00:00
devx-ci-bot a0c4c1c7f0 release: v0.11.0 [skip ci] 2026-06-24 22:17:36 +00:00
emil fd0c4de31e DEVX-43: feat: add publish step to post-merge release job, make publish idempotent 2026-06-24 22:16:37 +00:00
gitea-actions-bot 8f7af97335 chore: update badge URLs to commit 0bb83e89 [skip ci] 2026-06-24 22:57:46 +02:00
devx-ci-bot a14d838564 release: v0.10.2 [skip ci] 2026-06-24 20:56:35 +00:00
emil 7dcb9c03c0 DEVX-42: fix: badge generation respects pyproject.toml testpaths, shows stdout in warnings 2026-06-24 20:55:35 +00:00
gitea-actions-bot 6f2b110c17 chore: update badge URLs to commit 55e25b06 [skip ci] 2026-06-24 20:34:59 +00:00
devx-ci-bot cfb856ff75 release: v0.10.1 [skip ci] 2026-06-24 20:34:06 +00:00
emil 95adf86895 DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback 2026-06-24 20:33:12 +00:00
gitea-actions-bot 7cf039ebbe chore: update badge URLs to commit 16bc9c20 [skip ci] 2026-06-24 22:04:42 +02:00
devx-ci-bot a17982f2cf release: v0.10.0 [skip ci] 2026-06-24 22:03:14 +02:00
emil cf85964877 DEVX-40: feat: remove .taskid file fallback, use branch name only 2026-06-24 20:02:19 +00:00
gitea-actions-bot fbb1fc3134 chore: update badge URLs to commit ffc2c8a9 [skip ci] 2026-06-24 19:27:17 +00:00
emil a8f86aca68 DEVX-39: fix: use raw/branch/badges/ URLs for badges in README and docs 2026-06-24 19:25:50 +00:00
gitea-actions-bot 13bed1d99c chore: update badge URLs to commit 329cfc69 [skip ci] 2026-06-24 19:03:43 +00:00
devx-ci-bot 107cff5dec release: v0.9.12 [skip ci] 2026-06-24 19:02:56 +00:00
emil cb037aa69c DEVX-38: fix: clean dist/ before build and add workflow_dispatch to publish 2026-06-24 19:02:02 +00:00
gitea-actions-bot 7fa1c4450c chore: update badge URLs to commit 85d87b6f [skip ci] 2026-06-24 20:51:42 +02:00
devx-ci-bot cf2921845b release: v0.9.11 [skip ci] 2026-06-24 20:50:37 +02:00
emil c272150275 DEVX-37: fix: resolve repo_root from GITHUB_WORKSPACE or cwd 2026-06-24 18:49:43 +00:00
gitea-actions-bot 8de91be405 chore: update badge URLs to commit 5b6b6674 [skip ci] 2026-06-24 20:37:25 +02:00
emil 46b8fe5078 DEVX-36: docs: comprehensive documentation rewrite 2026-06-24 18:35:24 +00:00
gitea-actions-bot 7e2a8b4535 chore: update badge URLs to commit f54c01f9 [skip ci] 2026-06-24 17:25:38 +00:00
emil c90518acdb DEVX-35: fix: use raw/branch/badges/ URLs for badges in README and docs 2026-06-24 17:24:05 +00:00
devx-ci-bot 80ca622838 release: v0.9.10 [skip ci] 2026-06-24 18:42:23 +02:00
emil 3f2d19d7ac DEVX-34: fix: retrospective fixes for CI/CD friction 2026-06-24 16:41:22 +00:00
devx-ci-bot c839d49fe3 release: v0.9.9 [skip ci] 2026-06-24 13:17:30 +02:00
emil 93b5d2f926 DEVX-33: fix: use explicit refspecs for git push to avoid tag/branch ambiguity 2026-06-24 11:16:41 +00:00
emil 131c04c9d0 DEVX-32: fix: filter non-version tags in release verification 2026-06-24 11:09:47 +00:00
emil 8e9681cf7d DEVX-31: fix: prefer branch name for task ID extraction + strip heads/ prefix in release 2026-06-24 10:54:58 +00:00
emil 6631525a1d DEVX-30: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets 2026-06-24 10:35:32 +00:00
devx-ci-bot 6985030a3c release: v0.9.8 [skip ci] 2026-06-24 11:15:20 +02:00
emil 4d073f3beb DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets 2026-06-24 09:14:27 +00:00
devx-ci-bot 037d7b0d16 release: v0.9.7 [skip ci] 2026-06-24 02:18:19 +00:00
emil 9cb706e387 DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export 2026-06-24 02:17:20 +00:00
devx-ci-bot 5bd6158f2a release: v0.9.6 [skip ci] 2026-06-24 04:00:50 +02:00
emil 05aa2ffe76 DEVX-27: fix: add Docker socket diagnostics to start_docker 2026-06-24 01:59:55 +00:00
emil b9c3b55680 DEVX-27: fix: add Docker socket diagnostics to start_docker 2026-06-24 01:50:46 +00:00
devx-ci-bot d398c8e971 release: v0.9.5 [skip ci] 2026-06-24 01:37:15 +00:00
emil 39526d8e6a DEVX-26: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd 2026-06-24 01:36:21 +00:00
emil 37730e2187 DEVX-25: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd 2026-06-24 01:24:00 +00:00
devx-ci-bot e2f66ca70a release: v0.9.4 [skip ci] 2026-06-24 01:11:08 +00:00
emil 0b88c211f1 DEVX-24: fix: use separate Docker socket for DinD in CI 2026-06-24 01:08:10 +00:00
devx-ci-bot ea4ee0d303 release: v0.9.3 [skip ci] 2026-06-24 02:46:26 +02:00
emil 16fba17b03 DEVX-23: fix: use tempfile for dockerd log to fix CI permission error 2026-06-24 00:45:28 +00:00
102 changed files with 11565 additions and 1536 deletions
+20
View File
@@ -0,0 +1,20 @@
.venv/
.git/
.gitea/
tests/
docs/
*.egg-info/
__pycache__/
htmlcov/
.coverage
dist/
build/
*.md
!README.md
.env
.env.example
activate.sh
activate.fish
activate.zsh
hooks/
.devin/
+1 -1
View File
@@ -1,6 +1,6 @@
# Gitea API token (required for CI scripts that interact with Gitea)
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
REPO_TOKEN=
CI_GITEA_TOKEN=
# Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+131
View File
@@ -0,0 +1,131 @@
name: Build Images
# Builds and pushes pre-built Docker runner images to the Gitea registry.
# These images eliminate the 40-120s setup tax on every CI job by baking
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
# ci-base → ci-quality → ci-full
#
# Each tier builds FROM the previous one, so they must be built in order.
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
jobs:
detect-type:
runs-on: docker
timeout-minutes: 5
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release
- name: Docker registry login
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
# Build ci-base first (it's the base for ci-quality and ci-full)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-base/Dockerfile \
--name oblachno-oss/runner-images/ci-base \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push --pull
# Build ci-quality (FROM ci-base-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
# Build ci-full (FROM ci-quality-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-full/Dockerfile \
--name oblachno-oss/runner-images/ci-full \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}"
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Clean up old image versions
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.clean_images \
--owner oblachno-oss \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+42 -16
View File
@@ -8,11 +8,15 @@ on:
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-quality
run: make setup-image
- name: Lint all
run: |
. .venv/bin/activate
@@ -60,7 +64,11 @@ jobs:
detect-changes:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
@@ -68,7 +76,7 @@ jobs:
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Detect changed paths
id: detect
env:
@@ -84,32 +92,42 @@ jobs:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-image
- name: Release dry-run validation
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run || true
python3 -m devx.ci.release --dry-run
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Run automated PR review
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
@@ -120,24 +138,31 @@ jobs:
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# from .taskid file, validates the PR title, and squash-merges.
# No manual label or review needed — CI is the quality gate.
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
needs: [quality, detect-changes, pr-review, release-dry-run]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
run: make setup-image
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
@@ -146,6 +171,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+122 -60
View File
@@ -1,30 +1,30 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# replaces separate workflows for release, wiki sync, badges, and
# Vikunja task updates.
# for release, publish, wiki sync, badges, and Vikunja task updates.
#
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# 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)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (needs release — skip if release commit/fails)
# └── vikunja (needs release — skip if release commit/fails)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# sync-wiki and vikunja depend on release succeeding so that the wiki
# and task tracker are only updated when the code is actually released.
# If release fails, they are skipped to avoid leaving the wiki or
# Vikunja in an inconsistent state with the codebase on master.
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job depends on release so it picks up the latest version
# number. It 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()` 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.
#
# When release creates a "release: vX.Y.Z" commit, the release
# commit's post-merge run still updates badges (version badge picks
# up the new version). Other jobs skip. The tag push triggers publish.yml.
# 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
# Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
on:
push:
@@ -33,40 +33,47 @@ on:
jobs:
detect-type:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-image
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: python3 -m devx.ci.detect_release_commit
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages click python-dotenv
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-image
- name: Validate latest commit message
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
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
@@ -75,19 +82,28 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
run: make setup-release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-image
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
- name: Run release
id: release-tag
env:
PYTHONPATH: src
run: |
@@ -97,33 +113,73 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
run: make setup-image EXTRAS=release
- name: Build and publish release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
@@ -131,7 +187,7 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
@@ -142,22 +198,26 @@ jobs:
--commit "${{ github.sha }}"
badges:
needs: [detect-type, release]
needs: [detect-type]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Generate and push badges
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
@@ -167,7 +227,7 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
@@ -178,34 +238,35 @@ jobs:
--commit "${{ github.sha }}"
vikunja:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-image
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
run: |
. .venv/bin/activate
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
@@ -216,28 +277,29 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-image
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
-49
View File
@@ -1,49 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages build twine requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Install CI tools
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
- name: Configure tea login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
export PATH="$HOME/.local/bin:$PATH"
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ github.ref_name }}" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+3
View File
@@ -35,3 +35,6 @@ Thumbs.db
# Badges
.badges/
# Deprecated CI task tracking (branch name is the sole source of truth)
.taskid
+14
View File
@@ -0,0 +1,14 @@
# Hadolint configuration for devx Dockerfiles
# https://github.com/hadolint/hadolint#configure
ignored:
- DL3008 # Don't require pinning apt package versions
- DL3013 # Don't require pinning pip package versions
- DL3018 # Don't require pinning apk package versions
- DL3007 # Using latest is intentional for tier images (rebuilt on every merge)
- SC2102 # False positive: pip extras [release,molecule,deploy] look like shell ranges
trustedRegistries:
- git.oblachno.oblachno.fyi
- docker.io
- gitea/runner-images
-1
View File
@@ -1 +0,0 @@
DEVX-22
+211 -20
View File
@@ -4,8 +4,8 @@
```bash
make setup # Create venv, install deps, set up hooks, install CI tools
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
@@ -16,8 +16,8 @@ make clean # Remove caches, build artifacts, coverage data
`make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
- **actionlint, git-cliff, act_runner, tea, hadolint** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
## Workflow Verification (Before Push)
@@ -57,6 +57,7 @@ src/devx/
│ ├── 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
│ ├── detect_release_commit.py # Detect release commits on master
@@ -66,22 +67,28 @@ src/devx/
│ ├── 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)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
│ ├── configure_repo.py # Branch protection and label setup
│ └── generate_badges.py # Badge SVG generation
├── 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 (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── 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
└── platforms.py # Supported molecule platforms
```
@@ -163,7 +170,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
@@ -177,7 +184,7 @@ After a PR is merged to master, the **post-merge workflow**
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits.
release commits (except badges).
2. **release** — Runs `python -m devx.ci.release` which:
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
@@ -189,14 +196,20 @@ After a PR is merged to master, the **post-merge workflow**
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Uses `if: always()` so it runs on every push, including release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only
changes still update the task tracker.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
which builds and publishes the package to the Gitea PyPI registry.
6. **publish** — Runs after release succeeds (needs: release). Builds and
publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output (written via `GITHUB_OUTPUT`).
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -250,7 +263,7 @@ so `.:src` is not needed. The `src` directory is the sole import root.
The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
by `python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
`python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
**`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
- `create_issue()` — Create issues with labels
@@ -260,7 +273,7 @@ by `python -m devx.tools.install_tools` and configured by
### git-cliff Commit Preprocessing
Merge commits on master have the format `DEVX-N <conventional commit>`. The
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
prefix before parsing. This ensures all merged work appears in the changelog.
@@ -283,7 +296,52 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
| Branch commits | `<conventional commit>` | `feat: add release script` |
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
### Task ID Resolution
`auto_merge` resolves the task ID solely from the branch name (e.g.
`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.
`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.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
### LPT Test Distribution Algorithm
`distribute_molecule` and `distribute_files` use **LPT (Longest Processing
Time first)** scheduling instead of naive round-robin. This produces a more
balanced distribution when test items have varying costs:
1. **Weight estimation**: Each item is assigned a weight:
- Molecule scenarios: heuristic by name (`nextcloud`=10, `gitea`=8,
`binary`=2, default=3). See `_SCENARIO_WEIGHTS` in
`distribute_molecule.py`.
- Integration test files: weight by file size in bytes (as a proxy
for test runtime).
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
different runners rather than clustered on one, reducing the
longest-runner time from ~16 min to ~11 min with 6 runners.
## Config System
@@ -295,8 +353,12 @@ devx uses environment variables with `.env` file fallback for configuration.
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `REPO_TOKEN` | (from .env) | Gitea API token |
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
### Per-Project Overrides
@@ -305,6 +367,135 @@ Projects using devx can override the default API URLs and language by setting
`DEVX_*` environment variables or entries in their `.env` file. The config
system loads `.env` automatically via `python-dotenv`.
### pyproject.toml [tool.devx] Configuration
In addition to `DEVX_` env vars, several devx tools read configuration from
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"`)
- `vikunja_project_id` — Vikunja project ID
- `repo_owner` / `repo_name` — Gitea repository coordinates
- `gitea_api_url` / `vikunja_api_url` — API endpoints
**Tool-specific config**:
- `[tool.devx.check_mutable_globals]` — `scan_dirs`, `skip_dirs`, `known_safe`
- `[tool.devx.check_test_coverage]` — `rules` (source_pattern → test_paths mapping), `skip_patterns`
- `[tool.devx.check_agent_docs]` — `scan_dirs`, `deleted_files`, `deprecated_patterns`, `legitimate_indicators`
## devx.mak — Shared Makefile Fragment
`devx.mak` provides common Makefile targets that projects can include
via `-include $(DEVX_MAK)`. This eliminates Makefile duplication across
projects.
**Available targets** (all prefixed with `devx-`):
| Target | Purpose |
|--------|---------|
| `devx-create-task` | Create a Vikunja task |
| `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-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, hadolint |
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
| `devx-checkmake` | Lint Makefiles with checkmake |
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
| `devx-workflow-dryrun` | Dry-run all workflows (act_runner) |
| `devx-workflow-dryrun-safe` | Best-effort dry-run (skips if act_runner missing) |
| `devx-workflow-check` | Static lint + dry-run |
| `devx-notify-failure` | Create Gitea issue on CI failure |
| `devx-lint-ruff` | Run ruff check |
| `devx-lint-format` | Run ruff format --check |
| `devx-typecheck` | Run pyright |
| `devx-lint-bandit` | Run bandit security scan |
| `devx-lint-deps` | Check dependencies for vulnerabilities (pip-audit) |
| `devx-lint` | Run all lint targets |
| `devx-test-unit` | Run unit tests without coverage |
| `devx-pytest-cov` | Run pytest with coverage enforcement |
| `devx-check-mutable-globals` | Scan for mutable path globals |
| `devx-check-dep-docs` | Validate pyproject.toml deps are documented |
| `devx-check-test-coverage` | Check changed files have corresponding tests |
| `devx-check-docs` | Validate docs for stale references |
| `devx-check-test-speed` | Verify test suite timing |
| `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
| `devx-lint-dockerfiles` | Lint Dockerfiles with hadolint (fail-fast, parameterized by `DEVX_DOCKERFILE_PATHS`) |
| `devx-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed |
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
**Variables** (set BEFORE including devx.mak):
- `DEVX_PYTHON` — Python executable (default: `python3`)
- `DEVX_VENV` — venv directory (default: `.venv`)
- `DEVX_BIN` — venv bin directory (default: `$(DEVX_VENV)/bin`)
- `DEVX_LINT_PATHS` — paths for ruff/bandit (default: `src/ tests/`)
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`)
- `DEVX_DOCKERFILE_PATHS` — directory to search for Dockerfiles (default: `docker`)
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
## Pre-built Docker Runner Images
devx builds and publishes three tier images to the Gitea container registry
to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs |
|-------|----------|-------------|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
2. `ci-quality` builds FROM `ci-base-latest`
3. `ci-full` builds FROM `ci-quality-latest`
Each image is tagged `latest` and pushed to
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
**Using images in workflows**:
```yaml
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image # links /opt/venv, installs project (no-deps)
```
**Image build/push tools** (tested Python modules):
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
**Usage in project Makefile**:
```makefile
DEVX_PYTHON := $(BIN)/python
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 for project-specific names
lint-ruff: devx-lint-ruff
workflow-lint: devx-workflow-lint
create-task: devx-create-task
```
## Key Conventions
- Python 3.12+ required (ruff/pyright target `py312`)
+288
View File
@@ -2,6 +2,294 @@
All notable changes to this project will be documented in this file.
## [0.23.0] - 2026-06-27
### Features
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
### Refactor
- Remove hadolint on-the-fly install from setup-image
## [0.22.1] - 2026-06-27
### Bug Fixes
- Checkout release tag in publish job
- Fail lint-dockerfiles when hadolint is missing
## [0.22.0] - 2026-06-27
### Features
- Document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table
## [0.21.2] - 2026-06-27
### Bug Fixes
- Gate auto-merge on release-dry-run and unmask failures
## [0.21.1] - 2026-06-27
### Bug Fixes
- Devx-setup-image configures Gitea PyPI registry and shows pip errors
## [0.21.0] - 2026-06-27
### Features
- Add --auto-login to publish, extract configure_tea_login to gitea_cli
### Bug Fixes
- Publish job uses setup-release for build + tea login
- Remove tag fallback step from release workflow
## [0.20.3] - 2026-06-27
### Bug Fixes
- Release publish failures and duplicate release commits
## [0.20.2] - 2026-06-27
## [0.20.2] - 2026-06-27
### Bug Fixes
- Correct sed substitution in ci-full Dockerfile
## [0.20.1] - 2026-06-27
### Bug Fixes
- Correct image references in tier Dockerfiles
## [0.20.0] - 2026-06-27
### Features
- Add pre-built Docker runner images and tested image build/push tools
## [0.19.3] - 2026-06-26
### Refactor
- Make molecule weights configurable via pyproject.toml
## [0.19.2] - 2026-06-26
### Bug Fixes
- Calibrate molecule weights from actual CI execution times
## [0.19.1] - 2026-06-26
### Refactor
- Consolidate publish.yml into post-merge.yml
## [0.19.0] - 2026-06-26
### Features
- Add skip_ref_prefixes config to check_agent_docs
## [0.18.0] - 2026-06-26
### Features
- Extract generic tools into devx, expand devx.mak, remove personal references
## [0.17.0] - 2026-06-26
### Features
- Weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
## [0.16.0] - 2026-06-26
### Features
- Single-source-of-truth config via [tool.devx] in pyproject.toml
## [0.15.0] - 2026-06-26
### Features
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
## [0.14.2] - 2026-06-26
### Bug Fixes
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
## [0.14.1] - 2026-06-25
### Bug Fixes
- Handle 'already a release' error idempotently in publish
## [0.14.0] - 2026-06-25
### Features
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
## [0.13.0] - 2026-06-25
### Features
- Add --force flag to classify_changes, fix api_clients coverage
### Bug Fixes
- Squash-merge format uses space not colon after task ID
- Revert squash-merge format to use colon after task ID
## [0.1.0] - 2026-06-25
## [0.12.5] - 2026-06-25
### Bug Fixes
- Make PyPI publish failures non-fatal
## [0.12.4] - 2026-06-25
### Bug Fixes
- Pass REPO_TOKEN to setup-release so tea login is configured
- Guarantee Gitea release for every tag
## [0.12.3] - 2026-06-25
### Refactor
- Remove JUnit reporting from devx
## [0.12.2] - 2026-06-25
### Bug Fixes
- Remove auto-rebase from auto-merge to prevent CI feedback loop
## [0.12.1] - 2026-06-25
### Bug Fixes
- Use heredoc syntax for multi-line $GITHUB_ENV values
## [0.12.0] - 2026-06-24
### Features
- Add Polish as officially supported language
## [0.11.1] - 2026-06-24
### Bug Fixes
- Add build/twine to ci deps, activate venv in notify_failure
## [0.11.0] - 2026-06-24
### Features
- Add publish step to post-merge release job, make publish idempotent
## [0.10.2] - 2026-06-24
### Bug Fixes
- Badge generation respects pyproject.toml testpaths, shows stdout in warnings
## [0.10.1] - 2026-06-24
### Bug Fixes
- Badge generation REPO_ROOT, auto-detect package, error feedback
## [0.10.0] - 2026-06-24
### Features
- Remove .taskid file fallback, use branch name only
### Bug Fixes
- Use raw/branch/badges/ URLs for badges in README and docs
## [0.9.12] - 2026-06-24
### Bug Fixes
- Clean dist/ before build and add workflow_dispatch to publish
## [0.9.11] - 2026-06-24
### Bug Fixes
- Use raw/branch/badges/ URLs for badges in README and docs
- Resolve repo_root from GITHUB_WORKSPACE or cwd
## [0.9.10] - 2026-06-24
### Bug Fixes
- Retrospective fixes for CI/CD friction
## [0.9.9] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
- Prefer branch name for task ID extraction + strip heads/ prefix in release
- Filter non-version tags in release verification
- Use explicit refspecs for git push to avoid tag/branch ambiguity
## [0.9.8] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
## [0.9.7] - 2026-06-24
### Bug Fixes
- Add rootless socket fallback and GITHUB_ENV export
## [0.9.6] - 2026-06-24
### Bug Fixes
- Add Docker socket diagnostics to start_docker
- Add Docker socket diagnostics to start_docker
## [0.9.5] - 2026-06-24
### Bug Fixes
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
## [0.9.4] - 2026-06-24
### Bug Fixes
- Use separate Docker socket for DinD in CI
## [0.9.3] - 2026-06-24
### Bug Fixes
- Use tempfile for dockerd log to fix CI permission error
## [0.9.2] - 2026-06-24
### Bug Fixes
+3 -3
View File
@@ -208,8 +208,8 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
grm
Copyright (C) 2026 emil
devx
Copyright (C) 2026 oblachno-oss
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
grm Copyright (C) 2026 emil
devx Copyright (C) 2026 oblachno-oss
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+80 -39
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-ruff lint-format typecheck lint-bandit lint-deps lint-all test test-unit pytest-cov clean workflow-lint workflow-dryrun workflow-check install-tools install-hooks activate-scripts
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
PYTHON := python3
VENV := .venv
@@ -25,10 +25,18 @@ setup-quality: $(VENV)/bin/activate .env install-tools
# Setup for release jobs (needs git-cliff, tea, lint tools)
setup-release: $(VENV)/bin/activate .env
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
@$(BIN)/pip install -e '.[ci,lint,release]' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
# 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; \
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
@@ -52,48 +60,81 @@ install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
lint-ruff:
$(BIN)/ruff check src/ tests/
# --- 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/
lint-format:
$(BIN)/ruff format --check src/ 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)
typecheck:
$(BIN)/pyright
# Aliases — project-specific names map to devx.mak targets
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
lint-bandit: devx-lint-bandit
lint-deps: devx-lint-deps
lint: devx-lint
workflow-lint: devx-workflow-lint
workflow-dryrun: devx-workflow-dryrun
workflow-dryrun-safe: devx-workflow-dryrun-safe
workflow-check: devx-workflow-check
notify-failure: devx-notify-failure
checkmake: devx-checkmake
check-mutable-globals: devx-check-mutable-globals
check-dep-docs: devx-check-dep-docs
check-test-speed: devx-check-test-speed
check-test-coverage: devx-check-test-coverage
check-docs: devx-check-docs
create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
lint-bandit:
$(BIN)/bandit -r src/
lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed."
lint: lint-ruff lint-format typecheck lint-bandit
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@find docker -name 'Dockerfile*' -exec hadolint {} +
@echo "[lint-dockerfiles] All Dockerfiles passed."
lint-deps:
@echo "Checking dependencies for known vulnerabilities..."
@.venv/bin/python -m ensurepip 2>/dev/null || true
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python .venv/bin/pip-audit --desc --skip-editable 2>&1 || true
test-unit: devx-test-unit
lint-all: lint workflow-lint
workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { echo "actionlint not found."; exit 1; }
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
workflow-dryrun:
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found."; exit 1; }
@echo "Dry-running all workflows..."
act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
workflow-check: workflow-lint workflow-dryrun
@echo "Workflow checks passed."
test-unit:
$(BIN)/pytest tests/unit/ -v --no-cov
pytest-cov:
$(BIN)/pytest tests/ -v --cov=src/devx --cov-report=term-missing --cov-fail-under=100
pytest-cov: devx-pytest-cov
test: pytest-cov
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/
pre-push: lint-all pytest-cov
@echo "[pre-push] All checks passed. Proceeding with push."
clean: devx-clean
@echo "[clean] Done."
# ── Docker image management ──────────────────────────────────────────────────
build-images: devx-build-images
@echo "[build-images] Done."
push-images: devx-push-images
@echo "[push-images] Done."
build-images-dry-run: devx-build-images-dry-run
@echo "[build-images-dry-run] Done."
clean-images: devx-clean-images
@echo "[clean-images] Done."
+371 -29
View File
@@ -1,46 +1,141 @@
# devx — Reusable Development & CI/CD Tools
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects. devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package.
A Python package providing reusable development and CI/CD automation tools for
oblachno-oss projects. devx consolidates release management, PR automation,
wiki sync, badge generation, translation checks, documentation coverage,
parallel test distribution, and more into a single installable package.
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
project to be reusable across all oblachno-oss repositories. Any project hosted
on a Gitea instance with Gitea Actions can install devx and inherit a complete,
opinionated CI/CD pipeline: conventional commits, automated versioning via
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
quality badges.
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![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/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/python.svg)](https://www.python.org/downloads/)
## Why devx?
Every oblachno-oss project shares the same CI/CD needs: automated releases,
PR review, task tracking, documentation sync, and quality badges. Without a
shared package, each repository duplicates this logic in shell scripts and
workflow YAML, leading to drift, bugs, and maintenance burden.
devx solves this by providing a single, tested Python package that any
oblachno-oss project can install. The project declares its configuration via
environment variables and `pyproject.toml`, and devx handles the rest. Updates
to the CI/CD pipeline ship as new devx releases — consumer projects pick them
up by bumping their devx dependency.
### Key features
- **Automated releases** — git-cliff-driven semver versioning, changelog
generation, tagging, and publishing to a Gitea PyPI registry.
- **PR automation** — squash-merge with task ID validation, automated PR
review with inline comments, and conventional commit enforcement.
- **Smart change classification** — user-facing vs workflow-only change
detection so infrastructure-only changes skip releases.
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
integrity verification.
- **Quality badges** — generate self-contained SVG badges for coverage,
tests, docs, quality, version, and Python version.
- **Translation checks** — validate i18n keys against source code, detect
dead keys and missing languages.
- **Parallel test distribution** — split test files or molecule scenarios
across CI runners with cross-runner fail-fast.
- **Developer tools** — environment setup, CI tool installation, test speed
enforcement, repository configuration.
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
### Persistent configuration (recommended)
```bash
pip install devx
Add the registry to `~/.pip/pip.conf` so `pip install devx` works without
specifying `--index-url` every time:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
## Usage
### As a dependency in another project
### CI/CD Automation
To use devx as a dependency in your `pyproject.toml`, add the registry as an
extra index and list devx in your dependencies:
devx provides CI/CD modules invoked via `python -m devx.ci.*`:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Then install normally:
```bash
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"`).
### Optional extras
devx ships optional dependency groups for different use cases:
```bash
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
## Quick start
After installing devx, set the required environment variables (see
[Configuration](#configuration)) and invoke modules via `python -m devx.*` or
the `devx` CLI.
### CI/CD automation
CI/CD modules are invoked via `python -m devx.ci.*`. Each module is also
available as a `devx ci <command>` subcommand.
```bash
# Release automation (versioning, changelog, tagging)
python -m devx.ci.release
python -m devx.ci.release --dry-run
python -m devx.ci.release --dry-run # preview without changes
python -m devx.ci.release --verify # check tag/version/changelog alignment
# Publish a release to the Gitea PyPI registry
python -m devx.ci.publish v1.0.0 oblachno-oss/devx
python -m devx.ci.publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
# Automated PR review
# Automated PR review (posts inline comments and structured review)
python -m devx.ci.pr_review 42 oblachno-oss/devx
# Auto-merge a PR (validates title, squash-merges)
@@ -54,9 +149,11 @@ python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
# Generate and push quality badges
python -m devx.ci.push_badges
python -m devx.ci.push_badges --retries 3 # retry on git push failures
# Check translation completeness
python -m devx.ci.check_translations
python -m devx.ci.check_translations --translations path/to/translations.json
# Documentation coverage check
python -m devx.ci.doc_coverage --fail-on-missing
@@ -64,56 +161,225 @@ python -m devx.ci.doc_coverage --fail-on-missing
# Validate a commit message
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
# Detect whether the latest commit is a release commit
python -m devx.ci.detect_release_commit
# Notify on CI failure (creates a Gitea issue)
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 --workflow ci --commit abc123
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 \
--workflow ci --commit abc123 --auto-login
# Discover available Gitea Actions runners
python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
# Distribute files across parallel runners (round-robin)
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
--runner-index 1 --max-runners 3 --github-env
# Run pytest with cross-runner fail-fast
python -m devx.ci.integration_guard -- test_a.py test_b.py
```
### Developer Tools
### Developer tools
devx provides developer tooling invoked via `python -m devx.tools.*`:
Developer tooling modules are invoked via `python -m devx.tools.*` or the
`devx tools <command>` subcommand.
```bash
# Set up a development environment (venv, deps, hooks)
# Set up a development environment (venv, deps, hooks, tea login)
python -m devx.tools.setup --bin .venv/bin
python -m devx.tools.setup --bin .venv/bin --extras "ci,lint" --no-pre-commit
# Install CI tools (actionlint, git-cliff, act_runner, tea)
python -m devx.tools.install_tools
python -m devx.tools.install_tools --tool git-cliff --tool tea
python -m devx.tools.install_tools --list
# Install checkmake (Makefile linter)
python -m devx.tools.install_checkmake
# Check unit test speed
python -m devx.tools.check_test_speed --max-seconds 10
python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
# Configure repository (branch protection, labels)
python -m devx.tools.configure_repo
python -m devx.tools.configure_repo --repo devx --owner oblachno-oss
# Generate badge SVG files locally
python -m devx.tools.generate_badges --output-dir .badges/
# Generate a cliff.toml with the correct task ID prefix
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
```
### CLI
### Molecule testing (optional)
devx also provides a `devx` CLI command:
For projects with Ansible roles, devx provides molecule testing helpers via
`python -m devx.molecule.*` or `devx molecule <command>`.
```bash
# Distribute molecule scenarios across parallel runners
python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
python -m devx.molecule.distribute_molecule --list # list all scenarios
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
# Run molecule tests with cross-runner fail-fast
python -m devx.molecule.molecule_ci_guard pair1 pair2
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
# Run all molecule scenarios locally (sequential)
python -m devx.molecule.molecule_all
python -m devx.molecule.molecule_all --bin .venv/bin
# Discover available Gitea Actions runners for molecule tests
python -m devx.molecule.discover_runners --indices
# Ensure Docker is available for molecule tests in CI
python -m devx.molecule.start_docker
```
### OpenTofu helpers
devx provides reusable functions for extracting values from `tofu output`:
```python
from devx.opentofu import get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
```
## CLI commands overview
devx provides a `devx` CLI command with three command groups:
```bash
devx --help
devx --version
```
### Configuration
### `devx ci` — CI/CD automation
devx reads configuration from environment variables with `.env` file fallback:
| Command | Description |
|---------|-------------|
| `devx ci auto-merge` | Squash-merge a PR with task ID validation |
| `devx ci check-translations` | Check translation files for gaps and dead keys |
| `devx ci classify-changes` | Classify git changes as user-facing or workflow-only |
| `devx ci detect-release-commit` | Detect whether the latest commit is a release commit |
| `devx ci discover-runners` | Discover available Gitea Actions runners |
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
| `devx ci doc-coverage` | Check documentation coverage for CLI commands and modules |
| `devx ci integration-guard` | Run pytest with cross-runner fail-fast |
| `devx ci notify-failure` | Create a Gitea issue when a CI workflow fails |
| `devx ci post-merge` | Update Vikunja task after a merge to master |
| `devx ci pr-review` | Run automated PR review |
| `devx ci publish` | Build package, publish to registry, create Gitea release |
| `devx ci push-badges` | Generate badge SVG files and push to the badges branch |
| `devx ci release` | Automated release: version, changelog, tag, push |
| `devx ci sync-wiki` | Sync documentation from docs/ to the Gitea wiki |
| `devx ci validate-commit-msg` | Validate commit messages for conventional format |
### `devx tools` — Developer tools
| Command | Description |
|---------|-------------|
| `devx tools check-test-speed` | Run unit tests and enforce execution-time budgets |
| `devx tools configure-repo` | Configure branch protection and labels via Gitea API |
| `devx tools generate-badges` | Generate self-contained SVG badge files |
| `devx tools generate-cliff-config` | Generate a cliff.toml with the correct task ID prefix |
| `devx tools install-checkmake` | Install checkmake (Makefile linter) |
| `devx tools install-tools` | Install actionlint, git-cliff, act_runner, tea |
| `devx tools setup` | Project setup: install deps, hooks, tea login |
### `devx molecule` — Molecule testing (optional)
| Command | Description |
|---------|-------------|
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
| `devx molecule guard` | Run molecule tests with CI failure polling |
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
in the wiki for full command documentation with examples.
## Configuration
devx reads configuration from environment variables with `.env` file fallback.
The config system loads `.env` automatically via `python-dotenv`.
### DEVX_ environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_LANG` | `en` | Language (en, bg) |
| `REPO_TOKEN` | — | Gitea API token |
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
| `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, de, ru, zh, pl) |
| `DEVX_TRANSLATIONS_PATH` | — | Path to a custom JSON translations file |
| `DEVX_VERSION_FILE` | `src/devx/__init__.py` | Version source file (used by release) |
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
| `CI_GITEA_TOKEN` | — | Gitea API token (see scopes below) |
| `CI_GITEA_USERNAME` | — | Gitea username for registry authentication |
| `VIKUNJA_TOKEN` | — | Vikunja API token |
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
Copy `.env.example` to `.env` and fill in your tokens:
#### CI_GITEA_TOKEN scopes
The `CI_GITEA_TOKEN` is a single Gitea Personal Access Token used across all
workflows. It requires these scopes:
| Scope | Purpose |
|-------|---------|
| `read:repository` | Read repos, PRs, issues, branches |
| `write:repository` | Push commits, merge PRs, create tags/releases, create issues, set branch protection, push wiki |
| `read:package` | Pull packages from Gitea PyPI registry, pull Docker images |
| `write:package` | Publish packages to Gitea PyPI registry, push Docker images |
| `read:organization` | Query org-level runners for molecule test distribution |
### Per-project overrides
Projects using devx can override the default API URLs and language by setting
`DEVX_*` environment variables or entries in their `.env` file. Copy
`.env.example` to `.env` and fill in your tokens:
```bash
cp .env.example .env
```
### Change classification
Projects configure which file paths are infrastructure (no release needed) vs
user-facing (release needed) in `pyproject.toml`:
```toml
[tool.devx.classify]
# Merge with DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs, config)
# use_defaults = true # (default)
# Project-specific infrastructure paths (merged with defaults)
infrastructure = []
# Files that would default to user-facing but are actually infrastructure
infrastructure_overrides = [
"src/myproject/__init__.py", # only contains __version__
]
# Safety override for broad infrastructure patterns
user_facing_overrides = []
# Tag patterns for CI conditional execution (orthogonal to release impact)
[tool.devx.classify.tags]
# ansible = ["ansible/**"]
```
## Development
```bash
@@ -122,10 +388,86 @@ cd devx
make setup # Create venv, install deps, hooks, CI tools
make lint-all # ruff + pyright + bandit + actionlint
make pytest-cov # Unit tests with 100% coverage
make test-unit # Unit tests without coverage
make workflow-check # Static + dry-run validation of workflow YAML
make clean # Remove caches, build artifacts, coverage data
```
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and architecture details.
`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`
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env`)
### Make targets
| Target | Description |
|--------|-------------|
| `make setup` | Full local development setup (venv, deps, hooks, CI tools) |
| `make setup-ci` | Lean setup for CI jobs (pytest + lint + runtime deps) |
| `make setup-quality` | Setup for quality job (lint + test deps, actionlint) |
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea |
| `make install-hooks` | Install git hooks (pre-commit, pre-push) |
| `make lint` | ruff check + ruff format check + pyright + bandit |
| `make lint-ruff` | ruff check only |
| `make lint-format` | ruff format check only |
| `make typecheck` | pyright only |
| `make lint-bandit` | bandit security scan only |
| `make lint-all` | lint + workflow-lint (actionlint) |
| `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-dryrun` | act_runner exec --dryrun on all workflows |
| `make workflow-check` | workflow-lint + workflow-dryrun |
| `make clean` | Remove caches, build artifacts, coverage data |
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and
architecture details.
## Architecture overview
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.*`.
```
src/devx/
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
├── config.py # Configuration system (DEVX_ env vars, .env loading)
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── opentofu.py # OpenTofu output helpers
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── ci/ # CI/CD automation modules (run by workflows)
├── tools/ # Developer tooling modules (run locally or by CI)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
```
### Design principles
- **Self-contained package** — `src/devx/` never imports from scripts outside the package
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*` or `python -m devx.tools.*`
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (not `.:src` since there are no scripts at repo root)
- **Config via env vars** — `DEVX_*` environment variables with `.env` file fallback
- **100% test coverage** — enforced by `--cov-fail-under=100`
- **i18n by default** — all user-facing strings wrapped in `_()` for translation
See [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/Architecture)
and [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CI-CD-Workflow)
in the wiki for detailed documentation.
## Links
- **Wiki**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
## License
GPL-3.0
GPL-3.0 — see [LICENSE](LICENSE).
+26
View File
@@ -0,0 +1,26 @@
# ci-base — lightweight image for CI jobs that only need devx core + tea.
#
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
# molecule-report, discover-integration-runners
#
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
# No pip install needed — devx and all deps are pre-installed.
FROM gitea/runner-images:ubuntu-latest
# Create a virtual environment with all deps pre-installed
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
# Install devx from local source (build context = devx repo root)
COPY . /tmp/devx
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir /tmp/devx[ci] \
&& rm -rf /tmp/devx
# Install tea CLI (for Gitea API operations in CI)
RUN python3 -m devx.tools.install_tools --tool tea
# Workspace directory (actions/checkout mounts repo here)
WORKDIR /workspace
+26
View File
@@ -0,0 +1,26 @@
# ci-full — heaviest image, includes everything for release, molecule, deploy.
#
# Used by: release, publish, release-dry-run, molecule-tests,
# provision-infra, deploy-observability, provision-zitadel,
# deploy-customer, integration-tests
#
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
# git-cliff, and OpenTofu.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
&& rm -rf /tmp/devx
# Install git-cliff (changelog generator for release job)
RUN python3 -m devx.tools.install_tools --tool git-cliff
# Install OpenTofu (for infra deploy jobs)
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/') \
&& VERSION=1.12.3 \
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin tofu
+22
View File
@@ -0,0 +1,22 @@
# ci-quality — image for lint, type-checking, badge generation.
#
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
# runs ruff/pyright/bandit to produce quality badge)
#
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx
# Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \
&& python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter)
RUN curl -fsSL "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint \
&& chmod +x /usr/local/bin/hadolint
+20
View File
@@ -0,0 +1,20 @@
[
{
"name": "oblachno-oss/runner-images/ci-base",
"dockerfile": "docker/ci-base/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-full",
"dockerfile": "docker/ci-full/Dockerfile",
"context": ".",
"tags": ["latest"]
}
]
+158 -8
View File
@@ -1,24 +1,174 @@
# devx — Reusable Development & CI/CD Tools
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects.
A Python package providing reusable development and CI/CD automation tools for
oblachno-oss projects. devx consolidates release management, PR automation,
wiki sync, badge generation, translation checks, documentation coverage,
parallel test distribution, and more into a single installable package.
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
project to be reusable across all oblachno-oss repositories.
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![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/1d6085cf2f698751ef4d1509eff3b64b945e8d40/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1d6085cf2f698751ef4d1509eff3b64b945e8d40/python.svg)](https://www.python.org/downloads/)
## Overview
devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss projects.
devx provides a complete, opinionated CI/CD pipeline for any project hosted on
a Gitea instance with Gitea Actions. Install the package, declare configuration
via environment variables and `pyproject.toml`, and inherit:
- **Automated releases** — git-cliff-driven semver versioning, changelog
generation, tagging, and publishing to a Gitea PyPI registry.
- **PR automation** — squash-merge with task ID validation, automated PR
review with inline comments, and conventional commit enforcement.
- **Smart change classification** — user-facing vs workflow-only change
detection so infrastructure-only changes skip releases.
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
integrity verification.
- **Quality badges** — self-contained SVG badges for coverage, tests, docs,
quality, version, and Python version.
- **Translation checks** — validate i18n keys against source code, detect
dead keys and missing languages.
- **Parallel test distribution** — split test files or molecule scenarios
across CI runners with cross-runner fail-fast.
- **Developer tools** — environment setup, CI tool installation, test speed
enforcement, repository configuration.
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
### Persistent configuration (recommended)
Add the registry to `~/.pip/pip.conf`:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then `pip install devx` works without specifying `--index-url`.
### As a dependency in another project
Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[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"`.
### Optional extras
```bash
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
## Architecture
- **Core modules** — config, exceptions, i18n, api_clients, gitea_cli
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review, classify_changes, etc.
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible roles
devx is a self-contained Python package under `src/devx/`:
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) for full project conventions.
- **Core modules** — `config.py`, `exceptions.py`, `i18n.py`, `api_clients.py`,
`gitea_cli.py`, `cli.py`, `opentofu.py`
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
discover_runners, distribute_files, integration_guard
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
configure_repo, generate_badges, generate_cliff_config, install_checkmake
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
start_docker, platforms
See [Architecture](Architecture) for the full package structure, module
descriptions, design principles, and data flow diagrams.
## CI/CD pipeline
devx uses Gitea Actions with three workflows:
- **CI** (`ci.yml`) — runs on pull requests: quality checks, change detection,
release dry-run, automated PR review, and auto-merge.
- **Post-merge** (`post-merge.yml`) — runs on every push to master: release
versioning, wiki sync, badge generation, Vikunja task updates, and repo
configuration.
- **Publish** (`publish.yml`) — runs on tag pushes: builds the package,
publishes to the Gitea PyPI registry, and creates a Gitea release.
See [CI/CD Workflow](CI-CD-Workflow) for the full pipeline documentation,
including the post-merge job graph, release process, badge generation, and
wiki sync details.
## CLI commands
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 molecule <command>` — Molecule testing (4 commands, optional)
See [CLI Commands](CLI-Commands) for full command documentation with examples.
## Configuration
devx reads configuration from `DEVX_*` environment variables with `.env` file
fallback. Key variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_REPO_OWNER` | **(must be set)** | Repository owner |
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `CI_GITEA_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token |
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
for the full configuration reference, PR workflow, and project conventions.
## Wiki pages
- [Home](Home) — This page
- [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
## Links
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
## License
GPL-3.0
+562 -29
View File
@@ -1,50 +1,583 @@
# Architecture
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
devx is a reusable Python package providing development and CI/CD tools for
oblachno-oss projects. It is self-contained under `src/devx/` and never imports
from scripts outside the package.
## Package Structure
## Package structure
```
src/devx/
├── __init__.py # Version (single source of truth)
├── cli.py # Click-based CLI entry point (devx command)
├── config.py # Configuration system (DEVX_ env vars)
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── translations.json # Translation strings (en, bg, de, ru, zh)
├── ci/ # CI/CD automation modules
├── tools/ # Developer tooling modules
└── molecule/ # Optional molecule testing helpers
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
├── config.py # Configuration system (DEVX_ env vars, .env loading)
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (JSON-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── opentofu.py # OpenTofu output helpers
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── __init__.py
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review
│ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges
│ ├── notify_failure.py # Create Gitea issues on CI failures
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── discover_runners.py # Dynamic Gitea runner discovery
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── __init__.py
│ ├── setup.py # Environment setup (venv, deps, hooks, tea login)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── check_test_speed.py # Measure unit test execution time
│ ├── configure_repo.py # Branch protection and label setup
│ ├── generate_badges.py # Badge SVG generation
│ ├── generate_cliff_config.py # Generate cliff.toml with correct prefix
│ └── install_checkmake.py # Install checkmake (Makefile linter)
└── molecule/ # Optional molecule testing helpers (Ansible projects)
├── __init__.py
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute scenarios across runners
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast
├── molecule_all.py # Run all molecule scenarios locally
├── start_docker.py # Ensure Docker is available for molecule
└── platforms.py # Supported molecule platforms
```
## Core Modules
## Core modules
### cli.py
### `__init__.py`
Click-based CLI entry point. Provides three command groups: `devx ci`, `devx tools`, `devx molecule`. Each subcommand delegates to the corresponding module via `_run_module()`.
Contains only `__version__`, the single source of truth for the package
version. Read by setuptools via `dynamic = ["version"]` in `pyproject.toml`.
Updated automatically by `devx.ci.release` during the release process. Treated
as infrastructure (not user-facing) by the change classifier since it is a
release artifact, not user code.
### i18n.py
### `cli.py`
Simple i18n system using a JSON translations file. Supports en, bg, de, ru, zh. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file.
Click-based CLI entry point. Provides three command groups: `devx ci`,
`devx tools`, and `devx molecule`. Each subcommand delegates to the
corresponding module via `_run_module()`, which imports the module, sets
`sys.argv`, and calls its `main()` function. This design keeps all logic in
the modules themselves — `cli.py` is purely a router.
### exceptions.py
The CLI is registered as a console script via `pyproject.toml`:
```toml
[project.scripts]
devx = "devx.cli:cli"
```
Custom exception hierarchy: `DevxError` (base), `APIError` (HTTP errors with status code and message).
### `config.py`
### api_clients.py
Shared configuration constants for all devx modules. All defaults can be
overridden via environment variables with the `DEVX_` prefix. Provides:
HTTP API clients with connection pooling and retry logic:
- `GiteaClient` — Gitea REST API (branch protection, labels, issues, PRs, releases, reviews)
- `VikunjaClient` — Vikunja REST API (tasks, projects, comments)
- `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`)
- `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
- `CONVENTIONAL_RE` — conventional commit format regex
Both clients retry on transient errors (429, 5xx, connection errors) with exponential backoff.
### `exceptions.py`
### config.py
Custom exception hierarchy:
Configuration constants with env-var overrides (`DEVX_` prefix). Includes API URLs, timeouts, retry settings, task prefix regex, and conventional commit regex.
- `DevxError` — base exception for all devx errors
- `APIError(DevxError)` — raised when a REST API call returns an HTTP error.
Carries `status` (HTTP status code) and `message` (error message).
### gitea_cli.py
### `i18n.py`
Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for structured data. Used by CI scripts for Gitea API operations (issues, labels, PRs, releases, reviews).
Simple i18n system using a JSON translations file (`translations.json`).
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
wraps user-facing strings for translation.
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
custom JSON file. Keys from the project's file are merged on top of devx's
built-in translations, allowing projects to override or add keys without
modifying the package.
### `api_clients.py`
Reusable HTTP API clients with connection pooling and retry logic. Both
clients retry on transient errors (429, 5xx, connection errors) with
exponential backoff (2s, 4s, 8s).
**`GiteaClient`** — Gitea REST API wrapper:
- Branch protection (get, create, update)
- Labels (list, create, add to issues)
- Issues (create, list)
- Pull requests (get commits, merge, create review)
- Releases (list)
- Wiki pages (list, fetch, create, update, delete)
**`VikunjaClient`** — Vikunja REST API wrapper:
- Tasks (list project tasks, get, update, mark done)
- Comments (create)
### `gitea_cli.py`
Thin Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for
structured data. Used by CI scripts for Gitea API operations that tea handles
well, avoiding hand-rolled HTTP requests.
**`TeaCLI`** operations:
- `create_issue()` — Create issues with labels
- `list_labels()` / `create_label()` / `add_label()` — Label management
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
- `create_release()` / `list_releases()` — Release management
- `list_branches()` — Branch listing
Operations NOT supported via tea (still use `GiteaClient`):
- Wiki page management
- Commit status checks
- Runner discovery
- PR file/commit listing (tea has limited support)
- Branch protection with detailed config
### `opentofu.py`
OpenTofu output helpers for CI/CD deployment scripts. Provides reusable
functions for extracting values from `tofu output` in a structured way,
eliminating duplicated `subprocess.run` boilerplate:
- `get_tofu_output(output_name, cwd, env)` — Run `tofu output -json` and return parsed JSON
- `get_tofu_vm_ip(output_name, vm_name, cwd, env)` — Extract a VM's IP address
- `get_tofu_vm_field(output_name, vm_name, field, cwd, env)` — Extract a VM field
## CI/CD modules (`devx.ci`)
Modules in this package are run by Gitea Actions workflows. They may import
from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`.
### `release.py`
Automated release using git-cliff. Calculates the next semver version from
conventional commits since the last tag, updates `__version__` in
`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
tag, and pushes both to master.
Idempotent: if there are no new conventional commits since the last tag, it
exits without doing anything. If the tag already exists, it skips tag creation
and only pushes. Includes a `--verify` mode that checks tag/version/changelog
alignment without making changes.
### `publish.py`
Builds the Python package with `python -m build`, publishes to a Gitea PyPI
registry (or standard PyPI if `PYPI_TOKEN` is set), and creates a Gitea
release with git-cliff-generated notes. Supports `--skip-build` for non-Python
repos that only need a Gitea release.
### `auto_merge.py`
Auto-merges a PR when all CI checks pass. Reads the task ID from the branch
name, validates the PR title format against
the Vikunja task title, extracts the conventional commit message from PR
commits, and squash-merges with title `{PREFIX}-N <conventional commit>`.
If the head branch is behind master (HTTP 405), it automatically pulls master,
rebases, force-pushes, and retries the merge.
### `classify_changes.py`
Classifies git changes between two refs as user-facing or workflow-only. Uses
a layered rule system configured in `pyproject.toml` under
`[tool.devx.classify]`:
1. **User-facing overrides** (highest priority — safety override)
2. **Infrastructure overrides** (explicit per-file)
3. **Infrastructure patterns** (DEFAULT_INFRASTRUCTURE + project-specific)
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).
### `pr_review.py`
Automated PR review. Fetches the PR diff via the Gitea API and runs a series
of checks, posting a structured review with `COMMENT` (no issues) or
`REQUEST_CHANGES` (issues found):
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`, no
functions > 50 lines)
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
- i18n (no raw strings in `click.echo()` without `_()` wrapper)
- Resource management (no `open()` without `with`, no `Popen()` without cleanup)
- Documentation (source changes must include doc updates)
- Test coverage (source changes must include test updates)
- Commit conventions (conventional commit format on PR commits)
### `sync_wiki.py`
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
`docs/mapping.json` to map file paths to wiki page titles, then creates or
updates pages. Supports `--dry-run`, `--verify` (check content), and
`--strict` (full integrity check: page count, missing pages, stale pages,
content match).
### `push_badges.py`
Generates SVG badge files using `devx.tools.generate_badges`, pushes them to
an orphan `badges` branch, and updates `README.md` and `docs/index.md` on
master with cache-busting `raw/commit/<sha>/badge.svg` URLs (Gitea caches
`raw/branch/` URLs for 6 hours). Fetches latest master before generating
badges so the version badge reflects the current state. Supports `--retries`
for retrying on git push failures.
### `notify_failure.py`
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue
creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `CI_GITEA_TOKEN` and `DEVX_GITEA_API_URL` before creating
the issue.
### `post_merge.py`
Updates the Vikunja task after a merge to master. Extracts the task ID from
the commit message, marks the task as done, and posts a comment with the
merge SHA.
### `validate_commit_msg.py`
Validates commit messages. On feature branches: conventional commits only
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
auto-merge, followed by a conventional commit message.
### `detect_release_commit.py`
Detects whether the latest git commit is a release commit
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
### `check_translations.py`
Validates translation files against the Python source code. Checks for
missing keys (used in code but not in translations), dead keys (defined but
not used), and missing languages (a key exists but is missing one of the five
supported languages). Supports checking additional translation sets via
`--translations`.
### `doc_coverage.py`
Checks documentation coverage for CLI commands and major modules. Parses
Click commands from `cli.py` and verifies each has documentation in
`docs/user/cli-commands.md`. Checks that core modules are documented in
`architecture.md` and CI scripts in `ci-cd-workflow.md`. Supports
`--fail-on-missing` to enforce 100% coverage.
### `discover_runners.py`
Discovers available Gitea Actions runners at three levels: repository,
organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
array for use as a dynamic matrix in Gitea Actions.
### `distribute_files.py`
Distributes files matching a glob pattern across N parallel runners
(round-robin). Writes the assigned file list for the current runner to
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
### `integration_guard.py`
Runs pytest with the same cross-runner failure detection mechanism used by
`molecule_ci_guard`. If any other integration-tests matrix runner reports
failure, the current pytest subprocess is killed and this runner exits early.
## Developer tools (`devx.tools`)
Modules in this package are run locally or by CI setup jobs. They may import
from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
### `setup.py`
Project setup: installs Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
profile from `.env`. Supports `--extras` to specify dependency groups,
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
configuration.
### `install_tools.py`
Installs CI/CD development tools that are not Python packages: actionlint,
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
not already on PATH. Idempotent: skips tools that are already available.
Supports `--tool` to install specific tools and `--list` to show status.
### `check_test_speed.py`
Runs unit tests and enforces execution-time budgets. Two quality gates:
total suite time must not exceed `--max-seconds` (default: 10s), and no
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
### `configure_repo.py`
Configures repository branch protection and labels via the Gitea REST API.
Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch) and creates standard labels. Status check
contexts are read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`.
### `generate_badges.py`
Generates self-contained SVG badge files from project metrics. Runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files that can be served as static files from the Gitea raw file API.
Badges generated: coverage, tests, docs, quality, version, python.
### `generate_cliff_config.py`
Generates a `cliff.toml` configuration file with the correct task ID prefix
preprocessor. Eliminates the need to manually duplicate and maintain
`cliff.toml` across repos that use devx. Supports `--prefix` to set the task
ID prefix and `--force` to overwrite an existing file.
### `install_checkmake.py`
Installs checkmake (Makefile linter) if not already present. Tries
`go install` first if Go is available, otherwise downloads the latest
pre-built Linux binary from the official GitHub releases.
## Molecule modules (`devx.molecule`)
Optional modules for projects with Ansible roles. Requires the `molecule`
extra (`pip install devx[molecule]`).
### `distribute_molecule.py`
Distributes molecule (scenario, platform) pairs across N parallel runners.
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
the supported OS platform matrix. Supports `--roles-root` for multi-role
repositories, `--list` to list scenarios, and `--list-platforms` to list
platforms.
### `molecule_ci_guard.py`
Runs molecule tests sequentially while polling the Gitea API for other runner
failures. If any other molecule matrix runner reports failure, the current
molecule subprocess is killed and this runner exits early. Supports both
single-role (4-part) and multi-role (5-part) pair encoding.
### `molecule_all.py`
Runs all molecule scenarios on all supported OS platforms sequentially.
Intended for local development; CI uses the parallel matrix instead.
### `discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. Same logic as
`devx.ci.discover_runners` but intended for molecule-specific workflows.
### `start_docker.py`
Ensures Docker is available for molecule tests in CI. Verifies Docker is
accessible and sets `DOCKER_HOST` explicitly. If the host socket is not
available, tries the rootless socket, then starts a local `dockerd` with the
vfs storage driver (requires privileged container).
### `platforms.py`
Single source of truth for the supported OS platform matrix. Each entry maps
a short name to (image, command). Uses the project's pre-built
molecule-test-base image with `sleep infinity` (not systemd) to avoid cgroup
v2 failures. Supports loading custom platforms from a JSON file.
## Design principles
- **Self-contained package** — `src/devx/` never imports from scripts outside
the package. This allows devx to be installed and used as a dependency
without requiring a specific repo layout in the consumer.
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*`,
`python -m devx.tools.*`, or `python -m devx.molecule.*`. The `devx` CLI is
a thin router that delegates to module `main()` functions.
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (not `.:src` since
there are no scripts at repo root). The `src` directory is the sole import
root.
- **Config via env vars** — `DEVX_*` environment variables with `.env` file
fallback. Projects override defaults via environment or `.env`, never by
editing package code.
- **100% test coverage** — enforced by `--cov-fail-under=100` in pytest.
- **i18n by default** — all user-facing strings wrapped in `_()` for
translation. Five languages supported out of the box.
- **Safe-by-default classification** — any file that doesn't match an
infrastructure pattern defaults to user-facing, triggering a release. This
prevents new file types from accidentally skipping releases.
- **Secrets via environment** — secrets are passed via environment variables,
never on the command line.
## Import rules
1. **`src/devx/` is self-contained** — the package never imports from outside `src/`
2. **CI modules** (`devx.ci.*`) may import from `devx.api_clients`,
`devx.config`, `devx.gitea_cli`, `devx.i18n`
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
`classify_changes.py`)
## Data flow
### PR lifecycle
```
Developer creates Vikunja task (DEVX-N)
│
▼
Developer creates branch (DEVX-N-short-description)
│
▼
Developer commits (conventional commits, no DEVX-N prefix)
│
▼
Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
│
▼
CI workflow (ci.yml) triggers:
│
├── quality (lint, tests, coverage, test speed, doc coverage,
│ translation check, dependency scan, workflow dry-run)
│
├── detect-changes (classify_changes.py → user-facing or workflow-only)
│ └── if user-facing → release-dry-run (release.py --dry-run)
│
├── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
│
└── auto-merge (auto_merge.py)
├── validate PR title format
├── validate PR title matches Vikunja task title
├── extract conventional commit message from PR commits
├── squash-merge with "DEVX-N <conventional commit>" title
└── push to master
│
▼
Post-merge workflow triggers (see below)
```
### Post-merge flow
```
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
│
▼
Post-merge workflow (post-merge.yml) triggers:
│
├── detect-type (detect_release_commit.py)
│ └── is-release? → skip all jobs except badges
│
├── validate-commit-msg (validate_commit_msg.py --branch master)
│
├── release (release.py)
│ ├── classify_changes.py → skip if workflow-only
│ ├── git-cliff → calculate next version
│ ├── update __version__ in __init__.py
│ ├── update CHANGELOG.md
│ ├── run make lint-ruff && make pytest-cov
│ ├── commit "release: vX.Y.Z [skip ci]"
│ ├── create annotated tag vX.Y.Z
│ └── push commit + tag to master
│ │
│ ▼
│ Tag push triggers publish workflow (see below)
│
├── sync-wiki (sync_wiki.py --strict)
│ └── sync docs/ to Gitea wiki with integrity check
│
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
│ ├── fetch latest master
│ ├── generate_badges.py → SVG files
│ ├── push to orphan badges branch
│ └── update README.md + docs/index.md with cache-busting URLs
│
├── vikunja (post_merge.py)
│ ├── extract task ID from commit message
│ ├── mark Vikunja task as done
│ └── post comment with merge SHA
│
└── configure-repo (configure_repo.py)
└── ensure branch protection and labels
```
### Publish flow
```
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
│
▼
├── install build, twine, git-cliff, tea
├── configure tea login
│
└── publish (publish.py)
├── build package (python -m build)
├── publish to Gitea PyPI registry (twine upload)
│ OR publish to standard PyPI (if PYPI_TOKEN set)
│ OR skip publish (if --skip-build)
└── create Gitea release with git-cliff notes
```
### Badge generation flow
```
push_badges.py:
│
├── fetch_latest_master() → git fetch + reset --hard origin/master
│
├── generate_badges() → devx.tools.generate_badges
│ ├── run pytest-cov → parse coverage %
│ ├── run pytest → parse test count
│ ├── run doc_coverage → parse doc coverage %
│ ├── run lint → quality status
│ ├── read __version__ from __init__.py
│ └── write SVG files to .badges/
│
├── push_to_badges_branch()
│ ├── git checkout --orphan badges
│ ├── git rm -rf .
│ ├── copy SVG files to root
│ ├── git commit "Update badges [skip ci]"
│ ├── git push origin badges --force
│ └── return commit SHA
│
└── update_readme_with_badge_sha()
├── git checkout master
├── replace raw/branch/badges/ URLs with raw/commit/<sha>/ URLs
├── git commit "chore: update badge URLs [skip ci]"
└── git push origin master
```
## tea CLI integration
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
reliable, official support. It is installed by
`python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
`devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that
tea does not support (wiki management, commit status, runner discovery,
detailed branch protection) fall back to `GiteaClient` (direct HTTP).
## Version source
The version source is `__version__` in `src/devx/__init__.py`, read by
setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release
script updates this file, commits it, and tags the commit. This ensures the
package version, git tag, and changelog always stay aligned.
+505 -47
View File
@@ -1,85 +1,543 @@
# CI/CD Workflow
devx uses Gitea Actions for CI/CD automation. The workflow replicates GRM's automated pipeline but without molecule tests.
devx uses Gitea Actions for CI/CD automation. Three workflows implement a
complete pipeline: pull request validation, post-merge release automation, and
tag-triggered publishing.
## Workflows
## Workflow overview
### CI (`ci.yml`)
```
PR opened/synchronized ──► CI (ci.yml)
│ ├── quality
│ ├── detect-changes
│ ├── release-dry-run (if user-facing)
│ ├── pr-review
│ └── auto-merge ──► squash-merge to master
│ │
▼ ▼
Push to master ──► Post-merge (post-merge.yml)
├── detect-type
├── validate-commit-msg
├── release ──► tag vX.Y.Z
├── sync-wiki │
├── badges │
├── vikunja │
└── configure-repo │
│
▼
Tag push (v*) ──► Publish (publish.yml)
└── publish ──► Gitea PyPI registry + Gitea release
```
Runs on pull requests. Jobs:
## CI workflow (`ci.yml`)
1. **quality** — lint (ruff, pyright, bandit, actionlint), unit tests with 100% coverage, test speed check, doc coverage, translation check, dependency scan
2. **detect-changes** — classify changes as user-facing or workflow-only
3. **release-dry-run** — dry-run the release script (only if user-facing changes)
4. **pr-review** — automated PR review
5. **auto-merge** — squash-merge PR when all checks pass
Runs on pull requests (opened and synchronize) and manual dispatch.
### Post-merge (`post-merge.yml`)
### Jobs
Runs on every push to master. Jobs:
#### `quality`
1. **detect-type** — check if commit is a release commit
2. **validate-commit-msg** — validate conventional commit format
3. **release** — calculate next version, update changelog, tag, push
4. **sync-wiki** — sync docs to Gitea wiki
5. **badges** — generate and push quality badges
6. **vikunja** — mark Vikunja task as done
7. **configure-repo** — ensure branch protection and labels
The main quality gate. Runs on every PR:
### Publish (`publish.yml`)
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
(via `make lint-all`)
2. **Unit tests with 100% coverage** — `make pytest-cov`
3. **Check unit test speed** — `python -m devx.tools.check_test_speed
--max-seconds 4 --max-single-seconds 0.5`
4. **Documentation coverage check** — `python -m devx.ci.doc_coverage
--fail-on-missing`
5. **Translation completeness check** — `python -m devx.ci.check_translations`
6. **Dependency security scan** — `pip-audit --desc --skip-editable`
(best-effort, non-blocking)
7. **Workflow dry-run validation** — `make workflow-dryrun` via act_runner
(best-effort, skipped if act_runner is not installed)
Runs on tag pushes (`v*`). Builds the package, publishes to Gitea PyPI registry, and creates a Gitea release.
#### `detect-changes`
## CI Scripts
Classifies changes between `origin/master` and the PR head as user-facing or
workflow-only using `python -m devx.ci.classify_changes --github-output`.
Writes `user-facing-changed=true|false` to the job output for use by
downstream jobs.
### auto_merge.py
#### `release-dry-run`
Auto-merge PR when all CI checks pass. Reads task ID from `.taskid`, validates PR title format, checks Vikunja task exists, squash-merges with `DEVX-N <conventional commit>` title.
Depends on `quality` and `detect-changes`. Only runs if user-facing changes
are detected. Runs `python -m devx.ci.release --dry-run` to validate that
the release script can calculate the next version and generate the changelog
without making changes. Non-blocking (uses `|| true`).
### release.py
#### `pr-review`
Automated release using git-cliff. Calculates next semver version from conventional commits, updates `__version__` in `__init__.py`, updates `CHANGELOG.md`, runs lint and tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes.
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the
PR number and repository. Fetches the PR diff via the Gitea API and runs
automated checks, posting a structured review:
### publish.py
- `COMMENT` — no issues found
- `REQUEST_CHANGES` — issues found that must be addressed
Builds package with `python -m build`, publishes to Gitea PyPI registry via twine, creates Gitea release with git-cliff-generated notes.
Checks performed:
1. Architecture compliance — no subprocess in CLI, no hardcoded URLs
2. Best practices — no `print()`, no bare `except`, no `TODO`/`FIXME`,
no functions > 50 lines
3. Security — no hardcoded secrets, no `shell=True`, no `eval`/`exec`
4. i18n — no raw strings in `click.echo()` without `_()` wrapper
5. Resource management — no `open()` without `with`, no `Popen()` without
cleanup
6. Documentation — source changes must include doc updates
7. Test coverage — source changes must include test updates
8. Commit conventions — conventional commit format on PR commits
### pr_review.py
#### `auto-merge`
Automated PR review. Checks architecture compliance, best practices, security, i18n, resource management, documentation, test coverage, and commit conventions. Posts inline comments and structured review.
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:
### notify_failure.py
1. **Read task ID** from branch name (e.g., `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
4. **Extract conventional commit message** from PR commits (newest matching
conventional format)
5. **Squash-merge** with title `{PREFIX}-N <conventional commit message>`
6. If the head branch is behind master (HTTP 405), automatically pulls master,
rebases, force-pushes, and retries the merge
Creates a Gitea issue when a CI workflow fails. Uses tea CLI for issue creation with failure labels.
The merge commit push to master triggers the post-merge workflow.
### post_merge.py
### Smart CI: user-facing vs workflow-only changes
Updates Vikunja task after a merge to master. Extracts task ID from commit message, marks task as done, posts a comment with the merge SHA.
Not all changes require a new release. The `detect-changes` job classifies
changes using `python -m devx.ci.classify_changes`:
### classify_changes.py
**Workflow-only paths** (infrastructure — no release needed):
- `.gitea/**` — Gitea Actions workflows
- `tests/**` — Test files
- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml` — Config
- `.env.example`, `.gitignore` — Config
- `hooks/**` — Git hooks
- `src/devx/__init__.py` — Only contains `__version__` (release artifact)
Classifies git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes. Patterns are configurable.
**User-facing paths** (tool changes — release needed) — everything else:
- `src/devx/**` — Python package source (except `__init__.py`)
- `pyproject.toml` — Package metadata
- Any new file type not in the allowlist
### discover_runners.py
Classification is configured in `pyproject.toml` under
`[tool.devx.classify]`. The framework provides `DEFAULT_INFRASTRUCTURE` — a
curated list of paths that are infrastructure for any Python project. Projects
inherit these automatically and only specify what is different.
Discovers available Gitea Actions runners at repo, org, and instance levels. Generates a dynamic matrix for parallel job distribution.
Rule priority (first match wins):
1. `user_facing_overrides` — safety override (highest priority)
2. `infrastructure_overrides` — explicit per-file
3. `infrastructure` — DEFAULT_INFRASTRUCTURE + project-specific patterns
4. Default: user-facing (safe — any unknown file triggers release)
### detect_release_commit.py
## Post-merge workflow (`post-merge.yml`)
Detects whether the latest git commit is a release commit. Writes `is-release=true` or `is-release=false` to GitHub output.
Runs on every push to master. A single workflow with conditional jobs
replaces separate workflows for release, wiki sync, badges, and Vikunja task
updates.
### push_badges.py
### Job dependency graph
Generates SVG badge files from project metrics (tests, coverage, quality, version). Pushes to `badges` branch and updates README with cache-busting commit SHA URLs.
```
detect-type ──┬── validate-commit-msg (skip if release commit)
├── release (skip if release commit)
│ │
│ ├── sync-wiki (needs release)
│ ├── badges (needs release, ALWAYS runs)
│ └── vikunja (needs release)
└── configure-repo (independent, skip if release commit)
```
### distribute_molecule.py
`sync-wiki` and `vikunja` depend on `release` succeeding so that the wiki and
task tracker are only updated when the code is actually released. If release
fails, they are skipped to avoid leaving the wiki or Vikunja in an
inconsistent state.
Distributes molecule (scenario, platform) pairs across N parallel runners. Discovers scenarios under `ansible/roles/*/molecule/`.
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.
### molecule_ci_guard.py
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`.
Runs molecule tests sequentially while polling Gitea for other runner failures. Aborts if another runner fails the same job.
### Jobs
### validate_commit_msg.py
#### `detect-type`
Validates commit messages. On feature branches: conventional commits only (no `DEVX-N` prefix). On master: must have `DEVX-N` prefix from auto-merge.
Checks if the latest commit is a release commit (`release: vX.Y.Z [skip ci]`)
using `python -m devx.ci.detect_release_commit`. Writes `is-release=true` or
`is-release=false` to the job output. All subsequent jobs use this to
conditionally skip for release commits.
#### `validate-commit-msg`
Depends on `detect-type`. Skips for release commits. Validates the latest
commit message using `python -m devx.ci.validate_commit_msg --branch master`.
On master, commits must follow `{PREFIX}-N: <conventional commit>` format
(added by auto-merge).
#### `release`
Depends on `detect-type`. Skips for release commits. The core release
automation job. Runs `python -m devx.ci.release`:
1. **Classify changes** — calls `classify_changes.py` to check for user-facing
changes. If only infrastructure files changed, exits without releasing.
2. **Calculate next version** — uses git-cliff to determine the next semver
version from conventional commits since the last tag
3. **Update version file** — updates `__version__` in `src/devx/__init__.py`
4. **Update changelog** — prepends the new version section to `CHANGELOG.md`
using git-cliff output
5. **Run tests** — executes `make lint-ruff` and `make pytest-cov` to verify
the release is healthy. If either fails, the release is aborted — no
commit, no tag. Use `--skip-tests` only for emergency releases.
6. **Commit** — stages the version file and changelog, commits with
`release: vX.Y.Z [skip ci]` (uses `--no-verify` to bypass the commit-msg
hook since release commits are a special case)
7. **Create tag** — creates an annotated tag `vX.Y.Z` with the changelog as
the tag message
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.,
from a partial previous run), it skips tag creation and only pushes.
**Tag consistency**: Before releasing, the script fetches remote tags and
verifies all existing tags point to commits whose message matches the tag
version. This prevents duplicate release commits and ensures
tag/version/commit alignment.
**Version bumping rules** (git-cliff):
| Commit type | Version bump |
|-------------|-------------|
| `feat:` | minor (0.X.0) |
| `fix:` | patch (0.0.X) |
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0) |
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
On failure, the `notify_failure` step creates a Gitea issue via
`python -m devx.ci.notify_failure`.
#### `sync-wiki`
Depends on `detect-type` and `release`. Skips for release commits. Syncs
documentation from `docs/` to the Gitea wiki using
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
1. Reads `docs/mapping.json` to map file paths to wiki page titles
2. Lists existing wiki pages via the Gitea API
3. For each mapped file, reads content and creates or updates the wiki page
4. `--strict` runs a full integrity check: verifies page count, missing
pages, stale pages, and content match. Fails if any page is empty or
content doesn't match.
Pages that exist in the wiki but not in the mapping are left untouched (not
deleted).
On failure, the `notify_failure` step creates a Gitea issue.
#### `badges`
Depends on `detect-type` and `release`. Uses `if: always()` so it runs on
every push to master, including release commits. Generates and pushes quality
badges using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state,
even if the release job just pushed a new version)
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
`version.svg`, `python.svg`
3. **Push to badges branch** — creates an orphan `badges` branch, copies SVG
files, commits, and force-pushes
4. **Update README/docs** — switches back to master, replaces
`raw/branch/badges/<name>.svg` URLs with `raw/commit/<sha>/<name>.svg`
URLs (cache-busting — Gitea caches `raw/branch/` URLs for 6 hours),
commits, and pushes
Supports `--retries` for retrying on git push failures (fetches latest master
and waits 10s between attempts).
On failure, the `notify_failure` step creates a Gitea issue.
#### `vikunja`
Depends on `detect-type` and `release`. Skips for release commits. Updates
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
<sha>`:
1. Extracts the task ID from the first line of the commit message
2. Marks the corresponding Vikunja task as done
3. Posts a comment with the merge SHA
On failure, the `notify_failure` step creates a Gitea issue.
#### `configure-repo`
Depends on `detect-type`. Skips for release commits. Ensures branch
protection and labels are configured using
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
- Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch)
- Creates standard labels
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`
On failure, the `notify_failure` step creates a Gitea issue.
## Publish workflow (`publish.yml`)
Runs on tag pushes matching `v*`. Triggered by the `release` job in the
post-merge workflow when it creates and pushes a new version tag.
### Job: `publish`
1. **Install dependencies** — build, twine, requests, python-dotenv, click,
and the project itself
2. **Install CI tools** — git-cliff and tea via
`python -m devx.tools.install_tools`
3. **Configure tea login** — `tea login add` using `CI_GITEA_TOKEN`
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
- Build the package with `python -m build`
- Publish to the Gitea PyPI registry (default) using `twine upload
--repository-url <url> -u <token> -p <token>`
- OR publish to standard PyPI if `PYPI_TOKEN` is set
- OR skip publishing if `--skip-build` is passed (non-Python repos)
- Create a Gitea release with git-cliff-generated release notes via
`tea create release`
Publishing destination resolution (checked in order):
1. **Gitea PyPI registry** — if `--registry-url` is given, or
`DEVX_PYPI_REGISTRY_URL` env var is set, or derived from `GITEA_API_URL`
2. **Standard PyPI** — if `PYPI_TOKEN` is set (takes precedence over Gitea
registry)
3. **Skip** — if neither is configured, only the Gitea release is created
On failure, the `notify_failure` step creates a Gitea issue.
## CI scripts
### `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
Vikunja task exists and the title matches, extracts the conventional commit
message from PR commits, and squash-merges with
`{PREFIX}-N <conventional commit>` title.
```bash
python -m devx.ci.auto_merge <branch> <pr_title> <owner/repo> <pr_number>
```
### `release.py`
Automated release using git-cliff. Calculates next semver version from
conventional commits, updates `__version__` and `CHANGELOG.md`, runs lint and
tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, and
pushes. Idempotent — exits if no unreleased changes.
```bash
python -m devx.ci.release [--dry-run] [--skip-tests] [--verify]
```
- `--dry-run` — preview without making changes
- `--skip-tests` — skip lint and test verification (emergency only)
- `--verify` — check tag/version/changelog alignment and exit
### `publish.py`
Builds package, publishes to Gitea PyPI registry or standard PyPI, and
creates a Gitea release with git-cliff-generated notes.
```bash
python -m devx.ci.publish <tag> <owner/repo> [--registry-url <url>] [--skip-build]
```
### `pr_review.py`
Automated PR review. Fetches the PR diff via the Gitea API, runs automated
checks (architecture, best practices, security, i18n, resource management,
documentation, test coverage, commit conventions), and posts a structured
review with inline comments.
```bash
python -m devx.ci.pr_review <pr_number> <owner/repo>
```
### `notify_failure.py`
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `CI_GITEA_TOKEN`.
```bash
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
--workflow <name> --commit <sha> [--auto-login]
```
### `post_merge.py`
Updates Vikunja task after a merge to master. Extracts task ID from the
commit message, marks the task as done, and posts a comment with the merge SHA.
```bash
python -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>] [--git-sha <sha>]
```
### `classify_changes.py`
Classifies git changes as user-facing or workflow-only. Uses a layered rule
system configured in `pyproject.toml`. Safe-by-default: any unknown file
defaults to user-facing.
```bash
python -m devx.ci.classify_changes [--base <ref>] [--head <ref>] \
[--quiet] [--check <category>] [--github-output]
```
### `discover_runners.py`
Discovers available Gitea Actions runners at repository, organization, and
instance levels. Falls back to `MOLECULE_RUNNERS` repo variable or
`DEFAULT_MAX_RUNNERS` (3).
```bash
python -m devx.ci.discover_runners --owner <owner> --repo <repo> [--count] [--indices]
```
### `detect_release_commit.py`
Detects whether the latest git commit is a release commit. Writes
`is-release=true|false` to `$GITHUB_OUTPUT`.
```bash
python -m devx.ci.detect_release_commit
```
### `push_badges.py`
Generates SVG badge files, pushes them to the `badges` branch, and updates
README.md and docs/index.md with cache-busting `raw/commit/<sha>/` URLs.
```bash
python -m devx.ci.push_badges [--output-dir <dir>] [--branch <branch>] \
[--no-readme-update] [--retries <n>]
```
### `distribute_molecule.py`
Distributes molecule (scenario, platform) pairs across N parallel runners.
Discovers scenarios under `ansible/roles/*/molecule/`.
```bash
python -m devx.molecule.distribute_molecule --runner-index <i> --max-runners <n>
python -m devx.molecule.distribute_molecule --list
python -m devx.molecule.distribute_molecule --list-platforms
```
### `molecule_ci_guard.py`
Runs molecule tests sequentially while polling the Gitea API for other runner
failures. Aborts early if another runner fails the same job.
```bash
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
```
### `validate_commit_msg.py`
Validates commit messages. On feature branches: conventional commits only
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
auto-merge, followed by a conventional commit message.
```bash
python -m devx.ci.validate_commit_msg <commit_msg_file> [--branch <branch>]
```
### `sync_wiki.py`
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
`docs/mapping.json` for file-to-page mapping. Supports `--dry-run`,
`--verify`, and `--strict` (full integrity check).
```bash
python -m devx.ci.sync_wiki [--dry-run] [--repo <owner/repo>] [--verify] [--strict]
```
### `check_translations.py`
Validates translation files against the Python source code. Checks for
missing keys, dead keys, and missing languages.
```bash
python -m devx.ci.check_translations [--translations <file>]...
```
### `doc_coverage.py`
Checks documentation coverage for CLI commands and major modules. Parses
Click commands from `cli.py` and verifies documentation exists.
```bash
python -m devx.ci.doc_coverage [--docs-dir <dir>] [--fail-on-missing]
```
### `distribute_files.py`
Distributes files matching a glob pattern across N parallel runners
(round-robin). Writes the assigned file list to `$GITHUB_ENV`.
```bash
python -m devx.ci.distribute_files --pattern <glob> --runner-index <i> \
--max-runners <n> [--github-env] [--skip-if-excess]
```
### `integration_guard.py`
Runs pytest with cross-runner failure detection. If any other
integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early.
```bash
python -m devx.ci.integration_guard -- <pytest args>
```
## Release process summary
The complete release process from PR to published package:
1. **PR merged** — `auto-merge` squash-merges the PR to master with
`{PREFIX}-N <conventional commit>` title
2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
3. **detect-type** — confirms the commit is not a release commit
4. **release** — `release.py` calculates the next version, updates files,
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`,
and pushes to master
5. **Tag push triggers publish** — the tag push triggers `publish.yml`
6. **publish** — `publish.py` builds the package, publishes to the Gitea PyPI
registry, and creates a Gitea release with git-cliff notes
7. **sync-wiki** — documentation is synced to the Gitea wiki
8. **badges** — quality badges are regenerated and pushed to the `badges`
branch; README and docs/index.md are updated with cache-busting URLs
9. **vikunja** — the corresponding Vikunja task is marked as done
10. **configure-repo** — branch protection and labels are ensured
The release commit's post-merge run skips all jobs except `badges` (which
picks up the new version number). This prevents infinite loops.
## Failure handling
Every job in the post-merge and publish workflows has a `notify_failure` step
that runs `if: failure()`. This creates a Gitea issue with the workflow name,
run ID, and commit SHA, ensuring failures that would otherwise go unnoticed
in the Actions tab are surfaced as issues. The issue is created via the tea
CLI with a `bug` label if available.
+378 -43
View File
@@ -1,134 +1,469 @@
# CLI Commands
devx provides a CLI with three command groups: `ci`, `tools`, and `molecule`.
Each subcommand delegates to the corresponding Python module via
`python -m devx.*`, so `devx ci release` is equivalent to
`python -m devx.ci.release`.
```bash
devx --help # show all command groups
devx --version # show package version
devx ci --help # show CI commands
devx tools --help # show tools commands
devx molecule --help # show molecule commands
```
## CI Commands
### `devx ci auto-merge`
Auto-merge a PR when all CI checks pass. Validates PR title, checks Vikunja task, squash-merges.
Auto-merge a PR when all CI checks pass. Reads the task ID from the branch
name, validates the PR title format against
the Vikunja task title, extracts the conventional commit message from PR
commits, and squash-merges with `{PREFIX}-N <conventional commit>` title.
If the head branch is behind master (HTTP 405), automatically pulls master,
rebases, force-pushes, and retries the merge.
```bash
devx ci auto-merge <branch> <pr_title> <owner/repo> <pr_number>
# Example:
devx ci auto-merge DEVX-12-add-feature "DEVX-12: Add feature" oblachno-oss/devx 42
```
### `devx ci check-translations`
Check translation files for gaps, dead keys, and missing languages.
Check translation files for gaps, dead keys, and missing languages. Validates
translation files against the Python source code that uses them. By default,
checks `src/devx/translations.json` against `src/devx/**/*.py`.
Checks performed:
- **Missing keys** — a `_()` call in code has no entry in the translations file
- **Dead keys** — a key in the translations file is not used in any code
- **Missing languages** — a key exists but is missing one of the six
supported languages (en, bg, de, ru, zh, pl)
```bash
devx ci check-translations
devx ci check-translations --translations path/to/translations.json
```
### `devx ci classify-changes`
Classify git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes.
Classify git changes as user-facing or workflow-only. Used to skip releases
for infrastructure-only changes. Classification rules are configured in
`pyproject.toml` under `[tool.devx.classify]`.
```bash
devx ci classify-changes --base origin/master --head HEAD
devx ci classify-changes --base origin/master --head HEAD --github-output
devx ci classify-changes --quiet --check user-facing
devx ci classify-changes --check ansible # custom tag from pyproject.toml
```
Options:
- `--base <ref>` — base ref (default: latest tag)
- `--head <ref>` — head ref (default: HEAD)
- `--quiet` — only output true/false
- `--check <category>` — check specific category: `all` (default),
`user-facing`, or any tag name defined in `[tool.devx.classify.tags]`
- `--github-output` — write results to `$GITHUB_OUTPUT` for CI workflow steps
Exit code 2 indicates workflow-only changes (no release needed).
### `devx ci detect-release-commit`
Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip ci]`).
Detect whether the latest git commit is a release commit
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
```bash
devx ci detect-release-commit
```
### `devx ci discover-runners`
Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at repository, organization, and
instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
`DEFAULT_MAX_RUNNERS` (3).
```bash
devx ci discover-runners --owner oblachno-oss --repo devx
devx ci discover-runners --owner oblachno-oss --repo devx --count
devx ci discover-runners --owner oblachno-oss --repo devx --indices
```
Options:
- `--count` — print the number of available runners
- `--indices` — print a JSON array `[0, 1, ..., N-1]` for use as a dynamic
matrix in Gitea Actions
### `devx ci distribute-files`
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
Distribute files across parallel runners (round-robin). Discovers files
matching a glob pattern, sorts them for deterministic ordering, then assigns
them round-robin to `max_runners` groups. The assigned group for
`runner_index` is written to `$GITHUB_ENV`.
```bash
devx ci distribute-files --pattern "tests/integration/test_*.py" \
--runner-index 1 --max-runners 3 --github-env
```
Options:
- `--pattern <glob>` — glob pattern for files to distribute
- `--runner-index <i>` — current runner index (0-based)
- `--max-runners <n>` — total number of runners (default: 3)
- `--github-env` — write file list to `$GITHUB_ENV`
- `--skip-if-excess` — skip if fewer files than runners
### `devx ci doc-coverage`
Check documentation coverage for CLI commands and major modules.
Check documentation coverage for CLI commands and major modules. Parses
Click commands from `cli.py` and checks if each has documentation in
`docs/user/cli-commands.md`. Verifies core modules are documented in
`architecture.md` and CI scripts in `ci-cd-workflow.md`.
```bash
devx ci doc-coverage
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
```
Options:
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
- `--fail-on-missing` — exit with non-zero status if any documentation is
missing
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
Run pytest with cross-runner failure detection. If any
other integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early with code 1.
### `devx ci merge-junit`
```bash
devx ci integration-guard -- test_a.py test_b.py
devx ci integration-guard -- -x -v --tb=short test_a.py
```
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
Environment variables:
- `GITEA_URL` — base URL of the Gitea instance
- `CI_GITEA_TOKEN` — API token with repo access
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index)
- `GITEA_REPOSITORY` — repository in `owner/repo` format
### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails.
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
creation with a `bug` label if available.
```bash
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
--workflow ci --commit abc123def456
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
--workflow post-merge/release --commit abc123def456 --auto-login
```
Options:
- `--repo <owner/repo>` — repository (required)
- `--run-id <id>` — CI run ID (required)
- `--workflow <name>` — workflow name (required)
- `--commit <sha>` — commit SHA (required)
- `--auto-login` — configure tea CLI login from `CI_GITEA_TOKEN` before creating
the issue
### `devx ci post-merge`
Update Vikunja task after a merge to master.
Update Vikunja task after a merge to master. Extracts the task ID from the
commit message, marks the task as done, and posts a comment with the merge SHA.
```bash
devx ci post-merge "DEVX-12 feat: add feature" --git-sha abc123def456
```
### `devx ci pr-review`
Run automated PR review: check architecture compliance, best practices, and quality.
Run automated PR review. Fetches the PR diff via the Gitea API and runs a
series of checks, posting a structured review (`COMMENT` or
`REQUEST_CHANGES`).
Checks: architecture compliance, best practices, security, i18n, resource
management, documentation, test coverage, and commit conventions.
```bash
devx ci pr-review 42 oblachno-oss/devx
```
### `devx ci publish`
Build package, publish to Gitea PyPI registry, and create Gitea release.
Build package, publish to Gitea PyPI registry (or standard PyPI), and create
a Gitea release with git-cliff-generated notes.
```bash
devx ci publish v1.0.0 oblachno-oss/devx
devx ci publish v1.0.0 oblachno-oss/devx --registry-url https://git.example.com/api/packages/owner/pypi
devx ci publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
```
Options:
- `--registry-url <url>` — Gitea PyPI registry URL. Defaults to
`DEVX_PYPI_REGISTRY_URL` env var or a URL derived from `GITEA_API_URL`.
When set, publishes to Gitea PyPI instead of standard PyPI (unless
`PYPI_TOKEN` is also set).
- `--skip-build` — skip package build and PyPI publish (for non-Python repos
that only need a Gitea release)
### `devx ci push-badges`
Generate badge SVG files and push them to the `badges` branch.
Generate badge SVG files and push them to the `badges` branch. Also updates
`README.md` and `docs/index.md` on master with cache-busting
`raw/commit/<sha>/` URLs.
```bash
devx ci push-badges
devx ci push-badges --output-dir .badges/ --branch master
devx ci push-badges --no-readme-update # skip README update (local testing)
devx ci push-badges --retries 3 # retry on git push failures
```
Options:
- `--output-dir <dir>` — temporary directory for badge files (default:
`.badges/`)
- `--branch <branch>` — branch to sync before generating badges (default:
`master`)
- `--no-readme-update` — skip updating README with cache-busting URLs
- `--retries <n>` — number of attempts on git push failures (default: 1).
Between attempts, fetches latest master and waits 10s.
### `devx ci release`
Automated release: calculate next version, update files, tag, and push.
Automated release: calculate next version, update files, tag, and push. Uses
git-cliff to determine the next semver version from conventional commits.
```bash
devx ci release
devx ci release --dry-run # preview without making changes
devx ci release --skip-tests # skip lint and tests (emergency only)
devx ci release --verify # check tag/version/changelog alignment
```
Options:
- `--dry-run` — show what would happen without making changes
- `--skip-tests` — skip lint and test verification (NOT recommended — only
for emergency releases)
- `--verify` — verify tag/version/changelog alignment and exit (no changes
made)
### `devx ci sync-wiki`
Sync documentation from `docs/` to the Gitea wiki.
Sync documentation from `docs/` to the Gitea wiki. Reads `docs/mapping.json`
for file-to-page mapping. Pages that exist in the wiki but not in the mapping
are left untouched.
```bash
devx ci sync-wiki --repo oblachno-oss/devx
devx ci sync-wiki --repo oblachno-oss/devx --dry-run
devx ci sync-wiki --repo oblachno-oss/devx --verify
devx ci sync-wiki --repo oblachno-oss/devx --strict
```
Options:
- `--dry-run` — show what would happen without making changes
- `--repo <owner/repo>` — repository (auto-detected if omitted)
- `--verify` — after syncing, verify each page has non-empty content. Exit 1
if any page is empty or mismatched.
- `--strict` — full integrity check: verify page count, missing pages, stale
pages, and content. Implies `--verify`.
### `devx ci validate-commit-msg`
Validate commit messages for conventional commit format.
Validate commit messages for conventional commit format. On feature branches:
conventional commits only (no `{PREFIX}-N` prefix). On master: must have
`{PREFIX}-N` prefix from auto-merge, followed by a conventional commit
message.
```bash
devx ci validate-commit-msg commit-msg.txt
devx ci validate-commit-msg commit-msg.txt --branch master
```
Options:
- `--branch <branch>` — override branch detection (for CI use)
## Tools Commands
### `devx tools check-test-speed`
Run unit tests and enforce execution-time budgets:
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
Run unit tests and enforce execution-time budgets. Two quality gates:
- **Total suite time** must not exceed `--max-seconds` (default: 10s)
- **Per-test time** — no individual test may exceed `--max-single-seconds`
(default: 0.5s, 0 to disable)
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
per-test timing lines.
```bash
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
devx tools check-test-speed
devx tools check-test-speed --max-seconds 10
devx tools check-test-speed --max-seconds 4 --max-single-seconds 0.5
```
### `devx tools configure-repo`
Configure repository: branch protection + labels via Gitea API.
Configure repository: branch protection and labels via the Gitea REST API.
Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch) and creates standard labels.
```bash
devx tools configure-repo --repo devx --owner oblachno-oss
```
Status check contexts are read from `DEVX_STATUS_CHECKS` (comma-separated) or
default to `CI / quality (pull_request)`.
### `devx tools generate-badges`
Generate self-contained SVG badge files from project metrics.
Generate self-contained SVG badge files from project metrics. Runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files that can be served as static files from the Gitea raw file API.
Badges generated: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
`version.svg`, `python.svg`.
```bash
devx tools generate-badges
devx tools generate-badges --output-dir .badges/
```
### `devx tools generate-cliff-config`
Generate a `cliff.toml` configuration file with the correct task ID prefix.
Eliminates the need to manually duplicate and maintain cliff.toml across
repos that use devx.
Generate a `cliff.toml` configuration file with the correct task ID prefix
preprocessor. Eliminates the need to manually duplicate and maintain
`cliff.toml` across repos that use devx.
```bash
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
devx tools generate-cliff-config --prefix GRM
devx tools generate-cliff-config --prefix GRM --output cliff.toml
devx tools generate-cliff-config --prefix GRM --force # overwrite existing
```
Options:
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
or `DEVX`)
- `--output <file>` — output file path (default: `cliff.toml`)
- `--force` — overwrite existing file
### `devx tools install-checkmake`
Install checkmake (Makefile linter) if not already present.
Install checkmake (Makefile linter) if not already present. Tries
`go install` first if Go is available, otherwise downloads the latest
pre-built Linux binary from the official GitHub releases.
```bash
devx tools install-checkmake
```
### `devx tools install-tools`
Install CI/CD development tools: actionlint, git-cliff, act_runner, tea.
Install CI/CD development tools that are not Python packages: actionlint,
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
not already on PATH. Idempotent: skips tools that are already available.
```bash
devx tools install-tools # install all
devx tools install-tools --tool actionlint # install one
devx tools install-tools --tool git-cliff --tool tea # install specific
devx tools install-tools --list # list status
```
### `devx tools setup`
Project setup: install Python deps and pre-commit hooks.
Project setup: install Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
profile from `.env`.
```bash
devx tools setup --bin .venv/bin
devx tools setup --bin .venv/bin --extras "ci,lint"
devx tools setup --bin .venv/bin --no-pre-commit --no-tea-login
```
Options:
- `--bin <dir>` — virtualenv bin directory (required)
- `--extras <groups>` — pip extras to install (default: `dev`)
- `--no-pre-commit` — skip pre-commit hook installation
- `--no-tea-login` — skip tea CLI login configuration
## Molecule Commands
### `devx molecule distribute`
Distribute molecule test pairs across parallel runners.
### `devx molecule discover-runners`
Discover available Gitea Actions runners for molecule tests.
### `devx molecule guard`
Run molecule tests sequentially with CI failure polling.
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
### `devx molecule all`
Run all molecule scenarios on all supported OS platforms.
Run all molecule scenarios on all supported OS platforms. Sequential
execution — CI uses the parallel matrix instead.
```bash
devx molecule all
devx molecule all --bin .venv/bin
```
### `devx molecule discover-runners`
Discover available Gitea Actions runners for molecule tests. Same logic as
`devx ci discover-runners` but intended for molecule-specific workflows.
```bash
devx molecule discover-runners --owner oblachno-oss --repo devx --indices
```
### `devx molecule distribute`
Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
the supported OS platform matrix.
```bash
devx molecule distribute --runner-index 1 --max-runners 3
devx molecule distribute --list # list all scenarios
devx molecule distribute --list-platforms # list platforms
devx molecule distribute --roles-root ansible/roles # multi-role repos
```
Options:
- `--runner-index <i>` — current runner index (0-based)
- `--max-runners <n>` — total number of runners (default: 3)
- `--list` — list all scenarios, one per line
- `--list-platforms` — list all platforms, one per line
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
`ansible/roles`)
### `devx molecule guard`
Run molecule tests sequentially with CI failure polling. A background thread
polls the Gitea API. If any other molecule matrix runner reports failure, the
current molecule subprocess is killed and this runner exits early with code 1.
```bash
devx molecule guard pair1 pair2 pair3
devx molecule guard --roles-root ansible/roles pair1 pair2
```
Each pair is encoded as:
- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command`
- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command`
Options:
- `--roles-root <dir>` — roles root directory for multi-role repos
Environment variables:
- `GITEA_URL` — base URL of the Gitea instance
- `CI_GITEA_TOKEN` — API token with repo access
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index)
- `GITEA_REPOSITORY` — repository in `owner/repo` format
+28 -6
View File
@@ -26,12 +26,13 @@ devx = "devx.cli:cli"
version = {attr = "devx.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests
# Test runners (pytest + coverage + parallel execution)
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"pytest-xdist>=3.8",
]
# Lint and type-checking tools (quality job)
# Lint and type-checking tools (quality job, badge generation)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
@@ -39,16 +40,30 @@ lint = [
"pip-audit>=2.10",
"pre-commit>=4.6.0",
]
# Molecule testing (optional — for projects with Ansible roles)
# Release tools (build + publish to PyPI/Gitea registry)
release = [
"build>=1.5.0",
"twine>=6.2.0",
]
# Molecule testing (for projects with Ansible roles)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"ansible>=14.0.0",
"ansible-core>=2.15,<2.17",
]
# 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",
]
# Full dev environment (local development)
dev = [
"devx[ci,lint]",
"devx[ci,lint,release,molecule]",
"build>=1.3.0",
"twine>=6.2.0",
]
@@ -57,7 +72,7 @@ dev = [
where = ["src"]
[tool.setuptools.package-data]
devx = ["translations.json"]
devx = ["translations.json", "make/*.mak"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -94,6 +109,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
# Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file)
# Project-specific devx configuration (read by devx.config)
[tool.devx]
task_prefix = "DEVX"
vikunja_project_id = 8
repo_owner = "oblachno-oss"
repo_name = "devx"
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.9.2"
__version__ = "0.23.0"
+64
View File
@@ -192,6 +192,32 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]:
"""Create a pull request and return the PR dict.
Args:
title: PR title.
head: Head branch name.
base: Base branch name (default: master).
body: PR description (markdown).
"""
payload: dict[str, Any] = {"title": title, "head": head, "base": base}
if body:
payload["body"] = body
r = self._request("POST", "/pulls", json=payload)
return r.json()
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
"""List pull requests, optionally filtered by state.
Args:
state: ``open``, ``closed``, ``all`` (default).
**params: Additional query params (e.g. ``q="keyword"`` for title search).
"""
params.setdefault("state", state)
r = self._request("GET", "/pulls", params=params)
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
@@ -342,8 +368,46 @@ class VikunjaClient:
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
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.
Args:
project_id: Target Vikunja project ID.
title: Task title (required, non-empty).
description: Task description (HTML supported, optional).
"""
r = self._request(
"PUT",
f"/projects/{project_id}/tasks",
json={"title": title, "description": description},
)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
"""List all comments on a task."""
r = self._request("GET", f"/tasks/{task_id}/comments")
return r.json()
def update_task(self, task_id: int, **fields: Any) -> None:
"""Update task fields via POST (full replacement semantics).
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
Unspecified fields are reset to their type defaults. Use
``update_task_safe`` to preserve existing fields.
"""
self._request("POST", f"/tasks/{task_id}", json=fields)
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
"""Safely update task fields using read-merge-write pattern.
Fetches the full task body, merges the provided fields on top,
and POSTs the complete body back. This prevents accidental
resets of done status, title, etc.
"""
task = self.get_task(task_id)
task.update(fields)
r = self._request("POST", f"/tasks/{task_id}", json=task)
return r.json()
+45 -38
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""Auto-merge PR when all CI checks pass.
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
(falling back to branch name extraction for backwards compatibility),
validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID.
Runs as the final job in ci.yml. Reads the task ID from the branch name
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``), validates the PR title against
the Vikunja task, and squash-merges with a conventional commit message
prefixed by the task ID.
PR title format: ``{PREFIX}-N: <vikunja task title>``
Merge commit format: ``{PREFIX}-N <conventional commit message>``
@@ -17,7 +17,7 @@ This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
"""
import os
@@ -42,7 +42,7 @@ from devx.config import (
from devx.exceptions import APIError
from devx.i18n import _
TASKID_FILE = ".taskid"
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv()
@@ -63,20 +63,32 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[
def read_taskid(branch: str) -> str:
"""Read task ID from .taskid file, falling back to branch name extraction.
"""Read task ID from branch name.
The .taskid file is a simple text file containing just the task ID
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
branch name as a backwards-compatibility fallback.
The branch name is the sole source of truth for the task ID
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). Branches must include
the task ID prefix — there is no ``.taskid`` file fallback.
If a stale ``.taskid`` file exists and disagrees with the branch
name, a deprecation warning is printed advising its removal.
"""
path = Path(TASKID_FILE)
if path.exists():
task_id = path.read_text(encoding="utf-8").strip()
if task_id:
return task_id
# Fallback: extract from branch name
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
branch_task_id = extract_task_id(branch)
if branch_task_id:
# Warn about stale .taskid file if it exists and disagrees
path = Path(TASKID_FILE)
if path.exists():
file_task_id = path.read_text(encoding="utf-8").strip()
if file_task_id and file_task_id != branch_task_id:
click.echo(
_(
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). "
"Delete .taskid from the repo — branch name is the sole source of truth.",
file_id=file_task_id,
branch_id=branch_task_id,
)
)
return branch_task_id
return ""
def extract_task_id(branch: str) -> str:
@@ -184,9 +196,9 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo")
@click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
# Validate PR number is an integer
try:
@@ -204,7 +216,8 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
if not task_id:
raise click.ClickException(
_(
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
"Oops! No task ID found in branch name '{branch}'. "
"Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
branch=branch,
)
)
@@ -224,23 +237,17 @@ 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 — pull master and rebase, then retry
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
try:
run_cmd(["git", "config", "user.name", "devx-ci-bot"])
run_cmd(["git", "config", "user.email", "devx-ci-bot@oblachno.fyi"])
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
error=str(retry_err),
)
) from None
# 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(
_(
"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.",
)
) from None
else:
raise click.ClickException(
_(
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Pre-merge validation gate for auto-merge preconditions.
Validates that a PR satisfies auto-merge requirements BEFORE expensive
jobs (molecule tests, staging deploy) run. This catches issues early:
1. Branch name contains a task ID (e.g., ``DEVX-256-fix-foo``).
2. PR title follows ``{PREFIX}-N: <title>`` format.
3. PR title task ID matches the branch task ID.
4. PR title matches the Vikunja task title (requires ``VIKUNJA_TOKEN``).
5. Branch is not behind master (would trigger a rebase retry cycle).
Exit code 0 = ready for auto-merge (preconditions satisfied).
Exit code 1 = NOT ready — fix issues before pushing.
Usage::
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\
--repo "$REPOSITORY" \\
--pr-number "$PR_NUMBER"
# Local (pre-push hook, no PR yet — validates branch + title format only):
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)"
# Local (with PR number, fetches title from Gitea):
python3 -m devx.ci.check_auto_merge_ready --branch "$(git rev-parse --abbrev-ref HEAD)" \\
--repo owner/repo --pr-number 123
If ``VIKUNJA_TOKEN`` is not set, the Vikunja title match check is
skipped (with a warning) — this allows local pre-push hooks to run
without CI secrets. In CI, the token is always set and the check is
mandatory.
If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
branch-name and PR-title-format checks run (local mode).
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient
from devx.ci.auto_merge import extract_task_id
from devx.config import (
GITEA_API_URL,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
load_dotenv()
def is_branch_behind_master(branch: str) -> bool:
"""Check if the local branch is behind origin/master.
Fetches origin first (best-effort) then compares commit counts.
Returns ``True`` if master has commits not in branch.
"""
try:
subprocess.run( # nosec B603, B607
["git", "fetch", "origin", "master", "--quiet"],
check=False,
capture_output=True,
timeout=30,
)
result = subprocess.run( # nosec B603, B607
["git", "rev-list", "--count", f"origin/master..{branch}"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode != 0:
return False # Can't determine — don't block
result = subprocess.run( # nosec B603, B607
["git", "rev-list", "--count", f"{branch}..origin/master"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode != 0:
return False
behind = int(result.stdout.strip() or "0")
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
return False # Don't block on git errors
return behind > 0
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API.
Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
"""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token or "/" not in repo:
return None
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
try:
pr = client.get_pr(pr_number)
return str(pr.get("title", ""))
except Exception:
return None
def get_vikunja_title_optional(task_id: str) -> str | None:
"""Fetch the Vikunja task title, returning None if token is not set.
Unlike :func:`devx.ci.auto_merge.get_vikunja_task_title`, this does NOT
raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the
caller can skip the check in local mode.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return None
client = VikunjaClient(VIKUNJA_API_URL, token)
from devx.config import DEFAULT_PER_PAGE
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return None
@click.command()
@click.option("--branch", required=True, help=_("Branch name (e.g., DEVX-256-fix-foo)"))
@click.option("--pr-title", default=None, help=_("PR title (auto-fetched if --pr-number given)"))
@click.option("--repo", default=None, help=_("Repository in owner/name format"))
@click.option("--pr-number", type=int, default=None, help=_("PR number (to fetch title from Gitea)"))
@click.option("--skip-vikunja", is_flag=True, help=_("Skip Vikunja title match check"))
@click.option("--skip-behind-check", is_flag=True, help=_("Skip branch-behind-master check"))
def cli(
branch: str,
pr_title: str | None,
repo: str | None,
pr_number: int | None,
skip_vikunja: bool,
skip_behind_check: bool,
) -> None:
"""Validate auto-merge preconditions before expensive CI jobs."""
import re
from devx.config import TASK_PREFIX
pr_title_re = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") # noqa: PLW1503
errors: list[str] = []
# 1. Branch task ID
task_id = extract_task_id(branch)
if not task_id:
errors.append(
_(
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
branch=branch,
prefix=TASK_PREFIX,
),
)
# Can't continue — no task ID to validate against
for e in errors:
click.echo(f"ERROR: {e}", err=True)
raise click.ClickException(_("Branch name must contain a task ID."))
click.echo(f"[pre-merge-check] Task ID: {task_id}")
# 2. Resolve PR title
if pr_title is None and pr_number is not None and repo is not None:
pr_title = get_pr_title_from_gitea(repo, pr_number)
if pr_title:
click.echo(f"[pre-merge-check] PR title (from Gitea): {pr_title}")
if pr_title is None:
# Local mode without PR — only validate branch name
if pr_number is not None:
raise click.ClickException(
_("Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).")
)
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
return
# 3. PR title format
if not pr_title_re.match(pr_title):
errors.append(
_(
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
prefix=TASK_PREFIX,
title=pr_title,
),
)
# 4. PR title task ID matches branch task ID
if not pr_title.startswith(f"{task_id}:"):
errors.append(
_(
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
task_id=task_id,
title=pr_title,
),
)
# 5. Vikunja task title match (skip if no token or --skip-vikunja)
if not skip_vikunja:
vikunja_title = get_vikunja_title_optional(task_id)
if vikunja_title is None:
token_set = bool(os.environ.get("VIKUNJA_TOKEN", ""))
if token_set:
errors.append(
_(
"Could not find Vikunja task {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
),
)
else:
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
else:
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
errors.append(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
expected=expected,
title=pr_title,
),
)
else:
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
# 6. Branch behind master (skip if --skip-behind-check)
if not skip_behind_check:
if is_branch_behind_master(branch):
errors.append(
_("Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master")
)
else:
click.echo("[pre-merge-check] Branch is up-to-date with origin/master.")
if errors:
click.echo("", err=True)
click.echo("=" * 60, err=True)
click.echo("Pre-merge validation FAILED — fix these before pushing:", err=True)
click.echo("=" * 60, err=True)
for e in errors:
click.echo(f" - {e}", err=True)
raise click.ClickException(_("Pre-merge validation failed."))
click.echo("[pre-merge-check] All auto-merge preconditions satisfied.")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+6 -6
View File
@@ -11,8 +11,8 @@ Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). This is an error — all supported languages
- **Missing languages**: a key exists but is missing one of the 6 supported
languages (en, bg, de, ru, zh, pl). This is an error — all supported languages
must have translations for every key.
Usage::
@@ -33,7 +33,7 @@ import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
@@ -101,9 +101,9 @@ def collect_keys(src_dir: Path) -> set[str]:
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the default source directory
if src_dir == DEFAULT_SRC_DIR:
keys |= DYNAMIC_KEYS
# Dynamic keys are common status strings used via _(variable) that
# can't be detected by AST scanning. Include them for all projects.
keys |= DYNAMIC_KEYS
return keys
+19 -3
View File
@@ -298,8 +298,6 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
"activate.sh",
"activate.fish",
"activate.zsh",
# CI task tracking file (written by CI, not by developers)
".taskid",
]
@@ -617,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
@click.option(
"--force",
is_flag=True,
default=False,
help="Force user-facing-changed=true regardless of actual changes. "
"Used by workflow_dispatch with force-deploy input.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
force = True
if force and github_output:
_write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.")
return
if base is None:
base = get_latest_tag()
if not base:
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
Release commits have the format ``release: vX.Y.Z``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
+1 -1
View File
@@ -148,7 +148,7 @@ def main(
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+37 -7
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
"""Distribute a list of files across N parallel runners (round-robin).
"""Distribute a list of files across N parallel runners using LPT scheduling.
Generic file-based test distribution for CI matrix jobs. Discovers files
matching a glob pattern, sorts them for deterministic ordering, then
assigns them round-robin to *max_runners* groups. The assigned group for
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
assigns them to *max_runners* groups using LPT (Longest Processing Time
first) scheduling — files are weighted by size (as a proxy for test
runtime) and assigned to the runner with the least total weight.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` for
use by subsequent steps.
Usage::
@@ -32,11 +36,32 @@ def discover_files(pattern: str) -> list[str]:
return sorted(glob.glob(pattern))
def _file_weight(path: str) -> int:
"""Estimate a weight for a file based on its size in bytes.
Falls back to 1 if the file cannot be stat'd (e.g. in tests).
"""
try:
return max(1, os.path.getsize(path))
except OSError:
return 1
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
"""Split *files* into *max_runners* balanced groups (round-robin)."""
"""Split *files* into *max_runners* balanced groups using LPT scheduling.
Files are weighted by size (as a proxy for runtime) and assigned to
the runner with the least total weight.
"""
weights = [_file_weight(f) for f in files]
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, f in enumerate(files):
groups[i % max_runners].append(f)
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
@@ -55,7 +80,12 @@ def _write_github_env(key: str, value: str) -> None:
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
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()
+4 -16
View File
@@ -6,23 +6,18 @@ Wraps ``pytest`` with the same Gitea API polling mechanism used by
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
CI_GITEA_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index).
@@ -51,15 +46,10 @@ POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
@@ -69,7 +59,7 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
stop_event = threading.Event()
failed_event = threading.Event()
@@ -93,8 +83,6 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
poller.start()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+7 -52
View File
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation — tea must be installed and configured.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
@@ -14,7 +14,7 @@ Usage:
--auto-login
With ``--auto-login``, the script configures the tea CLI login profile
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow.
"""
@@ -22,14 +22,12 @@ from __future__ import annotations
import logging
import os
import shutil
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _
load_dotenv()
@@ -37,49 +35,6 @@ load_dotenv()
logger = logging.getLogger("devx")
def _configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or REPO_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("notify_failure: tea not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
return
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index.
@@ -116,15 +71,15 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
)
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
if auto_login:
_configure_tea_login()
configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = (
+3 -3
View File
@@ -17,7 +17,7 @@ Checks performed:
8. Commit conventions — conventional commit format on branch commits
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
"""
from __future__ import annotations
@@ -526,9 +526,9 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
def main(pr_number: str, repo: str, dry_run: bool) -> None:
"""Run automated PR review and post results to Gitea."""
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
+132 -25
View File
@@ -9,26 +9,27 @@ Publishing destinations (checked in order):
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the
``REPO_TOKEN`` as both username and password.
``CI_GITEA_TOKEN`` as both username and password.
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow.
3. **Skip** — if neither is configured, only the Gitea release is created.
Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
"""
import os
import shutil
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _
load_dotenv()
@@ -60,6 +61,12 @@ def generate_release_notes(tag: str) -> str:
def build_package() -> None:
"""Build the Python package using python -m build."""
# Clean dist/ to avoid uploading stale packages from previous builds
# (Gitea PyPI returns 409 Conflict for already-published versions).
dist_dir = Path("dist")
if dist_dir.exists():
shutil.rmtree(dist_dir)
result = subprocess.run( # nosec B603
[sys.executable, "-m", "build"],
capture_output=True,
@@ -128,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=result.stderr.strip(),
# Twine writes errors to stdout (not stderr), so check both.
combined = f"{result.stdout}\n{result.stderr}".strip()
# 409 Conflict means the package version is already published —
# this is not an error, just a sign we're re-running publish.
if "409" in combined or "Conflict" in combined:
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
else:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=combined,
)
)
)
click.echo(_("Published to Gitea PyPI registry."))
else:
click.echo(_("Published to Gitea PyPI registry."))
def _default_gitea_registry_url() -> str:
@@ -154,9 +169,37 @@ def _default_gitea_registry_url() -> str:
return f"{base}/api/packages/{owner}/pypi"
def get_latest_tag() -> str | None:
"""Get the latest git tag, or None if no tags exist."""
try:
result = subprocess.run( # nosec
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def is_release_commit(tag: str) -> bool:
"""Check if HEAD commit message starts with 'release: <tag>'."""
try:
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%s"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip().startswith(f"release: {tag}")
except subprocess.CalledProcessError:
return False
@click.command()
@click.argument("tag")
@click.argument("repo")
@click.argument("tag", required=False)
@click.argument("repo", required=False)
@click.option(
"--registry-url",
default=None,
@@ -171,10 +214,48 @@ def _default_gitea_registry_url() -> str:
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
gitea_token = os.environ.get("REPO_TOKEN", "")
@click.option(
"--from-tag",
is_flag=True,
default=False,
help="Auto-detect latest tag and check if HEAD is a release commit. "
"Skips publish if no tag or HEAD is not a release commit for that tag.",
)
@click.option(
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the Gitea release. "
"Eliminates the need for a separate tea login step in containerized CI jobs.",
)
def main(
tag: str | None,
repo: str | None,
registry_url: str | None,
skip_build: bool,
from_tag: bool,
auto_login: bool,
) -> None:
if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "")
if not repo:
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
if from_tag:
detected_tag = get_latest_tag()
if not detected_tag:
click.echo(_("No tag found — skipping publish."))
return
if not is_release_commit(detected_tag):
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
return
tag = detected_tag
click.echo(_("Publishing release {tag}...", tag=tag))
if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
pypi_token = os.environ.get("PYPI_TOKEN", "")
@@ -187,28 +268,54 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
try:
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
except click.ClickException as e:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
error=str(e),
),
err=True,
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo)
if auto_login:
configure_tea_login()
# Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows)
try:
releases = tea.list_releases(repo)
if any(r.get("tag_name") == tag for r in releases):
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
except TeaCLIError:
pass # If listing fails, proceed to create
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
if "already" in str(e).lower() and "release" in str(e).lower():
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
+12 -2
View File
@@ -18,6 +18,7 @@ Usage::
from __future__ import annotations
import contextlib
import os
import re
import subprocess # nosec B404
import sys
@@ -27,7 +28,16 @@ from typing import Any
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
path = Path(workspace)
if path.is_dir():
return path
return Path.cwd()
# Badge filenames that get pushed to the badges branch
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
@@ -116,7 +126,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
Switches back to master, replaces ``raw/branch/badges/`` URLs with
``raw/commit/<sha>/`` URLs, commits and pushes.
"""
root = repo_root or REPO_ROOT
root = repo_root or _repo_root()
# Switch back to master
_run(["git", "checkout", "master"]) # nosec B607
+47 -8
View File
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment
"""
@@ -136,10 +136,14 @@ def verify_tag_consistency() -> list[str]:
"""
errors: list[str] = []
tags = get_all_tags()
# Sort oldest first to identify the first tag
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
# Filter to version tags (vX.Y.Z) and sort oldest first
version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+$", t)]
sorted_tags = sorted(version_tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
first_tag = sorted_tags[0] if sorted_tags else None
for tag in tags:
# Skip non-version tags (e.g., branch names like "master")
if not re.match(r"^v\d+\.\d+\.\d+$", tag):
continue
tag_version = tag.lstrip("v")
commit_version = get_commit_version(tag)
if commit_version is None:
@@ -275,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
return True
@@ -313,6 +317,21 @@ def run_tests() -> None:
click.echo(_("Tests passed."))
def _write_github_output(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:
return
with open(github_output, "a") as f: # noqa: PTH123
f.write(f"tag={tag}\n")
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it.
@@ -340,14 +359,16 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False)
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run:
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", tag])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag)
return True
@@ -478,7 +499,7 @@ def verify_alignment() -> int:
)
if result.returncode == 0 and result.stdout.strip():
all_release_commits = result.stdout.strip().split("\n")
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
all_tags_set = {t.lstrip("v") for t in get_all_tags() if re.match(r"^v\d+\.\d+\.\d+$", t)}
truly_untagged: list[str] = []
duplicates: list[str] = []
for line in all_release_commits:
@@ -547,6 +568,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
# Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
# Some git versions return "heads/master" instead of "master"
branch = branch.removeprefix("heads/")
if branch != "master" and not dry_run:
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
if branch != "master" and dry_run:
@@ -611,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag,
)
)
_write_github_output(release_tag)
return
# Tag is missing — recover by creating and pushing it
click.echo(
@@ -645,6 +669,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
return
current_tag = get_latest_tag()
# If the bumped version equals the current tag version, there's nothing
# new to release. git-cliff didn't bump because the commits since the last
# tag don't warrant a version change (e.g., only ci:/chore: commits).
# Creating a release commit with the same version would cause a tag
# conflict.
if current_tag and current_tag.lstrip("v") == new_version:
click.echo(
_(
"Version stays at v{version} — no version bump from git-cliff. "
"Commits since last tag don't warrant a new release. Skipping.",
version=new_version,
)
)
return
click.echo(
_(
"Bumping version: {current} -> v{new_version}",
@@ -694,7 +732,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
# 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)
run_cmd(["git", "push", "origin", "master"])
# Use refs/heads/master to avoid ambiguity with a 'master' tag
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))
+3 -3
View File
@@ -14,7 +14,7 @@ Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
@@ -225,9 +225,9 @@ def verify_wiki_integrity(
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
)
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+31 -4
View File
@@ -14,6 +14,7 @@ task ID format for each project.
import re
import subprocess # nosec B404
import sys
import click
@@ -23,6 +24,17 @@ from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def get_latest_commit_msg() -> str:
"""Get the latest commit message from git."""
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%B"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def first_line(text: str) -> str:
return text.split("\n")[0]
@@ -41,11 +53,26 @@ def get_branch() -> str:
@click.command()
@click.argument("commit_msg_file")
@click.argument("commit_msg_file", required=False)
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
@click.option(
"--git",
"from_git",
is_flag=True,
default=False,
help="Read commit message from git log instead of a file.",
)
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
if from_git:
msg = get_latest_commit_msg()
elif commit_msg_file:
if commit_msg_file == "-":
msg = sys.stdin.read().strip()
else:
with open(commit_msg_file) as f:
msg = f.read().strip()
else:
raise click.ClickException(_("Provide a commit message file or use --git."))
if branch is None:
branch = get_branch()
+1 -8
View File
@@ -158,17 +158,10 @@ def ci_distribute_files(args: tuple[str, ...]) -> None:
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
"""Run pytest with cross-runner failure detection."""
_run_module("devx.ci.integration_guard", list(args))
+67 -8
View File
@@ -1,27 +1,86 @@
"""Shared configuration constants for devx scripts and API clients.
All defaults can be overridden via environment variables with the ``DEVX_``
prefix. Projects consuming devx can set these in their ``.env`` files.
Configuration is read from two sources, in priority order:
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
CI secrets and per-run overrides.
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
read from the current working directory.
If neither source provides a value, built-in defaults are used.
"""
from __future__ import annotations
import os
import re
import tomllib
from pathlib import Path
def _load_pyproject_devx() -> dict[str, object]:
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
Returns an empty dict if the file or section is missing.
"""
path = Path("pyproject.toml")
if not path.exists():
return {}
try:
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, object] = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
return {}
tool_raw: object = data.get("tool", {})
if not isinstance(tool_raw, dict):
return {}
tool: dict[str, object] = tool_raw # type: ignore[assignment]
devx_raw: object = tool.get("devx", {})
if not isinstance(devx_raw, dict):
return {}
devx: dict[str, object] = devx_raw # type: ignore[assignment]
return devx
_PYPROJECT = _load_pyproject_devx()
def _get(key: str, env_var: str, default: str) -> str:
"""Get a config value: env var > pyproject.toml > default."""
env_val = os.getenv(env_var)
if env_val is not None:
return env_val
pyproject_val = _PYPROJECT.get(key)
if isinstance(pyproject_val, str):
return pyproject_val
return default
def _get_int(key: str, env_var: str, default: int) -> int:
"""Get an int config value: env var > pyproject.toml > default."""
env_val = os.getenv(env_var)
if env_val is not None:
return int(env_val)
pyproject_val = _PYPROJECT.get(key)
if isinstance(pyproject_val, int):
return pyproject_val
return default
# API endpoints — override via env vars for different Gitea/Vikunja instances
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
# Organization defaults
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
# No default: prevents silent 404s when the wrong owner is used.
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
# Vikunja project ID — each project uses a different Vikunja project
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6)
# HTTP client defaults
DEFAULT_TIMEOUT = 30
+61 -6
View File
@@ -40,15 +40,67 @@ Usage::
from __future__ import annotations
import json
import os
import shutil
import subprocess # nosec B404
from typing import Any
import click
from devx.config import GITEA_API_URL
from devx.i18n import _
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set.
Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo(_("tea not installed — skipping login configuration."))
return
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(_("tea login '{name}' already configured.", name=login_name))
return
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
class TeaCLI:
"""Wrapper around the ``tea`` Gitea CLI tool.
@@ -82,12 +134,15 @@ class TeaCLI:
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
+2 -2
View File
@@ -1,7 +1,7 @@
"""Simple i18n for devx scripts and tools.
Set DEVX_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Supported: en, bg, de, ru, zh, pl.
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
JSON file with additional keys. Keys from the project's file are merged
@@ -45,7 +45,7 @@ def _(key: str, **kwargs: object) -> str:
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("DEVX_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+333
View File
@@ -0,0 +1,333 @@
# devx.mak — Shared Makefile fragment for devx-integrated projects.
#
# This fragment provides common targets for:
# - Vikunja task management and PR creation
# - Workflow validation (actionlint, act_runner)
# - Linting (ruff, pyright, bandit, pip-audit)
# - CI failure notification
# - Environment setup (venv, .env, hooks)
# - Test execution and quality checks
#
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
# is read from [tool.devx] in pyproject.toml by devx.config — no
# Makefile variables needed.
#
# Usage in your Makefile:
#
# # Set DEVX_PYTHON to your venv's Python
# DEVX_PYTHON := $(BIN)/python
#
# # Include the devx fragment (silent if devx not installed yet)
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
# 2>/dev/null)
# -include $(DEVX_MAK)
#
# If devx is not installed, the -include silently skips and the targets
# are simply unavailable (run 'make setup' first).
#
# Variables (set BEFORE including this fragment):
# DEVX_PYTHON — Python executable (default: python3)
# DEVX_PR_BASE — PR base branch (default: master)
# DEVX_VENV — venv directory name (default: .venv)
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
# DEVX_LINT_PATHS — paths for ruff/bandit (default: src/ tests/)
# DEVX_TYPECHECK_PATHS — paths for pyright (default: empty — uses pyright config)
# DEVX_COV_PKG — coverage package name (default: src/devx)
# DEVX_TEST_PATHS — pytest paths (default: tests/)
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.oblachno.fyi)
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
DEVX_PYTHON ?= python3
DEVX_PR_BASE ?= master
DEVX_VENV ?= .venv
DEVX_BIN ?= $(DEVX_VENV)/bin
DEVX_LINT_PATHS ?= src/ tests/
DEVX_COV_PKG ?= src/devx
DEVX_TEST_PATHS ?= tests/
DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows
DEVX_DOCKERFILE_PATHS ?= docker
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
.PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
.PHONY: devx-test-unit devx-pytest-cov
.PHONY: devx-setup-image devx-lint-dockerfiles
# ── Vikunja task and PR management ────────────────────────────────────────────
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
devx-create-task:
@$(DEVX_PYTHON) -m devx.tools.create_task
# Create a PR with title auto-derived from the Vikunja task
# (owner/repo read from [tool.devx] in pyproject.toml)
devx-create-pr:
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
# Push current branch to origin
devx-push:
@git push -u origin HEAD
# Validate devx configuration in pyproject.toml
devx-check-config:
@$(DEVX_PYTHON) -m devx.tools.check_config
# Push and create PR in one step
devx-push-with-pr: devx-push devx-create-pr
# ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
devx-configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
# Create .env from .env.example if it doesn't exist
devx-env:
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
# Create Python venv with version check
devx-venv:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Create activate scripts for shell/fish/zsh
devx-activate-scripts:
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@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)
# Set git hooks path to hooks/
devx-install-hooks:
@git config core.hooksPath hooks
@chmod +x hooks/pre-commit hooks/pre-push 2>/dev/null || true
@echo "core.hooksPath set to hooks/ — tracked hooks are now live."
# ── Tool installation ─────────────────────────────────────────────────────────
# Install CI/CD tools (actionlint, git-cliff, act_runner, tea) to ~/.local/bin
devx-install-tools:
@$(DEVX_PYTHON) -m devx.tools.install_tools
# Install checkmake (Makefile linter)
devx-install-checkmake:
@$(DEVX_PYTHON) -m devx.tools.install_checkmake
# Lint Makefiles with checkmake
devx-checkmake:
@CHECKMAKE_EXE="$$(command -v checkmake 2>/dev/null || echo $(HOME)/.local/bin/checkmake)"; \
if ! command -v "$$CHECKMAKE_EXE" >/dev/null 2>&1 && ! [ -x "$$CHECKMAKE_EXE" ]; then \
echo "[checkmake] checkmake not found. Run: make devx-install-checkmake"; exit 1; \
fi; \
"$$CHECKMAKE_EXE" $(CURDIR)/Makefile
# ── Workflow validation ───────────────────────────────────────────────────────
# Static lint of Gitea Actions workflow YAML files
devx-workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { \
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
exit 1; \
}
actionlint -config-file $(DEVX_ACTIONLINT_CFG) $(DEVX_WORKFLOW_DIR)/*.yml
# Dry-run all workflows (requires act_runner)
devx-workflow-dryrun:
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
@echo "Dry-running all workflows (no Docker containers started)..."
act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
# Best-effort dry-run (skips if act_runner is not installed)
devx-workflow-dryrun-safe:
@command -v act_runner >/dev/null 2>&1 && { echo "Dry-running workflows..."; act_runner exec --dryrun -W $(DEVX_WORKFLOW_DIR)/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'; } || echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
# Static lint + dry-run
devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
@echo "Workflow checks passed (static lint + dry-run)."
# ── CI failure notification ───────────────────────────────────────────────────
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
devx-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(DEVX_PYTHON) -m devx.tools.install_tools --tool tea 2>/dev/null || true; \
$(DEVX_PYTHON) -m devx.ci.notify_failure --auto-login \
--repo "$${GITHUB_REPOSITORY}" \
--run-id "$${GITHUB_RUN_ID}" \
--workflow "$(WORKFLOW)" \
--commit "$${GITHUB_SHA}"
# ── Linting ───────────────────────────────────────────────────────────────────
devx-lint-ruff:
@$(DEVX_BIN)/ruff check $(DEVX_LINT_PATHS)
devx-lint-format:
@$(DEVX_BIN)/ruff format --check $(DEVX_LINT_PATHS)
devx-typecheck:
@$(DEVX_BIN)/pyright
devx-lint-bandit:
@$(DEVX_BIN)/bandit -r src/
devx-lint-deps:
@echo "Checking dependencies for known vulnerabilities..."
@$(DEVX_BIN)/python -m ensurepip 2>/dev/null || true
@PIPAPI_PYTHON_LOCATION=$$(pwd)/$(DEVX_VENV)/bin/python \
$(DEVX_BIN)/pip-audit --desc --skip-editable 2>&1 || true
devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
@echo "[devx-lint] Linting checks passed."
# ── Testing ───────────────────────────────────────────────────────────────────
devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
# ── Quality checks ────────────────────────────────────────────────────────────
# Scan for module-level mutable globals that cause test isolation bugs
devx-check-mutable-globals:
@$(DEVX_PYTHON) -m devx.tools.check_mutable_globals
# Validate that every dependency in pyproject.toml has a documented purpose
devx-check-dep-docs:
@$(DEVX_PYTHON) -m devx.tools.check_pyproject_deps
# Check that changed files have corresponding tests
devx-check-test-coverage:
@$(DEVX_PYTHON) -m devx.tools.check_test_coverage
# Validate agent and user docs for stale file references
devx-check-docs:
@$(DEVX_PYTHON) -m devx.tools.check_agent_docs
# Verify test suite timing
devx-check-test-speed:
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
# ── Pre-push validation ───────────────────────────────────────────────────────
# Run lint + tests before push (projects can override with project-specific targets)
devx-pre-push: devx-lint devx-pytest-cov
@echo "[devx-pre-push] All checks passed. Proceeding with push."
# ── Cleanup ───────────────────────────────────────────────────────────────────
devx-clean:
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
# ── Dockerfile linting ────────────────────────────────────────────────────────
#
# Lint Dockerfiles with hadolint. Fails fast if hadolint is not installed
# (no silent skip). Set DEVX_DOCKERFILE_PATHS to the directory containing
# your Dockerfiles (default: docker).
#
# Usage:
# make devx-lint-dockerfiles (lints docker/ directory)
# make devx-lint-dockerfiles DEVX_DOCKERFILE_PATHS=ansible (lints ansible/)
devx-lint-dockerfiles:
@echo "[devx-lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[devx-lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@find $(DEVX_DOCKERFILE_PATHS) -name 'Dockerfile*' -exec hadolint {} +
@echo "[devx-lint-dockerfiles] All Dockerfiles passed."
# ── Pre-built image setup ─────────────────────────────────────────────────────
#
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
# ci-full), all deps are already installed in /opt/venv. This target links
# the venv and installs the project itself (with optional extras).
#
# Usage:
# make devx-setup-image (runtime deps only)
# make devx-setup-image EXTRAS=lint (runtime + lint deps)
# make devx-setup-image EXTRAS=ci,lint (runtime + ci + lint deps)
#
# Falls back to setup-ci if /opt/venv is not present (local dev).
# Note: the fallback target name is project-specific (setup-ci, not
# devx-setup-ci) — each project defines its own setup-ci target.
devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
# Variables:
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
DEVX_IMAGE_MANIFEST ?= docker/images.json
DEVX_IMAGE_OWNER ?= oblachno-oss
# Build all images from manifest (no push)
devx-build-images:
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
# Build and push all images to the Gitea registry
devx-push-images:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --pull
# Dry-run: show what would be built/pushed
devx-build-images-dry-run:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --dry-run
# Clean up old image versions (keep last 2 + latest)
devx-clean-images:
@$(DEVX_PYTHON) -m devx.tools.clean_images \
--owner $(DEVX_IMAGE_OWNER) \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+1 -1
View File
@@ -142,7 +142,7 @@ def main(
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+118 -10
View File
@@ -19,6 +19,7 @@ Usage:
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
@@ -132,14 +133,118 @@ def build_multi_role_pairs(
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
# --- Molecule weight configuration ---
#
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
# weights calibrated from actual CI execution times.
#
# Two key formats are supported:
# - ``"scenario" = weight`` — applies to any role with that scenario name
# - ``"role/scenario" = weight`` — role-specific (takes priority)
#
# Example pyproject.toml::
#
# [tool.devx.molecule.weights]
# "nextcloud" = 15
# "app_container/customer-apps" = 11
# "restore/default" = 11
# "default" = 3
#
# If no configuration is found, a generic default weight is used for all
# scenarios (producing a round-robin distribution).
_DEFAULT_SCENARIO_WEIGHT = 3
def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]:
"""Load molecule weights from ``[tool.devx.molecule.weights]`` in pyproject.toml.
Returns a tuple of ``(scenario_weights, role_scenario_weights)``:
- ``scenario_weights``: maps scenario name → weight (applies to any role)
- ``role_scenario_weights``: maps (role, scenario) → weight (role-specific)
"""
path = Path(pyproject_path)
if not path.exists():
return {}, {}
try:
with open(path, "rb") as f: # noqa: PTH123
data = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
return {}, {}
weights_raw = data.get("tool", {}).get("devx", {}).get("molecule", {}).get("weights", {})
if not isinstance(weights_raw, dict):
return {}, {}
scenario_weights: dict[str, int] = {}
role_scenario_weights: dict[tuple[str, str], int] = {}
for key, value in weights_raw.items():
if not isinstance(value, int):
continue
if "/" in key:
role, scenario = key.split("/", 1)
role_scenario_weights[(role.lower(), scenario.lower())] = value
else:
scenario_weights[key.lower()] = value
return scenario_weights, role_scenario_weights
# Load weights once at import time (like devx.config and classify_changes)
_SCENARIO_WEIGHTS, _ROLE_SCENARIO_WEIGHTS = _load_molecule_weights()
def _scenario_weight(scenario: str, role: str | None = None) -> int:
"""Estimate a weight for a scenario based on its name and optionally its role.
Role-specific weights (``"role/scenario"``) take priority over
scenario-name-only weights (``"scenario"``). Falls back to the
default weight if no configuration matches.
"""
s = scenario.lower()
if role is not None:
r = role.lower()
key = (r, s)
if key in _ROLE_SCENARIO_WEIGHTS:
return _ROLE_SCENARIO_WEIGHTS[key]
for key, weight in _SCENARIO_WEIGHTS.items():
if key in s:
return weight
return _DEFAULT_SCENARIO_WEIGHT
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
# Find the runner with the minimum load
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
Each pair is weighted by role+scenario heuristics (e.g. ``nextcloud`` is
heavier than ``simple-app``). Pairs are sorted by weight descending and
assigned to the runner with the least total weight.
"""
weights = [_scenario_weight(p.scenario, p.role) for p in pairs]
return _lpt_distribute(pairs, weights, max_runners)
def multi_role_pairs_for_runner(
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
) -> list[MultiRoleTestPair]:
@@ -153,11 +258,14 @@ def multi_role_pairs_for_runner(
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
heavier than ``binary``). Pairs are sorted by weight descending and
assigned to the runner with the least total weight.
"""
weights = [_scenario_weight(p.scenario) for p in pairs]
return _lpt_distribute(pairs, weights, max_runners)
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
+19 -80
View File
@@ -13,21 +13,16 @@ A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated when ``--junit-output`` is provided, recording each
pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
CI_GITEA_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index).
@@ -43,7 +38,6 @@ import subprocess # nosec B404
import sys
import threading
import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path
import click
@@ -158,56 +152,18 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command()
@click.argument("pairs", nargs=-1, required=True)
@click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
@@ -217,9 +173,12 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent.parent
# When devx is installed as a pip package, __file__ resolves to the
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
# (set by Gitea Actions) or cwd as the repo root.
repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
@@ -246,8 +205,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
)
poller.start()
testcases: list[dict] = []
try:
for pair in pairs:
if failed_event.is_set():
@@ -260,7 +217,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(cwd),
@@ -279,18 +235,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
@@ -300,30 +244,25 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
sys.exit(1)
rc = process.returncode
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
# Prune Docker data between scenarios to prevent disk exhaustion
# in Docker-in-Docker molecule containers (each scenario pulls
# hundreds of MB of images that accumulate across pairs).
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
["docker", "system", "prune", "-af", "--volumes"],
check=False,
capture_output=True,
timeout=60,
)
click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally:
stop_event.set()
+134 -14
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
"""Start a Docker daemon inside a CI runner container (Docker-in-Docker).
"""Ensure Docker is available for molecule tests in CI.
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
Docker socket mounted, but molecule needs a local Docker daemon to create
nested containers. This module always starts ``dockerd`` in the background
and waits for it to become ready.
Docker socket mounted. This module verifies Docker is accessible and
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
connects to the same socket as the Docker CLI.
If the host socket is not available, it tries the rootless socket, then
starts a local ``dockerd`` with the vfs storage driver (requires
privileged container).
Usage::
@@ -13,8 +17,11 @@ Usage::
from __future__ import annotations
import glob
import os
import subprocess # nosec B404
import sys
import tempfile
import time
import click
@@ -22,33 +29,130 @@ import click
from devx.i18n import _
DEFAULT_TIMEOUT = 30
DOCKERD_LOG = "/var/log/dockerd.log"
DOCKER_SOCK = "/var/run/docker.sock"
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
def is_docker_ready() -> bool:
"""Check if the Docker daemon is responding."""
"""Check if Docker daemon is responding on the configured socket."""
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
env={**os.environ, "DOCKER_HOST": docker_host},
)
return result.returncode == 0
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Start dockerd in the background and wait for it to be ready.
def _diagnose_socket() -> None:
"""Print diagnostic info about the Docker socket."""
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
click.echo(f"Socket path: {DOCKER_SOCK}")
click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}")
if os.path.exists(DOCKER_SOCK):
stat = os.stat(DOCKER_SOCK)
click.echo(f"Socket mode: {oct(stat.st_mode)}")
click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}")
# Check if it's a mount point
result = subprocess.run( # nosec B603 B607
["mount"],
capture_output=True,
check=False,
text=True,
)
docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()]
if docker_mounts:
click.echo("Docker-related mounts:")
for line in docker_mounts:
click.echo(f" {line}")
else:
click.echo("No Docker-related mounts found")
# Check docker context
result = subprocess.run( # nosec B603 B607
["docker", "context", "ls"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"Docker contexts:\n{result.stdout}")
# Try docker info without DOCKER_HOST
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}")
if result.returncode != 0:
click.echo(f" stderr: {result.stderr[:500]}")
else:
# Print server version and storage driver
for line in result.stdout.splitlines():
if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line:
click.echo(f" {line.strip()}")
Always starts a local dockerd even if ``docker info`` succeeds,
because the host socket may be mounted but not suitable for
molecule's nested container creation.
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Ensure Docker is ready for molecule tests.
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
returns immediately. If not, tries the rootless socket. If neither
works, starts a local ``dockerd`` with vfs storage driver (requires
privileged container).
Returns ``True`` if Docker is ready, ``False`` if it failed to
start within the timeout.
"""
click.echo(_("Starting Docker daemon..."))
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115
# Point Docker CLI and Python library to the socket explicitly
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Diagnose socket state
click.echo("--- Docker socket diagnostics ---")
_diagnose_socket()
click.echo("--- End diagnostics ---")
# Check if host Docker is already available
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
# Try rootless socket (e.g. /run/user/994/docker.sock)
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
# Scan for any rootless sockets at other UIDs
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
if sock == ROOTLESS_SOCK:
continue
click.echo(f"Trying alternative rootless socket: {sock}")
os.environ["DOCKER_HOST"] = f"unix://{sock}"
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Host Docker not available, starting local dockerd..."))
# Reset DOCKER_HOST to host socket for local dockerd
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Start local dockerd (requires privileged container)
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
mode="w", suffix="dockerd.log", delete=False
)
click.echo(f"dockerd log: {log_file.name}")
subprocess.Popen( # nosec B603 B607
["dockerd", "--storage-driver", "vfs"],
[
"dockerd",
"--storage-driver",
"vfs",
"-H",
f"unix://{DOCKER_SOCK}",
],
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
@@ -60,7 +164,17 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
return True
time.sleep(1)
# Print dockerd log on failure
click.echo(_("Docker daemon failed to start"))
click.echo("--- dockerd log ---")
try:
with open(log_file.name) as f:
log_content = f.read()
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
except OSError as e:
click.echo(f"Could not read log: {e}")
click.echo("--- End dockerd log ---")
return False
@@ -74,6 +188,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
def main(timeout: int) -> None:
"""Start Docker daemon for CI molecule tests."""
if start_docker_daemon(timeout):
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
github_env = os.environ.get("GITHUB_ENV")
if github_env and os.environ.get("DOCKER_HOST"):
with open(github_env, "a") as f:
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
sys.exit(0)
sys.exit(1)
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Build and push Docker images to a Gitea container registry.
Replaces raw ``docker build`` / ``docker push`` shell commands with a
tested Python tool. Supports:
- Building from any Dockerfile with a configurable context directory
- Tagging with multiple tags (e.g. ``latest`` + version)
- Optional push to a Gitea registry (with login)
- Dry-run mode (prints commands without executing)
Usage::
# Build a single image
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3
# Build and push to registry
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3 \\
--registry git.oblachno.oblachno.fyi \\
--push
# Build multiple images (from a manifest file)
python3 -m devx.tools.build_image --manifest docker/images.json --push
The manifest file is a JSON list of dicts, each with:
- ``name``: image name (e.g. ``ci-base``)
- ``dockerfile``: path to Dockerfile (relative to repo root)
- ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
environment variables, matching the existing CI workflow patterns.
"""
from __future__ import annotations
import json
import os
import subprocess # nosec B404
from dataclasses import dataclass, field
from pathlib import Path
import click
from devx.i18n import _
@dataclass
class ImageSpec:
"""Specification for a single Docker image to build."""
name: str
dockerfile: str
context: str = "."
tags: list[str] = field(default_factory=lambda: ["latest"])
@classmethod
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
name = str(data.get("name", ""))
if not name:
raise ValueError(_("Image manifest entry missing 'name'"))
dockerfile = str(data.get("dockerfile", ""))
if not dockerfile:
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
context = str(data.get("context", "."))
tags_raw = data.get("tags", ["latest"])
if not isinstance(tags_raw, list):
raise ValueError(_("Image 'tags' must be a list"))
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
def load_manifest(path: str | Path) -> list[ImageSpec]:
"""Load a JSON manifest file describing images to build.
The file must contain a JSON list of dicts with at least ``name`` and
``dockerfile`` keys. ``context`` and ``tags`` are optional.
Returns a list of :class:`ImageSpec` instances.
"""
p = Path(path)
if not p.is_file():
raise click.ClickException(_("Manifest file not found: {path}", path=p))
with p.open() as f: # noqa: PTH123
data = json.load(f)
if not isinstance(data, list):
raise click.ClickException(_("Manifest must be a JSON list"))
return [ImageSpec.from_dict(entry) for entry in data]
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
"""Build a full image tag, optionally prefixed with a registry.
>>> build_full_tag(None, "ci-base", "latest")
'ci-base:latest'
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
'git.example.com/ci-base:0.1.0'
"""
if registry:
return f"{registry}/{name}:{tag}"
return f"{name}:{tag}"
def registry_login(
registry: str,
username: str,
token: str,
*,
dry_run: bool = False,
) -> bool:
"""Log in to a Docker registry.
Returns True on success, False on failure.
In dry-run mode, prints the command without executing.
"""
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
result = subprocess.run( # nosec B603
cmd,
input=token,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Registry login failed: {error}", error=result.stderr.strip()),
err=True,
)
return False
click.echo(f"Logged in to {registry}")
return True
def build_image(
spec: ImageSpec,
registry: str | None = None,
*,
dry_run: bool = False,
pull: bool = False,
) -> bool:
"""Build a Docker image from a Dockerfile.
Tags the image with all specified tags, optionally prefixed with the
registry. Returns True on success, False on failure.
"""
if not Path(spec.dockerfile).is_file():
click.echo(
_("Dockerfile not found: {path}", path=spec.dockerfile),
err=True,
)
return False
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
cmd = ["docker", "build"]
if pull:
cmd.append("--pull")
for ft in full_tags:
cmd.extend(["-t", ft])
cmd.extend(["-f", spec.dockerfile, spec.context])
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
result = subprocess.run( # nosec B603
cmd,
check=False,
)
if result.returncode != 0:
click.echo(_("Build failed for {name}", name=spec.name), err=True)
return False
click.echo(f"Built {spec.name}")
return True
def push_image(
spec: ImageSpec,
registry: str,
*,
dry_run: bool = False,
) -> bool:
"""Push all tags of a Docker image to the registry.
Returns True if all pushes succeed, False if any fail.
"""
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
all_ok = True
for ft in full_tags:
cmd = ["docker", "push", ft]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
continue
click.echo(f"Pushing {ft}...")
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
err=True,
)
all_ok = False
else:
click.echo(f"Pushed {ft}")
return all_ok
def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables."""
token = os.environ.get("CI_GITEA_TOKEN", "")
username = os.environ.get("CI_GITEA_USERNAME", "")
return username, token
@click.command()
@click.option(
"--dockerfile",
"dockerfile",
default=None,
help="Path to Dockerfile (for single-image build).",
)
@click.option(
"--context",
"context",
default=".",
help="Build context directory (for single-image build).",
)
@click.option(
"--name",
"name",
default=None,
help="Image name (for single-image build).",
)
@click.option(
"--tag",
"tags",
multiple=True,
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
)
@click.option(
"--manifest",
"manifest",
default=None,
help="Path to JSON manifest file listing images to build.",
)
@click.option(
"--registry",
"registry",
default=None,
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
)
@click.option(
"--push",
is_flag=True,
default=False,
help="Push images to the registry after building.",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Print commands without executing.",
)
@click.option(
"--pull",
is_flag=True,
default=False,
help="Pass --pull to docker build (always fetch latest base image).",
)
def main(
dockerfile: str | None,
context: str,
name: str | None,
tags: tuple[str, ...],
manifest: str | None,
registry: str | None,
push: bool,
dry_run: bool,
pull: bool,
) -> None:
"""Build and optionally push Docker images to a Gitea registry."""
if manifest:
specs = load_manifest(manifest)
elif dockerfile and name:
tag_list = list(tags) if tags else ["latest"]
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
else:
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
if push:
if not registry:
raise click.ClickException(_("--push requires --registry"))
username, token = _get_registry_creds()
if not token or not username:
raise click.ClickException(
_("Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars")
)
if not registry_login(registry, username, token, dry_run=dry_run):
raise click.ClickException(_("Registry login failed"))
failed: list[str] = []
for spec in specs:
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
failed.append(spec.name)
continue
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
failed.append(spec.name)
if failed:
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
click.echo(f"\nDone. {len(specs)} image(s) processed.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Validate agent documentation and user docs for stale file references.
Scans documentation files (``.devin/``, ``docs/``, ``README.md``) for:
- References to files that no longer exist
- References to deleted files (configurable blocklist)
- References to deprecated patterns (configurable regex patterns)
Configuration (``[tool.devx.check_agent_docs]`` in pyproject.toml):
``scan_dirs`` — directories to scan for docs (default: ``[".devin", "docs"]``)
``scan_files`` — specific files to scan (default: ``["README.md", "README.rst"]``)
``scan_extensions`` — file extensions to scan (default: ``[".md", ".yml", ".yaml"]``)
``excluded_paths`` — paths to exclude from scanning (default: ``["docs/retrospectives"]``)
``deleted_files`` — list of file paths that should never be referenced
``deprecated_patterns`` — list of regex patterns for deprecated references
``legitimate_indicators`` — substrings that indicate a legitimate reference to a deprecated pattern
``repo_path_prefixes`` — path prefixes that indicate a repo-relative reference
(default: ``["ansible/", "scripts/", "tofu/", ".devin/", "src/"]``)
``min_path_ref_length`` — minimum length for a path reference to be checked (default: 5)
Usage::
python3 -m devx.tools.check_agent_docs
"""
from __future__ import annotations
import contextlib
import re
from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
MIN_PATH_REF_LENGTH_DEFAULT = 5
# Pattern that matches file path references in markdown or code
FILE_REF_RE = re.compile(
r"(?:`|\")?"
r"([\w\-./]+(?:\.[a-zA-Z0-9]+))"
r"(?:`|\))?"
)
DEFAULT_SCAN_DIRS = [".devin", "docs"]
DEFAULT_SCAN_FILES = ["README.md", "README.rst"]
DEFAULT_SCAN_EXTENSIONS = [".md", ".yml", ".yaml"]
DEFAULT_EXCLUDED_PATHS = ["docs/retrospectives"]
DEFAULT_REPO_PATH_PREFIXES = ["ansible/", "scripts/", "tofu/", ".devin/", "src/"]
def _load_config() -> dict[str, object]:
"""Load check_agent_docs configuration from pyproject.toml."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_agent_docs", {})
if not isinstance(cfg_raw, dict):
return {}
return cfg_raw # type: ignore[return-value]
def _should_skip(path: Path, excluded_paths: list[str], repo_root: Path) -> bool:
"""Check if a path should be excluded from scanning."""
try:
rel = str(path.relative_to(repo_root))
except ValueError:
return False
return any(excluded in rel for excluded in excluded_paths)
def _is_legitimate_ref(line: str, legitimate_indicators: list[str]) -> bool:
"""Check if a line contains a legitimate reference to a deprecated pattern."""
line_lower = line.lower()
return any(legit.lower() in line_lower for legit in legitimate_indicators)
def _collect_doc_files(
repo_root: Path,
scan_dirs: list[str],
scan_files: list[str],
scan_extensions: list[str],
excluded_paths: list[str],
) -> list[Path]:
"""Collect all documentation files to scan."""
files: list[Path] = []
for scan_dir_name in scan_dirs:
scan_dir = repo_root / scan_dir_name
if not scan_dir.exists():
continue
for ext in scan_extensions:
for path in scan_dir.glob(f"**/*{ext}"):
if not _should_skip(path, excluded_paths, repo_root):
files.append(path)
for readme_name in scan_files:
path = repo_root / readme_name
if path.exists() and not _should_skip(path, excluded_paths, repo_root):
files.append(path)
# Deduplicate while preserving order
seen: set[Path] = set()
unique: list[Path] = []
for f in files:
if f not in seen:
seen.add(f)
unique.append(f)
return unique
def _check_file(
path: Path,
repo_root: Path,
deleted_files: set[str],
deprecated_patterns: list[re.Pattern[str]],
legitimate_indicators: list[str],
repo_path_prefixes: list[str],
min_path_ref_length: int,
skip_ref_prefixes: list[str],
) -> list[str]:
"""Check a single file for stale references."""
issues: list[str] = []
rel_path = path.relative_to(repo_root)
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return issues
for lineno, line in enumerate(content.splitlines(), start=1):
# Check for deleted file references
for deleted in deleted_files:
if deleted in line:
issues.append(f"{rel_path}:{lineno}: references deleted file '{deleted}'")
# Check for deprecated pattern references
for pattern in deprecated_patterns:
if pattern.search(line) and not _is_legitimate_ref(line, legitimate_indicators):
issues.append(f"{rel_path}:{lineno}: matches deprecated pattern '{pattern.pattern}'")
# Check for references to files that don't exist
for match in FILE_REF_RE.finditer(line):
ref = match.group(1)
# Skip URLs, bare words, and short strings
if "/" not in ref or len(ref) < min_path_ref_length:
continue
# Only check references that look like repo paths
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
continue
# Skip references matching configured skip prefixes (e.g. aspirational test files)
if any(ref.startswith(prefix) for prefix in skip_ref_prefixes):
continue
candidate = repo_root / ref
if not candidate.exists():
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
return issues
@click.command()
def cli() -> None:
"""Validate agent documentation and user docs for stale file references."""
repo_root = Path.cwd()
cfg = _load_config()
scan_dirs_raw = cfg.get("scan_dirs")
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
scan_files_raw = cfg.get("scan_files")
scan_files: list[str] = [str(d) for d in scan_files_raw] if isinstance(scan_files_raw, list) else DEFAULT_SCAN_FILES
scan_ext_raw = cfg.get("scan_extensions")
scan_extensions: list[str] = (
[str(d) for d in scan_ext_raw] if isinstance(scan_ext_raw, list) else DEFAULT_SCAN_EXTENSIONS
)
excluded_raw = cfg.get("excluded_paths")
excluded_paths: list[str] = (
[str(d) for d in excluded_raw] if isinstance(excluded_raw, list) else DEFAULT_EXCLUDED_PATHS
)
prefixes_raw = cfg.get("repo_path_prefixes")
repo_path_prefixes: list[str] = (
[str(d) for d in prefixes_raw] if isinstance(prefixes_raw, list) else DEFAULT_REPO_PATH_PREFIXES
)
min_len_raw = cfg.get("min_path_ref_length")
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
skip_prefixes_raw = cfg.get("skip_ref_prefixes", [])
skip_ref_prefixes: list[str] = [str(d) for d in skip_prefixes_raw] if isinstance(skip_prefixes_raw, list) else []
deleted_files: set[str] = set()
deleted_raw = cfg.get("deleted_files", [])
if isinstance(deleted_raw, list):
deleted_files = {str(d) for d in deleted_raw}
deprecated_patterns: list[re.Pattern[str]] = []
deprecated_raw = cfg.get("deprecated_patterns", [])
if isinstance(deprecated_raw, list):
for pattern_str in deprecated_raw:
if isinstance(pattern_str, str):
with contextlib.suppress(re.error):
deprecated_patterns.append(re.compile(pattern_str))
legitimate_indicators: list[str] = []
legit_raw = cfg.get("legitimate_indicators", [])
if isinstance(legit_raw, list):
legitimate_indicators = [str(s) for s in legit_raw]
files = _collect_doc_files(repo_root, scan_dirs, scan_files, scan_extensions, excluded_paths)
all_issues: list[str] = []
for path in sorted(files):
issues = _check_file(
path,
repo_root,
deleted_files,
deprecated_patterns,
legitimate_indicators,
repo_path_prefixes,
min_path_ref_length,
skip_ref_prefixes,
)
all_issues.extend(issues)
if all_issues:
click.echo(f"[check_agent_docs] Found {len(all_issues)} issue(s):\n", err=True)
for issue in all_issues:
click.echo(issue, err=True)
click.echo(
f"\n[check_agent_docs] FAILED: {len(all_issues)} stale reference(s)",
err=True,
)
raise click.ClickException(_("Found {count} stale documentation reference(s)", count=len(all_issues)))
click.echo(_("[check_agent_docs] Passed: scanned {count} file(s), no stale references", count=len(files)))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Validate devx configuration consistency in pyproject.toml.
Checks:
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
2. devx version is consistent across all extras that mention it
Usage::
python3 -m devx.tools.check_config
"""
from __future__ import annotations
import re
import sys
import tomllib
from pathlib import Path
import click
from devx.i18n import _
@click.command()
def cli() -> None:
"""Validate devx configuration in pyproject.toml."""
path = Path("pyproject.toml")
if not path.exists():
click.echo(_("pyproject.toml not found in current directory."))
sys.exit(1)
with open(path, "rb") as f: # noqa: PTH123
data = tomllib.load(f)
errors: list[str] = []
# Check [tool.devx] section
devx_cfg = data.get("tool", {}).get("devx", {})
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
missing = required_keys - set(devx_cfg.keys())
if missing:
errors.append(
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
)
# Check devx version consistency across extras
optional_deps = data.get("project", {}).get("optional-dependencies", {})
devx_versions: dict[str, str] = {}
for extra_name, deps in optional_deps.items():
for dep in deps:
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
if m:
devx_versions[extra_name] = m.group(2)
if devx_versions:
unique_versions = set(devx_versions.values())
if len(unique_versions) > 1:
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
errors.append(
_("devx version mismatch across extras: {detail}", detail=detail),
)
if errors:
for err in errors:
click.echo(f"ERROR: {err}", err=True)
sys.exit(1)
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Detect module-level mutable globals that may cause test isolation bugs.
Scans Python files for patterns like::
_SEEN: set[Path] = set()
_CACHE: dict[Path, Any] = {}
PATHS: list[Path] = []
These are hazardous because one test mutates the container and the next
sees stale state. The script reports the file/line and suggests a factory
function or fixture replacement.
Configuration (``[tool.devx.check_mutable_globals]`` in pyproject.toml):
``scan_dirs`` — list of directories to scan (default: ``["scripts", "tests"]``)
``skip_dirs`` — directory names to skip (default: ``__pycache__``, ``.pytest_cache``, ``venv``, ``.venv``)
``known_safe`` — list of ``"path:line:var_name"`` entries to ignore
Usage::
python3 -m devx.tools.check_mutable_globals
python3 -m devx.tools.check_mutable_globals --scan-dir src --scan-dir tests
"""
from __future__ import annotations
import ast
import contextlib
from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
MUTABLE_TYPES = {"set", "dict", "list"}
PATH_HINTS = ("path", "paths", "seen", "cache", "memo", "registry")
DEFAULT_SCAN_DIRS = ["scripts", "tests"]
DEFAULT_SKIP_DIRS = {"__pycache__", ".pytest_cache", "venv", ".venv"}
def _load_config() -> tuple[list[str], set[str], set[tuple[str, int, str]]]:
"""Load configuration from pyproject.toml [tool.devx.check_mutable_globals]."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_mutable_globals", {})
if not isinstance(cfg_raw, dict):
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_DIRS, set()
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
skip_dirs_raw = cfg.get("skip_dirs", list(DEFAULT_SKIP_DIRS))
skip_dirs: set[str] = {str(d) for d in skip_dirs_raw} if isinstance(skip_dirs_raw, list) else DEFAULT_SKIP_DIRS
known_safe_raw = cfg.get("known_safe", [])
known_safe: set[tuple[str, int, str]] = set()
if isinstance(known_safe_raw, list):
for entry in known_safe_raw:
if isinstance(entry, str) and entry.count(":") >= 2:
parts = entry.rsplit(":", 2)
with contextlib.suppress(ValueError):
known_safe.add((parts[0], int(parts[1]), parts[2]))
return scan_dirs, skip_dirs, known_safe
def _should_skip(path: Path, skip_dirs: set[str]) -> bool:
return any(part in skip_dirs for part in path.parts)
def find_mutable_globals(
file_path: Path,
repo_root: Path,
known_safe: set[tuple[str, int, str]],
) -> list[str]:
"""Return a list of issue strings for mutable globals in *file_path*."""
issues: list[str] = []
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source)
except SyntaxError:
return issues
for node in ast.iter_child_nodes(tree):
if not isinstance(node, ast.AnnAssign | ast.Assign):
continue
names: list[str] = []
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.append(node.target.id)
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
names.append(target.id)
for name in names:
name_lower = name.lower()
value = node.value
if value is None:
continue
is_mutable_literal = False
if isinstance(value, ast.Call):
if isinstance(value.func, ast.Name):
if value.func.id in MUTABLE_TYPES:
is_mutable_literal = True
elif isinstance(value.func, ast.Attribute):
# e.g. collections.defaultdict
pass
elif isinstance(value, (ast.Dict, ast.List, ast.Set)):
is_mutable_literal = True
if not is_mutable_literal:
continue
# Check if the name or type hint suggests Path usage
has_path_hint = any(hint in name_lower for hint in PATH_HINTS)
has_path_type = False
if isinstance(node, ast.AnnAssign) and node.annotation:
ann = ast.unparse(node.annotation)
has_path_type = "Path" in ann
if has_path_hint or has_path_type:
rel = str(file_path.relative_to(repo_root))
if (rel, node.lineno, name) in known_safe:
continue
value_str = ast.unparse(value) if value is not None else "..."
issues.append(
f"{rel}:{node.lineno}: mutable global {name!r} "
f"({value_str}) — use a factory function or pytest fixture"
)
return issues
@click.command()
@click.option(
"--scan-dir",
multiple=True,
help=_("Additional directory to scan (default: scripts, tests). Can be repeated."),
)
def cli(scan_dir: tuple[str, ...]) -> None:
"""Scan for module-level mutable globals that cause test isolation bugs."""
repo_root = Path.cwd()
config_scan_dirs, skip_dirs, known_safe = _load_config()
# CLI --scan-dir overrides config if provided
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
all_issues: list[str] = []
for scan_dir_name in scan_dirs:
scan_path = repo_root / scan_dir_name
if not scan_path.exists():
continue
for py_file in scan_path.rglob("*.py"):
if _should_skip(py_file, skip_dirs):
continue
all_issues.extend(find_mutable_globals(py_file, repo_root, known_safe))
if all_issues:
click.echo(f"[check-mutable-globals] FAILED: {len(all_issues)} issue(s)", err=True)
for issue in all_issues:
click.echo(f" {issue}", err=True)
raise click.ClickException(
_("Found {count} mutable global(s) — use factory functions or pytest fixtures.", count=len(all_issues))
)
click.echo(_("[check-mutable-globals] Passed: no mutable path globals found"))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Validate that every dependency in pyproject.toml has a documented purpose.
This script does NOT resolve versions or query PyPI. It only ensures that
every dependency listed in ``[project.dependencies]`` or
``[project.optional-dependencies]`` has a corresponding comment nearby
explaining why it is needed.
Failure means a dependency lacks documentation.
Usage::
python3 -m devx.tools.check_pyproject_deps
python3 -m devx.tools.check_pyproject_deps --file path/to/pyproject.toml
"""
from __future__ import annotations
from pathlib import Path
import click
from devx.i18n import _
def check_deps(pyproject_path: Path) -> list[str]:
"""Return a list of issue strings for undocumented dependencies.
An empty list means all dependencies are documented.
"""
if not pyproject_path.exists():
return [str(pyproject_path) + ": file not found"]
content = pyproject_path.read_text(encoding="utf-8")
lines = content.splitlines()
issues: list[str] = []
in_deps_section = False
prev_was_comment = False
for i, raw_line in enumerate(lines, start=1):
stripped = raw_line.strip()
# Detect section headers
if stripped in ("[project.dependencies]", "[project.optional-dependencies]"):
in_deps_section = True
continue
if stripped.startswith("[") and in_deps_section:
in_deps_section = False
continue
if not in_deps_section:
continue
if stripped == "":
continue
# We're inside a dependency list
if stripped.startswith("#"):
prev_was_comment = True
continue
if stripped.startswith("-") or stripped.startswith('"'):
if not prev_was_comment:
issues.append(f"{pyproject_path.name}:{i}: dependency lacks description comment: {stripped}")
prev_was_comment = False
else:
prev_was_comment = False
return issues
@click.command()
@click.option(
"--file",
"pyproject_file",
type=click.Path(path_type=Path),
default=Path("pyproject.toml"),
help=_("Path to pyproject.toml (default: pyproject.toml in CWD)."),
)
def cli(pyproject_file: Path) -> None:
"""Validate that every dependency in pyproject.toml has a documented purpose."""
issues = check_deps(pyproject_file)
if issues:
click.echo(
_("FAILED: {count} undocumented dependency/ies", count=len(issues)),
err=True,
)
for issue in issues:
click.echo(f" {issue}", err=True)
raise click.ClickException(_("Dependencies must have documentation comments."))
click.echo(_("[check-dep-docs] Passed: all dependencies are documented"))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Pre-commit / CI check: ensure every changed or new file has corresponding tests.
Configuration (``[tool.devx.check_test_coverage]`` in pyproject.toml):
``rules`` — list of mapping rules, each with:
``source_pattern`` — glob pattern for source files (e.g. ``"scripts/*.py"``)
``test_paths`` — list of test path templates (e.g. ``["scripts/tests/test_{name}", "tests/unit/test_{name}"]``)
``description`` — human-readable description for error messages
``skip_patterns`` — list of file patterns to skip (e.g. ``["__init__.py", "config.py"]``)
``test_file_indicators`` — substrings that identify a file as a test (default: ``["tests/", "/test_", "_test.py"]``)
``skip_extensions`` — file extensions to skip (default: .md, .yml, .yaml, .json, .tf, .sh, .conf, .service)
Built-in defaults cover common Python project layouts (``scripts/*.py``, ``src/**/*.py``).
Project-specific rules are merged with defaults (first match wins).
Usage::
python3 -m devx.tools.check_test_coverage [--staged-only] [--warn-only]
"""
from __future__ import annotations
import argparse
import fnmatch
import subprocess # nosec B404
import sys
from pathlib import Path
from devx.config import _load_pyproject_devx
from devx.i18n import _
DEFAULT_TEST_INDICATORS = ["tests/", "/test_", "_test.py"]
DEFAULT_SKIP_EXTENSIONS = (".md", ".yml", ".yaml", ".json", ".tf", ".sh", ".conf", ".service")
# Built-in rules for common Python project layouts
BUILTIN_RULES: list[dict[str, object]] = [
{
"source_pattern": "scripts/*.py",
"test_paths": ["scripts/tests/test_{name}", "tests/unit/test_{name}"],
"description": "Missing unit test: scripts/tests/test_{name} or tests/unit/test_{name}",
},
{
"source_pattern": "src/**/*.py",
"test_paths": ["tests/unit/test_{name}", "tests/unit/test_{module}_{name}"],
"description": "Missing unit test: tests/unit/test_{name}",
},
]
def _load_rules() -> tuple[list[dict[str, object]], list[str], list[str], tuple[str, ...]]:
"""Load test coverage rules from pyproject.toml."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_test_coverage", {})
if not isinstance(cfg_raw, dict):
return BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
rules_raw = cfg.get("rules", BUILTIN_RULES)
rules: list[dict[str, object]] = [dict(r) for r in rules_raw] if isinstance(rules_raw, list) else BUILTIN_RULES
skip_raw = cfg.get("skip_patterns", [])
skip_patterns: list[str] = [str(s) for s in skip_raw] if isinstance(skip_raw, list) else []
indicators_raw = cfg.get("test_file_indicators", DEFAULT_TEST_INDICATORS)
indicators: list[str] = (
[str(s) for s in indicators_raw] if isinstance(indicators_raw, list) else DEFAULT_TEST_INDICATORS
)
skip_ext_raw = cfg.get("skip_extensions", list(DEFAULT_SKIP_EXTENSIONS))
if isinstance(skip_ext_raw, list):
skip_ext: tuple[str, ...] = tuple(str(s) for s in skip_ext_raw)
else:
skip_ext = DEFAULT_SKIP_EXTENSIONS
return rules, skip_patterns, indicators, skip_ext
def _changed_files(staged_only: bool, repo_root: Path) -> list[str]:
"""Return list of changed file paths relative to repo root."""
if staged_only:
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"]
else:
# Compare against origin/master for CI usage
cmd = ["git", "diff", "origin/master...HEAD", "--name-only", "--diff-filter=ACMR"]
result = subprocess.run( # nosec B603, B607
cmd, capture_output=True, text=True, check=False, cwd=repo_root
)
if result.returncode != 0:
# fallback: just use staged files
result = subprocess.run( # nosec B603, B607
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
capture_output=True,
text=True,
check=False,
cwd=repo_root,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def _is_test_file(filepath: str, indicators: list[str]) -> bool:
"""Check if a file is a test file."""
return any(indicator in filepath for indicator in indicators)
def _should_skip_file(
filepath: str,
skip_patterns: list[str],
skip_extensions: tuple[str, ...],
) -> bool:
"""Check if a file should be skipped."""
if filepath.startswith("."):
return True
if filepath.endswith(skip_extensions):
return True
name = Path(filepath).name
return any(fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(filepath, pattern) for pattern in skip_patterns)
def _resolve_test_path(template: str, source_path: str, repo_root: Path) -> Path:
"""Resolve a test path template to an actual path.
Templates can use:
- ``{name}`` — the source file's name (without extension)
- ``{module}`` — the source file's parent directory name
- ``{package_prefix}`` — underscore-joined subdirectories (for nested modules)
"""
path = Path(source_path)
name = path.stem
module = path.parent.name
# Build package prefix for nested modules (e.g. scripts/utils/secrets.py -> utils)
parts = path.parts
package_prefix = ""
if len(parts) > 2:
package_prefix = "_".join(parts[1:-1])
resolved = template.format(
name=name,
module=module,
package_prefix=package_prefix,
)
# Normalize hyphens to underscores (Python module naming)
resolved = resolved.replace("-", "_")
return repo_root / resolved
def _find_missing_tests(
files: list[str],
repo_root: Path,
rules: list[dict[str, object]],
skip_patterns: list[str],
test_indicators: list[str],
skip_extensions: tuple[str, ...],
) -> dict[str, str]:
"""Map each untested file to the reason it's untested."""
missing: dict[str, str] = {}
for f in files:
# Skip test files themselves
if _is_test_file(f, test_indicators):
continue
# Skip config, docs, meta files
if _should_skip_file(f, skip_patterns, skip_extensions):
continue
for rule in rules:
pattern = str(rule.get("source_pattern", ""))
if not fnmatch.fnmatch(f, pattern):
continue
test_templates = rule.get("test_paths", [])
if not isinstance(test_templates, list):
continue
description_template = str(rule.get("description", "Missing test for {f}"))
test_paths = [_resolve_test_path(str(t), f, repo_root) for t in test_templates]
# Check if any test path exists (with .py extension)
found = False
for tp in test_paths:
if tp.with_suffix(".py").exists() or tp.exists():
found = True
break
if not found:
# Format description with file info
name = Path(f).stem
missing[f] = description_template.format(
name=name,
f=f,
test_name=f"test_{name}".replace("-", "_"),
)
break
# If no rule matched, the file is not checked (no test requirement)
# This is intentional — only files matching a rule need tests
return missing
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=_("Check that changed files have corresponding tests"),
)
parser.add_argument(
"--staged-only",
action="store_true",
help=_("Only check staged files (for pre-commit)"),
)
parser.add_argument(
"--warn-only",
action="store_true",
help=_("Print warnings but always exit 0"),
)
args = parser.parse_args(argv)
repo_root = Path.cwd()
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
files = _changed_files(args.staged_only, repo_root)
if not files:
print(_("[check_test_coverage] No changed files to check."))
return 0
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
if not missing:
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
return 0
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
for f, reason in missing.items():
print(f" {f}", file=sys.stderr)
print(f" -> {reason}", file=sys.stderr)
print(
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
file=sys.stderr,
)
if args.warn_only:
return 0
return 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Clean up old Docker images from a Gitea container registry.
Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
Usage::
# Clean up ci-base images, keep last 2 versions
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2
# Clean up multiple images
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--name ci-quality \\
--name ci-full \\
--keep 2
# Dry run (list what would be deleted)
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2 \\
--dry-run
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
"""
from __future__ import annotations
import os
from typing import Any
import click
import requests
from devx.config import GITEA_API_URL
from devx.i18n import _
def list_package_versions(
api_url: str,
owner: str,
name: str,
token: str,
*,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""List all versions of a container package from the Gitea API.
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
while True:
resp = requests.get(
f"{url}&page={page}&limit=50",
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data:
break
all_versions.extend(data)
if len(data) < 50:
break
page += 1
return all_versions
def delete_package_version(
api_url: str,
owner: str,
name: str,
version: str,
token: str,
*,
timeout: int = 30,
) -> bool:
"""Delete a specific version of a container package.
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout)
return resp.status_code in (204, 200)
def sort_versions_by_date(
versions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Sort package versions by creation date, newest first.
Falls back to version string comparison if created_at is missing.
"""
def _sort_key(v: dict[str, Any]) -> str:
return str(v.get("created_at", v.get("version", "")))
return sorted(versions, key=_sort_key, reverse=True)
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
return to_delete
@click.command()
@click.option(
"--owner",
required=True,
help="Package owner (user or org).",
)
@click.option(
"--name",
"names",
multiple=True,
required=True,
help="Package name(s). Can be repeated.",
)
@click.option(
"--keep",
default=2,
type=int,
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="List versions that would be deleted without actually deleting.",
)
@click.option(
"--api-url",
default=None,
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
)
def main(
owner: str,
names: tuple[str, ...],
keep: int,
dry_run: bool,
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
base_url = api_url or GITEA_API_URL
total_deleted = 0
total_kept = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
click.echo(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
continue
if not versions:
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
total_kept += kept_count
continue
deleted_count = 0
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
total_deleted += deleted_count
total_kept += kept_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+17 -4
View File
@@ -6,8 +6,8 @@ The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed.
Usage:
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
"""
from __future__ import annotations
@@ -102,7 +102,7 @@ def configure_repo(
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
"""
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
url = api_url or GITEA_API_URL
client = GiteaClient(url, token, owner, repo)
@@ -148,13 +148,26 @@ def configure_repo(
)
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
"""Configure branch protection and repository settings via the Gitea API."""
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "")
if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
if "/" in repo and owner is None:
parts = repo.split("/", 1)
owner, repo = parts[0], parts[1]
click.echo(
_(
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
owner=owner,
repo=repo,
)
)
if owner is None:
owner = REPO_OWNER
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Create a pull request with the correct title from the Vikunja task.
This tool is run **after** pushing a feature branch. It:
1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``).
2. Fetches the Vikunja task title for that task ID.
3. Creates a PR with title ``{TASK_PREFIX}-N: <vikunja task title>``.
This eliminates manual PR title entry and ensures the title always
matches the Vikunja task — which is what the auto-merge workflow
validates.
If a PR already exists for the branch, the tool prints its URL and
exits successfully (idempotent).
Usage::
python -m devx.tools.create_pr --branch DEVX-31-fix-foo
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient, VikunjaClient
from devx.config import (
DEFAULT_PER_PAGE,
GITEA_API_URL,
REPO_OWNER,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
load_dotenv()
def get_repo_name() -> str:
"""Auto-detect repository name from env vars or git remote."""
name = os.environ.get("DEVX_REPO_NAME", "")
if name:
return name
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if github_repo and "/" in github_repo:
return github_repo.split("/", 1)[1]
raise click.ClickException(
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
)
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given task identifier.
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
),
)
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
"""Return an existing open PR for the branch, or None."""
prs = client.list_prs(state="open")
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return pr
return None
def create_pr(
branch: str,
base: str,
body: str,
repo_owner: str,
repo_name: str,
) -> dict:
"""Create a PR with the title derived from the Vikunja task.
Returns the PR dict from the Gitea API.
"""
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
branch=branch,
prefix=TASK_PREFIX,
),
)
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}"
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
existing = find_existing_pr(client, branch)
if existing:
click.echo(
_(
"PR already exists: #{index} — {url}",
index=existing.get("number", "?"),
url=existing.get("html_url", ""),
),
)
return existing
pr = client.create_pr(title=pr_title, head=branch, base=base, body=body)
click.echo(
_(
"Created PR #{index}: {title}\n {url}",
index=pr.get("number", "?"),
title=pr_title,
url=pr.get("html_url", ""),
),
)
return pr
@click.command()
@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).")
@click.option("--base", default="master", show_default=True, help="Base branch.")
@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None:
"""Create a PR with the correct title from the Vikunja task."""
if branch is None:
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("Could not detect current branch: {error}", error=result.stderr.strip()),
)
branch = result.stdout.strip()
if body == "-":
body = click.get_text_stream("stdin").read().strip()
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
create_pr(branch, base, body, repo_owner, repo_name)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Create a Vikunja task with a detailed HTML description.
This tool is used during the planning phase of the development workflow
to create a well-described task before any code is written. The task
identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used
to name the feature branch and the pull request.
Usage::
python -m devx.tools.create_task --title "Add release automation" \\
--description "<h2>Overview</h2><p>Implement automated...</p>"
The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID``
and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import VikunjaClient
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _
load_dotenv()
@click.command()
@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).")
@click.option(
"--description",
default="",
help="Task description (HTML supported). Read from stdin if '-' is passed.",
)
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
def cli(title: str, description: str, project_id: int | None) -> None:
"""Create a Vikunja task and print its identifier."""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
if description == "-":
description = click.get_text_stream("stdin").read().strip()
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.create_task(pid, title, description)
identifier = task.get("identifier", "")
task_id = task.get("id", "")
click.echo(
_(
"Created Vikunja task: {identifier} (id={task_id})",
identifier=identifier,
task_id=task_id,
)
)
if identifier:
click.echo(
_(
"Next steps:\n"
" 1. git checkout master && git pull\n"
" 2. git checkout -b {prefix}-{num}-short-description\n"
" 3. Implement changes, commit with conventional commit format\n"
" 4. git push -u origin HEAD\n"
" 5. make create-pr (creates PR with title: {identifier}: {title})",
prefix=TASK_PREFIX,
num=identifier.split("-")[-1] if "-" in identifier else "N",
identifier=identifier,
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+231 -60
View File
@@ -5,12 +5,21 @@ Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes SVG badge files that can be served as static files from
the Gitea raw file API.
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
so this module works correctly both when run from a source checkout
and when devx is installed as a pip package in CI.
The package name and coverage target are auto-detected from the
``src/`` directory structure, making this module reusable across
all oblachno-oss repos without per-repo configuration.
Usage:
python3 -m devx.tools.generate_badges --output-dir .badges/
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
@@ -18,8 +27,7 @@ from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parents[4]
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
@@ -37,18 +45,59 @@ COLOR_HEX: dict[str, str] = {
}
def _find_package_init() -> Path | None:
"""Find the first package __init__.py under src/ that defines __version__."""
src_dir = REPO_ROOT / "src"
if not src_dir.exists():
def resolve_repo_root() -> Path:
"""Resolve the repository root directory.
Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
falls back to ``os.getcwd()``. This ensures the correct repo
root is used even when devx is installed as a pip package.
"""
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
path = Path(workspace)
if path.is_dir():
return path
return Path.cwd()
def detect_package_name(repo_root: Path) -> str | None:
"""Auto-detect the Python package name from ``src/`` directory.
Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``,
``gitea_runner_manager``) or ``None`` if no package is found.
"""
src_dir = repo_root / "src"
if not src_dir.is_dir():
return None
for init_file in src_dir.rglob("__init__.py"):
try:
content = init_file.read_text()
except OSError:
for entry in sorted(src_dir.iterdir()):
if not entry.is_dir():
continue
if "__version__" in content:
return init_file
init_file = entry / "__init__.py"
if init_file.exists():
return entry.name
return None
def detect_coverage_target(repo_root: Path) -> str | None:
"""Auto-detect the pytest-cov target from pyproject.toml.
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
``--cov=src/<package>``. Falls back to ``src/<package>`` if
the package is detected but no explicit cov target is found.
"""
pyproject = repo_root / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
match = re.search(r"--cov=(\S+)", content)
if match:
return match.group(1)
# Fallback: derive from package name
pkg = detect_package_name(repo_root)
if pkg:
return f"src/{pkg}"
return None
@@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]:
def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
root = str(cwd or resolve_repo_root())
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
cwd=root,
)
return result.returncode, result.stdout, result.stderr
@@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None:
return None
def read_version() -> str:
"""Read __version__ from the package __init__.py."""
init_file = _find_package_init()
if init_file is None:
def read_version(repo_root: Path) -> str:
"""Read __version__ from the package __init__.py under src/.
Auto-detects the package directory and reads ``__version__``
from its ``__init__.py``.
"""
pkg = detect_package_name(repo_root)
if pkg is None:
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
return "unknown"
init_file = repo_root / "src" / pkg / "__init__.py"
if not init_file.exists():
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
return "unknown"
content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
return "unknown"
@@ -179,65 +239,170 @@ def doc_coverage_color(pct: int) -> str:
return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict."""
badges: dict[str, dict[str, str | int]] = {}
def detect_testpaths(repo_root: Path) -> list[str]:
"""Detect test paths from pyproject.toml or filesystem.
# 1. Code coverage + test count (single pytest-cov run)
rc, stdout, stderr = run_command(
[
sys.executable,
"-m",
"pytest",
"tests/",
"-v",
"--cov=src/devx",
"--cov-report=term-missing",
"--cov-fail-under=0",
]
)
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
pyproject.toml. Falls back to ``["tests"]`` if the tests/
directory exists. Returns an empty list if no test paths
are found (pytest will use its own defaults).
"""
pyproject = repo_root / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
# Match: testpaths = ["dir1", "dir2"]
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
if match:
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
resolved = []
for p in paths:
p = p.strip()
if (repo_root / p).exists():
resolved.append(p)
if resolved:
return resolved
# Fallback: tests/ directory
tests_dir = repo_root / "tests"
if tests_dir.is_dir():
return ["tests"]
return []
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
"""Run pytest-cov and collect coverage + test count badges.
Returns (coverage_badge, tests_badge). If pytest is not
available or no tests are found, returns 'unknown' badges
with a clear warning explaining the failure.
"""
cov_target = detect_coverage_target(repo_root)
if cov_target is None:
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
cmd = [
sys.executable,
"-m",
"pytest",
*testpaths,
"--cov",
cov_target,
"--cov-report=term-missing",
"--cov-fail-under=0",
"-q",
]
rc, stdout, stderr = run_command(cmd, cwd=repo_root)
combined = stdout + "\n" + stderr
coverage = extract_coverage(combined)
if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
badges["coverage"] = make_badge("coverage", "unknown", "red")
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined)
if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
badges["tests"] = make_badge("tests", "unknown", "red")
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
tests_badge = make_badge("tests", "unknown", "red")
# 2. Documentation coverage
rc, stdout, _ = run_command(
[
sys.executable,
"-m",
"devx.ci.doc_coverage",
]
return cov_badge, tests_badge
def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
"""Run doc_coverage and collect the docs badge."""
rc, stdout, stderr = run_command(
[sys.executable, "-m", "devx.ci.doc_coverage"],
cwd=repo_root,
)
doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
else:
badges["docs"] = make_badge("docs", "unknown", "red")
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
return make_badge("docs", "unknown", "red")
# 3. Code quality (ruff + pyright + bandit all pass)
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
def collect_quality(repo_root: Path) -> dict[str, str | int]:
"""Run lint checks and collect the quality badge.
Runs ruff check, ruff format --check, pyright, and bandit.
If any tool is not installed, it is skipped with a warning.
"""
results: list[bool] = []
tool_names: list[str] = []
for cmd, name in [
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
]:
rc, _, stderr = run_command(cmd, cwd=repo_root)
if rc == 0:
results.append(True)
tool_names.append(f"{name}: pass")
else:
results.append(False)
# Distinguish "tool not installed" from "tool found issues"
if "No module named" in stderr or "not found" in stderr.lower():
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
results[-1] = True
tool_names.append(f"{name}: not installed (skipped)")
else:
tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
all_pass = all(results)
click.echo(f" Quality checks: {', '.join(tool_names)}")
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict.
Args:
output_dir: Directory to write SVG files.
repo_root: Repository root (auto-detected if None).
"""
root = repo_root or resolve_repo_root()
click.echo(f" Repo root: {root}")
pkg = detect_package_name(root)
click.echo(f" Package: {pkg or 'none'}")
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
click.echo(" Collecting coverage and tests...")
cov_badge, tests_badge = collect_coverage_and_tests(root)
badges["coverage"] = cov_badge
badges["tests"] = tests_badge
# 2. Documentation coverage
click.echo(" Collecting doc coverage...")
badges["docs"] = collect_doc_coverage(root)
# 3. Code quality (ruff + pyright + bandit)
click.echo(" Collecting code quality...")
badges["quality"] = collect_quality(root)
# 4. Version
version = read_version()
click.echo(" Collecting version...")
version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice)
# 5. Python version (static)
badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files
@@ -254,14 +419,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
@click.command()
@click.option(
"--output-dir",
default=str(REPO_ROOT / ".badges"),
default=".badges",
help="Directory to write badge SVG files.",
)
def cli(output_dir: str) -> None:
@click.option(
"--repo-root",
default=None,
help="Repository root (auto-detected if not specified).",
)
def cli(output_dir: str, repo_root: str | None) -> None:
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...")
badges = generate_badges(out)
badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
+19 -1
View File
@@ -6,6 +6,7 @@ Handles installation of:
- git-cliff (changelog generator)
- act_runner (Gitea Actions local runner, optional)
- tea (Gitea CLI — official command-line tool for Gitea API operations)
- hadolint (Dockerfile linter)
Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available.
@@ -39,6 +40,8 @@ ACT_RUNNER_VERSION = "0.2.11"
TEA_VERSION = "0.14.1"
HADOLINT_VERSION = "2.12.0"
def _arch() -> str:
"""Return the architecture string used by release assets."""
@@ -161,7 +164,20 @@ def install_tea() -> bool:
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
def install_hadolint() -> bool:
"""Install hadolint if not already present. Returns True if installed/skipped."""
if _is_installed("hadolint"):
click.echo("hadolint: already installed")
return True
machine = platform.machine().lower()
arch = "x86_64" if machine in {"x86_64", "amd64"} else "arm64"
url = f"https://github.com/hadolint/hadolint/releases/download/v{HADOLINT_VERSION}/hadolint-Linux-{arch}"
dest = _download_binary(url, "hadolint")
click.echo(f"hadolint: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
def _install_tool(name: str) -> bool:
@@ -174,6 +190,8 @@ def _install_tool(name: str) -> bool:
return install_act_runner()
if name == "tea":
return install_tea()
if name == "hadolint":
return install_hadolint()
raise click.ClickException(f"Unknown tool: {name}")
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Pre-push validation: ensure a Vikunja task exists for the branch.
This tool is designed to run as a git pre-push hook. It extracts the
task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``)
and verifies that a corresponding Vikunja task exists.
If the task does not exist, the hook **fails with guidance** — it does
not auto-create the task. This prevents accidental pushes of branches
without a planning task.
Usage::
python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo
Exit codes:
0 — all checks passed, safe to push
1 — validation failed (missing task, missing token, etc.)
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
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.i18n import _
load_dotenv()
def get_current_branch() -> str:
"""Return the current git branch name, or empty string on error."""
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip()
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def task_exists(task_id: str) -> bool:
"""Check if a Vikunja task with the given identifier exists.
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return False
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
if any(t.get("identifier") == task_id for t in tasks):
return True
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return False
def validate(branch: str) -> None:
"""Run all pre-push validations for the given branch.
Raises ``click.ClickException`` on failure.
"""
if not branch or branch in ("master", "main"):
return
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n"
" Expected format: {prefix}-N-short-description\n"
" Example: {prefix}-42-add-feature\n"
" Fix: rename the branch or create a Vikunja task first:\n"
' python -m devx.tools.create_task --title "Task title"',
branch=branch,
prefix=TASK_PREFIX,
)
)
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
click.echo(
_(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
"Set it in .env to enable full validation.",
),
err=True,
)
return
if not task_exists(task_id):
raise click.ClickException(
_(
"Vikunja task {task_id} not found in project {project_id}.\n"
" Create it first:\n"
' python -m devx.tools.create_task --title "Task title"\n'
" Or check that the task ID in the branch name is correct.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id))
@click.command()
@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).")
def cli(branch: str | None) -> None:
"""Validate pre-push preconditions for the current branch."""
if branch is None:
branch = get_current_branch()
validate(branch)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+16 -6
View File
@@ -64,19 +64,19 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set.
"""Configure tea CLI login from .env if CI_GITEA_TOKEN is set.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips if tea is not installed or REPO_TOKEN is not set.
Skips if tea is not installed or CI_GITEA_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.")
return
token = os.environ.get("REPO_TOKEN", "")
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.")
return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
@@ -150,19 +150,29 @@ def _verify(bin_dir: str) -> None:
default=False,
help="Skip Ansible Galaxy collection installation.",
)
@click.option(
"--skip-install",
is_flag=True,
default=False,
help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).",
)
def main(
bin_dir: str,
extras: str,
no_pre_commit: bool,
no_tea_login: bool,
no_ansible_collections: bool,
skip_install: bool,
) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not skip_install:
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
else:
click.echo("Skipping pip install (--skip-install).")
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
File diff suppressed because it is too large Load Diff
+149
View File
@@ -137,6 +137,19 @@ class TestGiteaClient:
assert result is None
client.create_label.assert_not_called()
def test_ensure_label_creates_when_others_exist(self) -> None:
"""When labels exist but none match the target name, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(
return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}]
)
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -196,6 +209,18 @@ class TestGiteaClient:
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_ensure_branch_protection_creates_when_none_match(self) -> None:
"""When existing protections exist but none match the target branch, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(
return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}]
)
client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["id"] == 5
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
@@ -248,6 +273,37 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_create_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"})
)
result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc")
assert result["number"] == 15
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls",
timeout=DEFAULT_TIMEOUT,
json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"},
)
def test_create_pr_no_body(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"})
)
result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix")
assert result["number"] == 16
call_kwargs = client._session.request.call_args.kwargs
assert "body" not in call_kwargs["json"]
def test_create_pr_custom_base(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 17}))
client.create_pr(title="Test", head="branch", base="develop")
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["base"] == "develop"
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -278,6 +334,35 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
)
result = client.list_prs()
assert len(result) == 2
assert result[0]["number"] == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "all"},
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs_with_params(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
result = client.list_prs(state="closed", q="docs")
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "closed", "q": "docs"},
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
@@ -566,6 +651,46 @@ class TestVikunjaClient:
json={"done": True},
)
def test_list_comments(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
)
result = client.list_comments(42)
assert len(result) == 2
assert result[0]["comment"] == "first"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
)
def test_update_task_safe(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
side_effect=[
_mock_response({"id": 42, "title": "My task", "done": False}),
_mock_response({"id": 42, "title": "My task", "done": True}),
]
)
result = client.update_task_safe(42, done=True)
assert result["done"] is True
assert result["title"] == "My task"
assert client._session.request.call_count == 2
client._session.request.assert_any_call(
"GET",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
)
client._session.request.assert_any_call(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"id": 42, "title": "My task", "done": True},
)
@patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
@@ -632,6 +757,30 @@ class TestVikunjaClient:
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
def test_vikunja_create_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"})
)
result = client.create_task(6, "Test", "<p>desc</p>")
assert result["identifier"] == "DEVX-1"
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/projects/6/tasks",
timeout=DEFAULT_TIMEOUT,
json={"title": "Test", "description": "<p>desc</p>"},
)
def test_vikunja_create_task_no_description(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"})
)
result = client.create_task(6, "No desc")
assert result["id"] == 2
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["description"] == ""
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
+67 -48
View File
@@ -21,23 +21,47 @@ from devx.exceptions import APIError
class TestReadTaskid:
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
assert read_taskid("some-branch") == "DEVX-60"
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_extracts_from_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_returns_empty_when_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("feature-branch") == ""
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
# Branch name takes priority, stale .taskid should produce deprecation warning
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "WARNING" in combined
assert "deprecated" in combined
assert "DEVX-60" in combined
assert "DEVX-19" in combined
def test_no_warning_when_taskid_file_absent(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("DEVX-42-test") == "DEVX-42"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file content matches the branch task ID."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file exists but is empty."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-42-test") == "DEVX-42"
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
# -- extract_task_id (legacy fallback) --
@@ -193,14 +217,13 @@ class TestRunCmd:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
@@ -216,72 +239,72 @@ class TestMain:
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
# No .taskid file, no DEVX-N in branch name
# No DEVX-N in branch name
runner = CliRunner()
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "No task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(
def test_merge_behind_master_raises_no_rebase(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When branch is behind master, auto-merge should NOT rebase.
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.
"""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "behind master" in result.output.lower()
assert "rebase manually" in result.output.lower()
# Must NOT have called merge_pr twice (no retry after rebase)
assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
@@ -298,7 +321,7 @@ class TestMain:
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(
@@ -306,7 +329,6 @@ class TestMain:
) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = []
@@ -320,35 +342,32 @@ class TestMain:
assert result.exit_code != 0
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
assert result.exit_code != 0
assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
assert result.exit_code != 0
assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(
def test_merge_behind_master_does_not_force_push(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
"""Verify no git commands are run when branch is behind master."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
@@ -358,14 +377,14 @@ class TestMain:
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
# No git commands should be run (no rebase, no push)
mock_run.assert_not_called()
def test_main_module_block() -> None:
+583
View File
@@ -0,0 +1,583 @@
"""Unit tests for devx.tools.build_image."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.tools.build_image as build_image
from devx.tools.build_image import (
ImageSpec,
build_full_tag,
load_manifest,
push_image,
registry_login,
)
from devx.tools.build_image import (
build_image as do_build,
)
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
class TestImageSpec:
def test_from_dict_minimal(self) -> None:
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
assert spec.name == "ci-base"
assert spec.dockerfile == "docker/ci-base/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest"]
def test_from_dict_full(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest", "0.19.3"],
}
)
assert spec.name == "ci-quality"
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest", "0.19.3"]
def test_from_dict_missing_name(self) -> None:
with pytest.raises(ValueError, match="missing 'name'"):
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
def test_from_dict_missing_dockerfile(self) -> None:
with pytest.raises(ValueError, match="missing 'dockerfile'"):
ImageSpec.from_dict({"name": "ci-base"})
def test_from_dict_tags_not_list(self) -> None:
with pytest.raises(ValueError, match="tags.*must be a list"):
ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": "latest",
}
)
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": [],
}
)
assert spec.tags == ["latest"]
class TestBuildFullTag:
def test_no_registry(self) -> None:
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
def test_with_registry(self) -> None:
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
def test_with_registry_and_path(self) -> None:
assert (
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
)
class TestLoadManifest:
def test_load_valid_manifest(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
]
)
)
specs = load_manifest(manifest)
assert len(specs) == 2
assert specs[0].name == "ci-base"
assert specs[1].tags == ["latest", "1.0"]
def test_load_missing_file(self, tmp_path: Path) -> None:
with pytest.raises(ClickException, match="not found"):
load_manifest(tmp_path / "nonexistent.json")
def test_load_not_a_list(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(json.dumps({"name": "ci-base"}))
with pytest.raises(ClickException, match="must be a JSON list"):
load_manifest(manifest)
class TestRegistryLogin:
def test_success(self) -> None:
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert registry_login("git.example.com", "user", "token") is True
assert mock_run.call_args.args[0] == [
"docker",
"login",
"git.example.com",
"-u",
"user",
"--password-stdin",
]
assert mock_run.call_args.kwargs["input"] == "token"
def test_failure(self) -> None:
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert registry_login("git.example.com", "user", "bad") is False
def test_dry_run(self) -> None:
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
mock_run.assert_not_called()
class TestBuildImage:
def test_success(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is True
def test_dockerfile_not_found(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
assert do_build(spec) is False
def test_build_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is False
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert do_build(spec, dry_run=True) is True
mock_run.assert_not_called()
def test_with_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, registry="git.example.com") is True
cmd = mock_run.call_args.args[0]
assert "-t" in cmd
idx = cmd.index("-t")
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
def test_pull_flag(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, pull=True) is True
cmd = mock_run.call_args.args[0]
assert "--pull" in cmd
class TestPushImage:
def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2
def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [
MagicMock(returncode=0, stderr="", stdout=""),
MagicMock(returncode=1, stderr="push failed", stdout=""),
]
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False
def test_dry_run(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
class TestSortVersions:
def test_sort_by_created_at_desc(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
def test_sort_fallback_to_version(self) -> None:
versions = [
{"version": "0.1.0"},
{"version": "0.3.0"},
{"version": "0.2.0"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
class TestSelectForDeletion:
def test_keep_2(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 2
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
def test_preserve_latest_tag(self) -> None:
versions = [
{"version": "latest", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
deleted_versions = {v["version"] for v in to_delete}
assert "latest" not in deleted_versions
# latest is oldest by date but still preserved
assert "0.2.0" in deleted_versions
def test_keep_all(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 0
def test_keep_more_than_available(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
]
to_delete = select_for_deletion(versions, keep=5)
assert len(to_delete) == 0
class TestCleanImagesAPI:
"""Tests for the clean_images module's API functions."""
def test_list_package_versions(self) -> None:
from devx.tools.clean_images import list_package_versions
mock_resp = MagicMock()
mock_resp.json.return_value = [{"version": "0.1.0"}]
mock_resp.raise_for_status = MagicMock()
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert versions == [{"version": "0.1.0"}]
assert "page=1" in mock_get.call_args.args[0]
def test_list_package_versions_pagination(self) -> None:
from devx.tools.clean_images import list_package_versions
# First page: 50 items, second page: 3 items, third page: empty
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
responses = [
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
]
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert len(versions) == 53
def test_delete_package_version_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is False
)
class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
)
assert result.exit_code == 0
def test_manifest_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": str(dockerfile)},
]
)
)
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--manifest", str(manifest)],
)
assert result.exit_code == 0
def test_missing_dockerfile_and_manifest(self) -> None:
runner = CliRunner()
result = runner.invoke(build_image.main, [])
assert result.exit_code != 0
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
def test_push_without_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
)
assert result.exit_code != 0
assert "registry" in result.output.lower()
def test_push_without_credentials(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "credential" in result.output.lower() or "token" in result.output.lower()
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch("devx.tools.build_image.subprocess.run") as mock_run:
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
mock_run.assert_not_called()
assert "dry-run" in result.output
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base"],
)
assert result.exit_code != 0
def test_push_login_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "login" in result.output.lower()
def test_push_image_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
build_result = MagicMock(returncode=0)
login_result = MagicMock(returncode=0, stderr="", stdout="")
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
with patch(
"devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result],
):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
class TestCLICleanImages:
def test_dry_run(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
)
assert result.exit_code == 0
assert "dry-run" in result.output
assert "0.1.0" in result.output
def test_no_token(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base"],
)
assert result.exit_code != 0
assert "token" in result.output.lower()
def test_no_versions_found(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "No versions" in result.output
def test_actual_delete(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=204)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "Deleted" in result.output
def test_list_request_exception(self) -> None:
import requests as req
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch(
"devx.tools.clean_images.requests.get",
side_effect=req.ConnectionError("network down"),
):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "FAILED" in result.output
+231
View File
@@ -0,0 +1,231 @@
"""Unit tests for devx.tools.check_agent_docs."""
import re
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_agent_docs import (
DEFAULT_REPO_PATH_PREFIXES,
DEFAULT_SCAN_DIRS,
DEFAULT_SCAN_EXTENSIONS,
DEFAULT_SCAN_FILES,
MIN_PATH_REF_LENGTH_DEFAULT,
_check_file,
_collect_doc_files,
_is_legitimate_ref,
_should_skip,
cli,
)
class TestShouldSkip:
def test_skips_excluded_path(self, tmp_path: Path) -> None:
f = tmp_path / "docs" / "retrospectives" / "r.md"
f.parent.mkdir(parents=True)
f.write_text("")
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is True
def test_does_not_skip_normal(self, tmp_path: Path) -> None:
f = tmp_path / "docs" / "guide.md"
f.parent.mkdir(parents=True)
f.write_text("")
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is False
def test_returns_false_for_path_outside_repo(self, tmp_path: Path) -> None:
f = Path("/tmp/some_other_path/guide.md")
assert _should_skip(f, [], tmp_path) is False
class TestIsLegitimateRef:
def test_legitimate_legacy(self) -> None:
assert _is_legitimate_ref("This is legacy code", ["legacy"]) is True
def test_not_legitimate(self) -> None:
assert _is_legitimate_ref("Use this file", ["legacy"]) is False
def test_case_insensitive(self) -> None:
assert _is_legitimate_ref("This is LEGACY", ["legacy"]) is True
class TestCollectDocFiles:
def test_collects_devin_and_docs(self, tmp_path: Path) -> None:
(tmp_path / ".devin").mkdir()
(tmp_path / ".devin" / "guide.md").write_text("")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "api.md").write_text("")
(tmp_path / "README.md").write_text("")
files = _collect_doc_files(tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
names = {f.name for f in files}
assert "guide.md" in names
assert "api.md" in names
assert "README.md" in names
def test_excludes_paths(self, tmp_path: Path) -> None:
(tmp_path / "docs" / "retrospectives").mkdir(parents=True)
(tmp_path / "docs" / "retrospectives" / "r.md").write_text("")
(tmp_path / "docs" / "guide.md").write_text("")
files = _collect_doc_files(
tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, ["docs/retrospectives"]
)
names = {f.name for f in files}
assert "guide.md" in names
assert "r.md" not in names
def test_deduplicates(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "api.md").write_text("")
files = _collect_doc_files(tmp_path, ["docs", "docs"], DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
assert len(files) == 1
class TestCheckFile:
def test_detects_deleted_file_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/old.py for details.\n")
issues = _check_file(
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert any("deleted file" in i for i in issues)
def test_detects_nonexistent_file_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/nonexistent.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert any("non-existent file" in i for i in issues)
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
(tmp_path / "scripts").mkdir()
(tmp_path / "scripts" / "exists.py").write_text("")
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/exists.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert issues == []
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert any("deprecated pattern" in i for i in issues)
def test_legitimate_ref_skips_deprecated(self, tmp_path: Path) -> None:
# Create the referenced file so the non-existent check doesn't trigger
secrets = tmp_path / "ansible" / "envs" / "prod" / "secrets.yml"
secrets.parent.mkdir(parents=True)
secrets.write_text("")
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert issues == []
def test_unicode_error_returns_empty(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_bytes(b"\xff\xfe\x00\x00")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert issues == []
def test_skips_short_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See a.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5, [])
# "a.py" is only 4 chars, below min_path_ref_length
assert issues == []
def test_skips_ref_without_repo_prefix(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See vendor/some/long/path.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
# "vendor/" is not in repo_path_prefixes
assert issues == []
def test_skip_ref_prefixes_skips_nonexistent(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/test_foo.py for details.\n")
issues = _check_file(
doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, ["scripts/test_"]
)
assert issues == []
class TestCli:
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value={}),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_stale_ref(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("See scripts/deleted.py\n")
cfg = {"deleted_files": ["scripts/deleted.py"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_load_config_returns_empty_when_not_dict(self) -> None:
from devx.tools.check_agent_docs import _load_config
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": "not a dict"}):
assert _load_config() == {}
def test_load_config_returns_dict_when_valid(self) -> None:
from devx.tools.check_agent_docs import _load_config
cfg = {"scan_dirs": ["custom"]}
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": cfg}):
assert _load_config() == cfg
def test_invalid_regex_pattern_skipped(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
cfg = {"deprecated_patterns": ["[invalid"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
def test_custom_scan_dirs(self, tmp_path: Path) -> None:
custom = tmp_path / "custom_docs"
custom.mkdir()
(custom / "guide.md").write_text("See scripts/deleted.py\n")
cfg = {"scan_dirs": ["custom_docs"], "deleted_files": ["scripts/deleted.py"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
+292
View File
@@ -0,0 +1,292 @@
"""Unit tests for devx.ci.check_auto_merge_ready."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.check_auto_merge_ready import (
cli,
get_pr_title_from_gitea,
get_vikunja_title_optional,
is_branch_behind_master,
)
class TestIsBranchBehindMaster:
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None:
# First: fetch (ok), second: ahead count (ok), third: behind count = 0
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=0, stdout="0\n", stderr=""),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_true_when_behind(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="0\n", stderr=""),
MagicMock(returncode=0, stdout="5\n", stderr=""),
]
assert is_branch_behind_master("feature") is True
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="error"),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30)
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=0, stdout="not_a_number\n", stderr=""),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None:
mock_run.side_effect = FileNotFoundError("git not found")
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None:
# fetch ok, ahead count ok, behind count command fails
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="error"),
]
assert is_branch_behind_master("feature") is False
class TestGetPrTitleFromGitea:
def test_returns_none_without_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert get_pr_title_from_gitea("owner/repo", 1) is None
def test_returns_none_with_invalid_repo(self) -> None:
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
assert get_pr_title_from_gitea("invalid", 1) is None
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_fetches_title(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1)
assert result == "DEVX-1: Fix bug"
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr.side_effect = Exception("API error")
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1)
assert result is None
class TestGetVikunjaTitleOptional:
def test_returns_none_without_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert get_vikunja_title_optional("DEVX-1") is None
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result == "Fix bug"
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result is None
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
# First page: full page of non-matching tasks, second page: match
page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "DEVX-99", "title": "Found it"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-99")
assert result == "Found it"
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result is None
class TestCli:
def test_fails_without_task_id(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True):
result = runner.invoke(cli, ["--branch", "no-task-id-here"])
assert result.exit_code != 0
def test_local_mode_no_pr_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"])
assert result.exit_code == 0
assert "local mode" in result.output
def test_validates_pr_title_format(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"])
assert result.exit_code != 0
assert "format" in result.output.lower() or "mismatch" in result.output.lower()
def test_passes_with_valid_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"])
assert result.exit_code == 0
assert "satisfied" in result.output
def test_skip_behind_check(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"],
)
assert result.exit_code == 0
def test_fails_when_behind_master(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "behind" in result.output.lower()
def test_skip_vikunja(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"],
)
assert result.exit_code == 0
def test_fetches_pr_title_from_gitea(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
)
assert result.exit_code == 0
assert "from Gitea" in result.output
def test_fails_when_pr_number_but_no_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
)
assert result.exit_code != 0
assert "Could not fetch" in result.output
def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "Could not find Vikunja task" in result.output
def test_passes_with_vikunja_title_match(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code == 0
assert "Vikunja title match OK" in result.output
def test_fails_with_vikunja_title_mismatch(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "does not match Vikunja" in result.output
+90
View File
@@ -0,0 +1,90 @@
"""Unit tests for devx.tools.check_config."""
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_config import cli
class TestCheckConfig:
def test_valid_config(self, tmp_path: Path) -> None:
"""A valid [tool.devx] section with consistent versions passes."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
assert "Configuration OK" in result.output
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
"""Missing [tool.devx] section fails with error."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
result = runner.invoke(cli)
assert result.exit_code == 1
assert "missing required keys" in result.output
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
"""Partial [tool.devx] section fails with missing keys."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
result = runner.invoke(cli)
assert result.exit_code == 1
assert "missing required keys" in result.output
assert "vikunja_project_id" in result.output
assert "repo_owner" in result.output
assert "repo_name" in result.output
def test_version_mismatch(self, tmp_path: Path) -> None:
"""Version mismatch across extras fails."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
"[project.optional-dependencies]\n"
'ci = ["devx>=0.15.0"]\n'
'dev = ["devx>=0.14.2"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 1
assert "version mismatch" in result.output
def test_no_pyproject_file(self, tmp_path: Path) -> None:
"""Missing pyproject.toml fails."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
result = runner.invoke(cli)
assert result.exit_code == 1
assert "not found" in result.output
def test_no_extras_passes(self, tmp_path: Path) -> None:
"""No optional-dependencies with devx is fine (no versions to compare)."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
assert "Configuration OK" in result.output
def test_single_extra_passes(self, tmp_path: Path) -> None:
"""Single extra with devx version is fine (no mismatch possible)."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for devx.tools.check_mutable_globals."""
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_mutable_globals import (
DEFAULT_SCAN_DIRS,
DEFAULT_SKIP_DIRS,
_load_config,
_should_skip,
cli,
find_mutable_globals,
)
class TestFindMutableGlobals:
def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None:
source = "_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_SEEN" in issues[0]
assert "set()" in issues[0]
def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_CACHE" in issues[0]
def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None:
source = "PATHS: list[Path] = []\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "PATHS" in issues[0]
def test_skips_non_mutable_globals(self, tmp_path: Path) -> None:
source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_SEEN" in issues[0]
def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None:
source = "_DATA: dict[str, int] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 0
def test_detects_path_type_annotation(self, tmp_path: Path) -> None:
source = "_FILES: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_known_safe_exception(self, tmp_path: Path) -> None:
source = "_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
known_safe = {("mod.py", 1, "_SEEN")}
issues = find_mutable_globals(f, tmp_path, known_safe)
assert len(issues) == 0
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
f = tmp_path / "mod.py"
f.write_text("def broken(:\n")
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_detects_mutable_literal_list(self, tmp_path: Path) -> None:
source = "SEEN_PATHS: list[Path] = []\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_detects_mutable_literal_set(self, tmp_path: Path) -> None:
source = "REGISTRY: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_skips_function_definitions(self, tmp_path: Path) -> None:
source = "def foo():\n pass\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_handles_assign_with_name_target(self, tmp_path: Path) -> None:
source = "SEEN_PATHS = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "SEEN_PATHS" in issues[0]
def test_skips_annotation_without_value(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any]\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_skips_attribute_call(self, tmp_path: Path) -> None:
# collections.defaultdict is an Attribute call, not a Name call
source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
# Attribute calls are skipped (pass), so not flagged as mutable literal
assert issues == []
def test_multiple_assign_targets(self, tmp_path: Path) -> None:
source = "SEEN = CACHE = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
# Both SEEN and CACHE should be flagged
assert len(issues) == 2
class TestShouldSkip:
def test_skips_pycache(self) -> None:
assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True
def test_skips_venv(self) -> None:
assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True
def test_does_not_skip_normal(self) -> None:
assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False
class TestLoadConfig:
def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None:
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
assert skip_dirs == DEFAULT_SKIP_DIRS
assert known_safe == set()
def test_reads_config_from_pyproject(self) -> None:
cfg = {
"check_mutable_globals": {
"scan_dirs": ["src", "tests"],
"skip_dirs": ["__pycache__", ".tox"],
"known_safe": ["src/mod.py:10:_CACHE"],
}
}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == ["src", "tests"]
assert ".tox" in skip_dirs
assert ("src/mod.py", 10, "_CACHE") in known_safe
def test_returns_defaults_when_cfg_not_dict(self) -> None:
with patch(
"devx.tools.check_mutable_globals._load_pyproject_devx",
return_value={"check_mutable_globals": "not a dict"},
):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
assert skip_dirs == DEFAULT_SKIP_DIRS
assert known_safe == set()
def test_known_safe_with_invalid_line_number(self) -> None:
cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
_, _, known_safe = _load_config()
assert known_safe == set()
def test_scan_dirs_not_list_returns_default(self) -> None:
cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
scan_dirs, _, _ = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
class TestCli:
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_issues_found(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "src"
scan_dir.mkdir()
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "custom"
scan_dir.mkdir()
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, ["--scan-dir", "custom"])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "src"
pycache = scan_dir / "__pycache__"
pycache.mkdir(parents=True)
(pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
+208
View File
@@ -0,0 +1,208 @@
"""Unit tests for devx.tools.check_pyproject_deps."""
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_pyproject_deps import check_deps, cli
class TestCheckDeps:
def test_no_issues_when_all_documented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
# CLI framework
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_finds_undocumented_dependency(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 1
assert "click" in issues[0]
def test_finds_multiple_undocumented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"requests>=2.0"
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 2
def test_handles_optional_dependencies(self, tmp_path: Path) -> None:
content = """\
[project.optional-dependencies]
ci = [
# Test runner
"pytest>=8",
"pytest-cov>=4",
]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 1
assert "pytest-cov" in issues[0]
def test_returns_file_not_found_for_missing_file(self, tmp_path: Path) -> None:
issues = check_deps(tmp_path / "nonexistent.toml")
assert len(issues) == 1
assert "not found" in issues[0]
def test_empty_deps_section_no_issues(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_skips_non_deps_sections(self, tmp_path: Path) -> None:
content = """\
[project]
name = "test"
version = "0.1.0"
[project.dependencies]
# HTTP
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_handles_dash_prefixed_deps(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
-requests>=2.0
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_empty_lines_in_deps_section(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
# CLI
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_non_dep_non_comment_line_resets_prev(self, tmp_path: Path) -> None:
# A line that's not a comment, not a dep, not empty — resets prev_was_comment
content = """\
[project.dependencies]
# Comment
ci = [
"requests>=2.0",
]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# "requests" is preceded by a comment, but the `ci = [` line resets prev_was_comment
# Actually `ci = [` doesn't start with - or ", so it hits the else branch
assert len(issues) == 1
def test_section_transition_exits_deps(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP
"requests>=2.0"
[project.optional-dependencies]
# Test runner
"pytest>=8"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# Both deps are documented
assert issues == []
def test_deps_after_other_section_not_checked(self, tmp_path: Path) -> None:
content = """\
[project]
name = "test"
[project.dependencies]
# Documented
"requests>=2.0"
[tool.ruff]
line-length = 120
"undocumented-dep>=1.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# The "undocumented-dep" is in [tool.ruff], not a deps section
assert issues == []
class TestCli:
def test_passes_when_all_documented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
runner = CliRunner()
with __import__("contextlib").chdir(tmp_path):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_undocumented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
runner = CliRunner()
with __import__("contextlib").chdir(tmp_path):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_custom_file_option(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# Documented
"requests>=2.0"
"""
f = tmp_path / "custom.toml"
f.write_text(content)
runner = CliRunner()
result = runner.invoke(cli, ["--file", str(f)])
assert result.exit_code == 0
+239
View File
@@ -0,0 +1,239 @@
"""Unit tests for devx.tools.check_test_coverage."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from devx.tools.check_test_coverage import (
BUILTIN_RULES,
DEFAULT_SKIP_EXTENSIONS,
DEFAULT_TEST_INDICATORS,
_changed_files,
_find_missing_tests,
_is_test_file,
_load_rules,
_resolve_test_path,
_should_skip_file,
main,
)
class TestIsTestFile:
def test_tests_dir(self) -> None:
assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True
def test_test_prefix(self) -> None:
assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True
def test_test_suffix(self) -> None:
assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True
def test_non_test_file(self) -> None:
assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False
class TestShouldSkipFile:
def test_skips_dotfiles(self) -> None:
assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_markdown(self) -> None:
assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_yaml(self) -> None:
assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_does_not_skip_python(self) -> None:
assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False
def test_skips_by_pattern(self) -> None:
assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_by_glob_pattern(self) -> None:
assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True
class TestResolveTestPath:
def test_resolves_name(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path)
assert result == tmp_path / "tests" / "unit" / "test_foo"
def test_resolves_module(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path)
assert result == tmp_path / "tests" / "unit" / "test_pkg_foo"
def test_resolves_package_prefix(self, tmp_path: Path) -> None:
result = _resolve_test_path(
"tests/unit/test_{package_prefix}_{name}",
"scripts/utils/secrets.py",
tmp_path,
)
assert result == tmp_path / "tests" / "unit" / "test_utils_secrets"
def test_normalizes_hyphens(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path)
assert result == tmp_path / "tests" / "test_my_script"
class TestFindMissingTests:
def test_finds_missing_test(self, tmp_path: Path) -> None:
files = ["scripts/foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert "scripts/foo.py" in missing
def test_no_missing_when_test_exists(self, tmp_path: Path) -> None:
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
files = ["scripts/foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_skips_test_files(self, tmp_path: Path) -> None:
files = ["tests/unit/test_foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_skips_non_python_files(self, tmp_path: Path) -> None:
files = ["README.md", "config.yml"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_no_rule_no_requirement(self, tmp_path: Path) -> None:
files = ["unknown_type.xyz"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
class TestChangedFiles:
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0)
files = _changed_files(staged_only=True, repo_root=tmp_path)
assert files == ["file1.py", "file2.py"]
cmd = mock_run.call_args.args[0]
assert "--cached" in cmd
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0)
files = _changed_files(staged_only=False, repo_root=tmp_path)
assert files == ["file1.py"]
cmd = mock_run.call_args.args[0]
assert "origin/master...HEAD" in cmd
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None:
# First call fails, second succeeds
mock_run.side_effect = [
MagicMock(stdout="", returncode=1),
MagicMock(stdout="file1.py\n", returncode=0),
]
files = _changed_files(staged_only=False, repo_root=tmp_path)
assert files == ["file1.py"]
class TestLoadRules:
def test_defaults_when_no_config(self) -> None:
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}):
rules, skip, indicators, skip_ext = _load_rules()
assert rules == BUILTIN_RULES
assert skip == []
assert indicators == DEFAULT_TEST_INDICATORS
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
def test_custom_rules(self) -> None:
cfg = {
"check_test_coverage": {
"rules": [
{
"source_pattern": "lib/*.py",
"test_paths": ["tests/test_{name}"],
"description": "Missing: tests/test_{name}",
}
],
"skip_patterns": ["__init__.py"],
}
}
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
rules, skip, indicators, skip_ext = _load_rules()
assert len(rules) == 1
assert rules[0]["source_pattern"] == "lib/*.py"
assert "__init__.py" in skip
def test_returns_defaults_when_cfg_not_dict(self) -> None:
with patch(
"devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"}
):
rules, skip, indicators, skip_ext = _load_rules()
assert rules == BUILTIN_RULES
assert skip == []
def test_skip_extensions_not_list_returns_default(self) -> None:
cfg = {"check_test_coverage": {"skip_extensions": "not a list"}}
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
_, _, _, skip_ext = _load_rules()
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None:
files = ["scripts/foo.py"]
rules = [
{
"source_pattern": "scripts/*.py",
"test_paths": "not a list",
"description": "Missing test",
}
]
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
# Rule matches but test_paths is not a list, so it's skipped — no missing
assert missing == {}
class TestMain:
def test_no_changed_files(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=[]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 0
def test_all_have_tests(self, tmp_path: Path) -> None:
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 0
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 1
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main(["--warn-only"]) == 0
+34 -16
View File
@@ -41,9 +41,12 @@ class TestCheckTranslationSet:
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
# Include dynamic keys since collect_keys now adds them for all dirs
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
@@ -170,9 +173,11 @@ class TestMain:
def test_translations_flag(self, tmp_path: Path) -> None:
"""--translations flag should check a specific file."""
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
(tmp_path / "mod.py").write_text('_("Hello")\n')
runner = CliRunner()
@@ -243,14 +248,27 @@ class TestDevxI18n:
import devx.i18n
importlib.reload(devx.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
# "ERROR: CI_GITEA_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: CI_GITEA_TOKEN is not set.")
assert "FEHLER" in result
# Restore
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_polish_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "pl")
import devx.i18n
importlib.reload(devx.i18n)
result = devx.i18n._("Running tests...")
assert "Uruchamianie testów" in result
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
@@ -290,18 +308,18 @@ class TestCollectKeys:
assert "should_appear" in keys
assert "should_not_appear" not in keys
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should not include DYNAMIC_KEYS."""
def test_non_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should also include DYNAMIC_KEYS."""
(tmp_path / "mod.py").write_text('_("mykey")\n')
keys = check_translations.collect_keys(tmp_path)
assert "mykey" in keys
# Dynamic keys should NOT be present for non-default dirs
assert "completed" not in keys
assert "pending" not in keys
# Dynamic keys should be present for all dirs
assert "completed" in keys
assert "pending" in keys
def test_default_dir_includes_dynamic_keys(self) -> None:
"""The default source dir should include DYNAMIC_KEYS."""
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
keys = check_translations.collect_keys(tmp_path)
assert "completed" in keys
assert "pending" in keys
assert "in_progress" in keys
+122
View File
@@ -162,6 +162,15 @@ class TestClassifierConfig:
assert config.user_facing_overrides == []
assert config.tags == {}
def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None:
"""Project infrastructure patterns already in defaults are not duplicated."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
# .gitea/** should appear only once (deduplicated with defaults)
assert config.infrastructure.count(".gitea/**") == 1
assert "scripts/**" in config.infrastructure
def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig()
@@ -515,6 +524,27 @@ class TestMain:
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_skips_empty_tag(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Tags with no matching files are skipped in default mode output."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"], "docs": ["docs/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
# docs tag has no matching files — should not appear
assert "Docs files" not in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
@@ -742,3 +772,95 @@ class TestGithubOutput:
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output writes user-facing-changed=true and all tags true."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
assert "Forced user-facing-changed=true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
def test_force_without_github_output_does_nothing(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force without --github-output falls through to normal classification."""
mock_clf.return_value = self._make_classifier_with_ansible()
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--force", "--quiet"])
assert result.exit_code == 0
assert result.output.strip() == "false"
@patch("devx.ci.classify_changes._get_classifier")
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output and no tags writes only user-facing-changed=true."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "true")
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var_false(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""FORCE_DEPLOY=false does not activate force mode."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_flag_overrides_env_var(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force flag works even when FORCE_DEPLOY=false."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
-7
View File
@@ -233,13 +233,6 @@ class TestNewCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+106 -25
View File
@@ -1,12 +1,14 @@
"""Unit tests for config module constants."""
import importlib
from pathlib import Path
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
MAX_RETRIES,
REPO_OWNER,
RETRY_BACKOFF_BASE,
RETRY_STATUS_CODES,
TASK_ID_RE,
@@ -20,25 +22,10 @@ class TestConfigConstants:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self, monkeypatch: object) -> None:
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 6
# Restore module state
importlib.reload(cfg)
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None:
assert REPO_OWNER == "oblachno-oss"
def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX"
@@ -64,28 +51,122 @@ class TestConfigConstants:
assert 503 in RETRY_STATUS_CODES
assert 504 in RETRY_STATUS_CODES
def test_env_var_override(self, monkeypatch: object) -> None:
"""Test that env vars override defaults at import time."""
# We can't easily re-import the module, but we can verify
# the constants respect env vars by checking the module source.
class TestPyprojectReading:
"""Test that config.py reads [tool.devx] from pyproject.toml."""
def test_pyproject_provides_values(self) -> None:
"""When pyproject.toml has [tool.devx], values are read from it."""
import devx.config as cfg
assert cfg.GITEA_API_URL # always non-empty
assert cfg.VIKUNJA_API_URL # always non-empty
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 8
assert cfg.REPO_OWNER == "oblachno-oss"
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
"""Env vars take priority over pyproject.toml."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "CUSTOM"
assert cfg.TASK_ID_RE.search("CUSTOM-42")
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When no pyproject.toml exists, defaults are used."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 6
assert cfg.REPO_OWNER == ""
importlib.reload(cfg)
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml is invalid TOML, defaults are used."""
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml has no [tool.devx], defaults are used."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 6
importlib.reload(cfg)
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 42
importlib.reload(cfg)
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
"""Env var override for int config takes priority over pyproject.toml."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 99
importlib.reload(cfg)
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When [tool] is not a dict, defaults are used."""
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When [tool.devx] is not a dict, defaults are used."""
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
class TestTaskPrefixOverride:
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "INFRA"
assert cfg.TASK_ID_RE.search("INFRA-42")
assert not cfg.TASK_ID_RE.search("DEVX-42")
# Restore
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
+44 -10
View File
@@ -47,7 +47,7 @@ class TestDefaultConfigs:
class TestConfigureRepo:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -58,7 +58,7 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -69,10 +69,10 @@ class TestConfigureRepo:
configure_repo(token="tok", owner="owner", repo="repo")
def test_configure_repo_no_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
configure_repo(token="", owner="owner", repo="repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -102,7 +102,7 @@ class TestConfigureRepo:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -114,7 +114,7 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -125,7 +125,7 @@ class TestMain:
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -142,16 +142,16 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_main_no_repo(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Repository name not specified" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
@@ -163,3 +163,37 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once()
args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop"
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
# Verify GiteaClient was constructed with parsed owner and repo (positional)
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
assert call_args[0][3] == "infra" # repo is 4th positional arg
@patch.dict(
"os.environ",
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
clear=True,
)
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner
assert call_args[0][3] == "infra" # repo
+193
View File
@@ -0,0 +1,193 @@
"""Unit tests for devx.tools.create_pr."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.create_pr import (
cli,
create_pr,
extract_task_id,
find_existing_pr,
get_repo_name,
get_vikunja_task_title,
)
class TestExtractTaskId:
def test_valid(self) -> None:
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
def test_invalid(self) -> None:
assert extract_task_id("feature") == ""
class TestGetRepoName:
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"):
get_repo_name()
class TestGetVikunjaTaskTitle:
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("DEVX-42") == "Add feature"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
class TestFindExistingPr:
def test_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is not None
assert result["number"] == 10
def test_not_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is None
class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
assert result["number"] == 15
mock_client.create_pr.assert_called_once_with(
title="DEVX-42: Add feature",
head="DEVX-42-fix",
base="master",
body="body",
)
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock()
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
assert result["number"] == 10
mock_client.create_pr.assert_not_called()
@patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo")
class TestCli:
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
assert result.exit_code == 0
mock_create.assert_called_once()
assert mock_create.call_args.args[2] == "PR body text"
@patch("devx.tools.create_pr.REPO_OWNER", "")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_missing_owner(self, mock_repo: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for devx.tools.create_task."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.create_task import cli
class TestCreateTaskCli:
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code == 0
assert "DEVX-60" in result.output
mock_client.create_task.assert_called_once()
@patch.dict("os.environ", {}, clear=True)
def test_missing_token(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code != 0
assert "VIKUNJA_TOKEN" in result.output
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_with_description(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Y", "--description", "<p>desc</p>"],
)
assert result.exit_code == 0
call_args = mock_client.create_task.call_args
assert call_args.args[1] == "Add feature Y"
assert call_args.args[2] == "<p>desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Z", "--description", "-"],
input="<p>stdin desc</p>",
)
assert result.exit_code == 0
mock_client.create_task.assert_called_once()
call_args = mock_client.create_task.call_args
assert call_args.args[2] == "<p>stdin desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_custom_project_id(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"])
assert result.exit_code == 0
mock_client.create_task.assert_called_once_with(3, "Task", "")
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"id": 99}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task"])
assert result.exit_code == 0
assert "id=99" in result.output
+12
View File
@@ -237,3 +237,15 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
# Verify owner/repo passed through
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+61
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
_file_weight,
discover_files,
distribute,
files_for_runner,
@@ -101,6 +102,23 @@ class TestCli:
assert "ASSIGNED_FILES=" in content
assert "SKIP=false" in content
def test_github_env_multiline_uses_heredoc(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(6):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
# Multi-line values must use heredoc syntax to avoid corrupting $GITHUB_ENV
assert "ASSIGNED_FILES<<EOF" in content
assert content.count("EOF") >= 2
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
@@ -152,3 +170,46 @@ def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
class TestFileWeight:
def test_weight_based_on_size(self, tmp_path: Path) -> None:
f = tmp_path / "test_big.py"
f.write_text("x" * 5000)
assert _file_weight(str(f)) == 5000
def test_min_weight_is_1(self, tmp_path: Path) -> None:
f = tmp_path / "empty.py"
f.write_text("")
assert _file_weight(str(f)) == 1
def test_nonexistent_file_returns_1(self) -> None:
assert _file_weight("/nonexistent/file.py") == 1
class TestDistributeLpt:
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
"""Two large files should go to different runners."""
big1 = tmp_path / "test_big1.py"
big2 = tmp_path / "test_big2.py"
small1 = tmp_path / "test_small1.py"
small2 = tmp_path / "test_small2.py"
big1.write_text("x" * 10000)
big2.write_text("x" * 10000)
small1.write_text("x")
small2.write_text("x")
files = [str(big1), str(big2), str(small1), str(small2)]
groups = distribute(files, 2)
runner_0 = groups[0]
runner_1 = groups[1]
# Big files should be on different runners
assert not (str(big1) in runner_0 and str(big2) in runner_0)
assert not (str(big1) in runner_1 and str(big2) in runner_1)
def test_all_files_preserved(self, tmp_path: Path) -> None:
for i in range(5):
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
groups = distribute(files, 3)
flat = sorted(f for group in groups for f in group)
assert flat == sorted(files)
+159
View File
@@ -13,6 +13,9 @@ from devx.molecule.distribute_molecule import (
PLATFORMS,
MultiRoleTestPair,
TestPair,
_load_molecule_weights,
_lpt_distribute,
_scenario_weight,
build_multi_role_pairs,
build_pairs,
cli,
@@ -477,3 +480,159 @@ class TestCliMultiRole:
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
class TestScenarioWeight:
def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without pyproject.toml, all scenarios get the default weight."""
monkeypatch.chdir(tmp_path)
scenario_w, role_w = _load_molecule_weights()
assert scenario_w == {}
assert role_w == {}
assert _scenario_weight("unknown-scenario") == 3
def test_load_weights_from_pyproject(self, tmp_path: Path) -> None:
"""Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.molecule.weights]\n"
'"nextcloud" = 15\n'
'"default" = 3\n'
'"binary" = 2\n'
'"app_container/customer-apps" = 11\n'
'"restore/default" = 11\n'
)
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {"nextcloud": 15, "default": 3, "binary": 2}
assert role_w == {("app_container", "customer-apps"): 11, ("restore", "default"): 11}
def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Role-specific weights take priority over scenario-name-only weights."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.molecule.weights]\n"default" = 3\n"docker_base/default" = 8\n"restore/default" = 11\n'
)
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("default", "docker_base") == 8
assert _scenario_weight("default", "restore") == 11
assert _scenario_weight("default", "app_container") == 3
def test_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Weight keys are matched case-insensitively."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("NextCloud") == 15
assert _scenario_weight("NEXTCLOUD") == 15
def test_substring_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Scenario-name weights use substring matching."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("nextcloud-with-redis") == 15
def test_no_pyproject_returns_empty(self, tmp_path: Path) -> None:
"""Missing pyproject.toml returns empty weight dicts."""
scenario_w, role_w = _load_molecule_weights(str(tmp_path / "nonexistent.toml"))
assert scenario_w == {}
assert role_w == {}
def test_invalid_weights_ignored(self, tmp_path: Path) -> None:
"""Non-integer weight values are silently ignored."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"good" = 5\n"bad" = "not an int"\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {"good": 5}
assert role_w == {}
def test_malformed_toml_returns_empty(self, tmp_path: Path) -> None:
"""Malformed TOML returns empty weight dicts."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("this is not valid toml = = =")
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {}
assert role_w == {}
def test_non_dict_weights_returns_empty(self, tmp_path: Path) -> None:
"""If [tool.devx.molecule.weights] is not a table, returns empty dicts."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule]\nweights = "not a table"\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {}
assert role_w == {}
class TestLptDistribute:
def test_equal_weights_produce_even_split(self) -> None:
items = list(range(6))
weights = [3, 3, 3, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
assert all(len(g) == 2 for g in groups)
def test_heavy_items_on_different_runners(self) -> None:
"""Two heavy items should go to different runners."""
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
weights = [10, 10, 1, 1]
groups = _lpt_distribute(items, weights, 2)
# Heavy items should be on different runners
flat = [item for group in groups for item in group]
assert "heavy-a" in flat
assert "heavy-b" in flat
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
assert runner_a != runner_b
def test_load_balance_with_varying_weights(self) -> None:
"""LPT should produce better load balance than round-robin."""
items = list(range(7))
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
weights = [10, 10, 7, 7, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
loads = [sum(weights[i] for i in g) for g in groups]
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
assert max(loads) - min(loads) <= 10 # Reasonably balanced
def test_more_runners_than_items(self) -> None:
items = ["a"]
weights = [5]
groups = _lpt_distribute(items, weights, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_items(self) -> None:
groups = _lpt_distribute([], [], 3)
assert groups == [[], [], []]
def test_preserves_all_items(self) -> None:
items = ["a", "b", "c", "d", "e"]
weights = [5, 3, 8, 1, 2]
groups = _lpt_distribute(items, weights, 3)
flat = sorted(item for group in groups for item in group)
assert flat == sorted(items)
class TestDistributeLpt:
def test_nextcloud_on_separate_runners(self) -> None:
"""Two nextcloud scenarios should go to different runners."""
pairs = [
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
TestPair("default", {"name": "p", "image": "i", "command": ""}),
]
groups = distribute(pairs, 2)
# Both nextcloud scenarios (weight 10) should be on different runners
runner_0 = [p.scenario for p in groups[0]]
runner_1 = [p.scenario for p in groups[1]]
# nextcloud and nextcloud-backup should NOT be on the same runner
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)
+15
View File
@@ -46,6 +46,21 @@ class TestExtractCliCommands:
commands = extract_cli_commands()
assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a command decorator has no name and no following def, it is skipped."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
)
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands
assert "pass" not in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
+215 -142
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/generate_badges.py."""
"""Unit tests for devx/tools/generate_badges.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -8,7 +8,13 @@ from click.testing import CliRunner
from devx.tools.generate_badges import (
COLOR_HEX,
cli,
collect_coverage_and_tests,
collect_doc_coverage,
collect_quality,
coverage_color,
detect_coverage_target,
detect_package_name,
detect_testpaths,
doc_coverage_color,
extract_coverage,
extract_doc_coverage,
@@ -17,10 +23,124 @@ from devx.tools.generate_badges import (
make_badge,
read_version,
render_svg,
resolve_repo_root,
run_command,
)
class TestResolveRepoRoot:
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
result = resolve_repo_root()
assert result == Path.cwd()
class TestDetectPackageName:
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
assert detect_package_name(tmp_path) == "mypkg"
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_package_name(tmp_path) is None
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
# No __init__.py
assert detect_package_name(tmp_path) is None
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
for name in ["zpkg", "apkg"]:
d = src / name
d.mkdir(parents=True)
(d / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "apkg"
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
src.mkdir(parents=True)
(src / "README.md").write_text("not a package")
pkg = src / "mypkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "mypkg"
class TestDetectCoverageTarget:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
)
assert detect_coverage_target(tmp_path) == "src/devx"
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_coverage_target(tmp_path) is None
def test_pyproject_without_cov_falls_back_to_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When pyproject exists but has no --cov=, falls back to package name."""
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\naddopts = "-ra"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
class TestDetectTestpaths:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "tests" / "unit").mkdir(parents=True)
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n'
)
assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"]
def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "tests").mkdir()
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
assert detect_testpaths(tmp_path) == ["tests"]
def test_all_paths_nonexistent_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When all testpaths are non-existent, falls back to tests/ directory."""
(tmp_path / "tests").mkdir()
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\ntestpaths = ["nonexistent1", "nonexistent2"]\n'
)
assert detect_testpaths(tmp_path) == ["tests"]
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "tests").mkdir()
assert detect_testpaths(tmp_path) == ["tests"]
def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_testpaths(tmp_path) == []
def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n")
assert detect_testpaths(tmp_path) == []
class TestRunCommand:
@patch("devx.tools.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
@@ -157,100 +277,122 @@ class TestDocCoverageColor:
class TestReadVersion:
@patch("devx.tools.generate_badges._find_package_init")
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
mock_find.return_value = mock_init
assert read_version() == "0.5.0"
def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src" / "mypkg"
src.mkdir(parents=True)
(src / "__init__.py").write_text('__version__ = "0.5.0"\n')
assert read_version(tmp_path) == "0.5.0"
@patch("devx.tools.generate_badges._find_package_init")
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = "no version here\n"
mock_find.return_value = mock_init
assert read_version() == "unknown"
def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src" / "mypkg"
src.mkdir(parents=True)
(src / "__init__.py").write_text("no version here\n")
assert read_version(tmp_path) == "unknown"
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
assert read_version() == "unknown"
def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert read_version(tmp_path) == "unknown"
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
# Package detected but __init__.py doesn't exist (edge case)
assert read_version(tmp_path) == "unknown"
class TestFindPackageInit:
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_src_dir(self, mock_root: MagicMock) -> None:
"""Returns None when src/ directory doesn't exist."""
from devx.tools.generate_badges import _find_package_init
class TestCollectCoverageAndTests:
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
def test_extracts_coverage_and_tests(
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "100%"
assert tests["message"] == "1018 passing"
mock_src = MagicMock()
mock_src.exists.return_value = False
mock_root.__truediv__ = MagicMock(return_value=mock_src)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
def test_returns_unknown_when_no_match(
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "garbled output", "some error")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "unknown"
assert tests["message"] == "unknown"
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns None when no __init__.py has __version__."""
from devx.tools.generate_badges import _find_package_init
@patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "unknown"
assert cov["color"] == "lightgrey"
assert tests["message"] == "unknown"
assert tests["color"] == "lightgrey"
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "__init__.py").write_text("# no version here\n")
mock_root.__truediv__ = MagicMock(return_value=src_dir)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns the __init__.py that has __version__."""
from devx.tools.generate_badges import _find_package_init
class TestCollectDocCoverage:
@patch("devx.tools.generate_badges.run_command")
def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
badge = collect_doc_coverage(tmp_path)
assert badge["message"] == "100%"
src_dir = tmp_path / "src"
pkg_dir = src_dir / "mypkg"
pkg_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text("# no version\n")
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
result = _find_package_init()
assert result is not None
assert "__version__" in result.read_text()
@patch("devx.tools.generate_badges.run_command")
def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "no doc coverage", "error")
badge = collect_doc_coverage(tmp_path)
assert badge["message"] == "unknown"
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Handles OSError when reading init files."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
src_dir.mkdir()
init_file = src_dir / "__init__.py"
init_file.write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
# Patch Path.read_text to raise OSError
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
result = _find_package_init()
assert result is None
class TestCollectQuality:
@patch("devx.tools.generate_badges.run_command")
def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "", "")
badge = collect_quality(tmp_path)
assert badge["message"] == "A"
assert badge["color"] == "brightgreen"
@patch("devx.tools.generate_badges.run_command")
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "some error")
badge = collect_quality(tmp_path)
assert badge["message"] == "F"
assert badge["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "No module named ruff")
badge = collect_quality(tmp_path)
assert badge["message"] == "A"
class TestGenerateBadges:
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.collect_quality")
@patch("devx.tools.generate_badges.collect_doc_coverage")
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
@patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
def test_generates_all_badge_files(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_pkg: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
mock_cov_tests: MagicMock,
mock_doc: MagicMock,
mock_quality: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
) -> None: # type: ignore[no-untyped-def]
mock_cov_tests.return_value = (
make_badge("coverage", "100%", "brightgreen"),
make_badge("tests", "573 passing", "brightgreen"),
)
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
badges = generate_badges(tmp_path, repo_root=tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected:
svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists()
@@ -258,78 +400,10 @@ class TestGenerateBadges:
assert content.startswith("<svg")
assert "</svg>" in content
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli:
@patch("devx.tools.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"),
@@ -339,7 +413,6 @@ class TestCli:
assert result.exit_code == 0
assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None:
+43 -3
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
class TestExtractIssueNumber:
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_tea_not_found_raises_tea_error(self) -> None:
cli = TeaCLI(tea_bin="tea")
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
with pytest.raises(TeaCLIError, match="tea binary not found"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
@@ -350,6 +356,40 @@ class TestListBranches:
class TestWhoami:
def test_whoami(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "emil"
assert cli.whoami() == "testuser"
class TestConfigureTeaLogin:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""configure_tea_login with no token prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""configure_tea_login with no tea binary prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login adds login when not already configured."""
mock_list = MagicMock(returncode=0, stdout="")
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count >= 2 # login list + login add + login default
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_skips_when_already_configured(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login skips if login already exists."""
mock_list = MagicMock(returncode=0, stdout="devx https://git.example.com")
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count == 1 # only login list, no add
+24 -1
View File
@@ -222,6 +222,24 @@ class TestInstallTea:
assert (tmp_path / "tea").exists()
class TestInstallHadolint:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_hadolint() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_hadolint() is True
assert (tmp_path / "hadolint").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
@@ -251,6 +269,11 @@ class TestInstallTool:
assert install_tools._install_tool("tea") is True
mock.assert_called_once()
def test_hadolint(self) -> None:
with patch.object(install_tools, "install_hadolint", return_value=True) as mock:
assert install_tools._install_tool("hadolint") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
@@ -269,7 +292,7 @@ class TestMain:
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 4
assert mock_install.call_count == 5
def test_install_specific_tool(self) -> None:
runner = CliRunner()
+7 -27
View File
@@ -43,26 +43,6 @@ class TestCli:
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
@@ -121,7 +101,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
@@ -136,7 +116,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -168,7 +148,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
@@ -183,7 +163,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -213,7 +193,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
@@ -228,7 +208,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -259,7 +239,7 @@ class TestCli:
assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None:
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
"""Only GITEA_URL set (missing CI_GITEA_TOKEN and RUN_ID) — should skip polling."""
with (
patch.dict(
os.environ,
-91
View File
@@ -1,91 +0,0 @@
"""Unit tests for devx.ci.merge_junit."""
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from click.testing import CliRunner
from devx.ci.merge_junit import main, merge_files
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
for i in range(tests):
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
if i < failures:
ET.SubElement(tc, "failure", message="fail")
tree = ET.ElementTree(suite)
tree.write(path, encoding="UTF-8", xml_declaration=True)
class TestMergeFiles:
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 5
assert total_failures == 1
assert merged.tag == "testsuites"
assert len(merged) == 2
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
assert total_tests == 0
assert total_failures == 0
assert merged.tag == "testsuites"
assert len(merged) == 0
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
wrapper = ET.Element("testsuites")
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
tree = ET.ElementTree(wrapper)
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 4
assert total_failures == 2
class TestCli:
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code == 0
assert out.exists()
tree = ET.parse(out)
root = tree.getroot()
assert root.get("tests") == "5"
assert root.get("failures") == "0"
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code != 0
assert "failures" in result.output
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
)
assert result.exit_code == 0
assert "No JUnit" in result.output or "skipping" in result.output
def test_main_module_block() -> None:
import devx.ci.merge_junit as mod
assert hasattr(mod, "main")
+27 -173
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
import subprocess # nosec B404
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -22,7 +21,6 @@ from devx.molecule.molecule_ci_guard import (
parse_pair,
poll_for_other_failures,
resolve_role_dir,
write_junit_report,
)
@@ -99,6 +97,11 @@ class TestBuildEnvForPair:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_preserves_existing_molecule_home(self) -> None:
"""When MOLECULE_HOME is already set, it is not overridden."""
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
assert env["MOLECULE_HOME"] == "/custom/home"
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
@@ -162,17 +165,26 @@ class TestCli:
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
# Verify Docker prune was called between scenarios
mock_run.assert_called_once_with(
["docker", "system", "prune", "-af", "--volumes"],
check=False,
capture_output=True,
timeout=60,
)
def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise."""
@@ -208,7 +220,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
@@ -276,7 +288,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
@@ -290,7 +302,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -313,7 +325,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
@@ -324,14 +336,16 @@ class TestCli:
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
@@ -357,7 +371,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
@@ -371,7 +385,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
@@ -404,7 +418,7 @@ class TestCli:
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"CI_GITEA_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
@@ -418,7 +432,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
@@ -493,40 +507,6 @@ class TestResolveRoleDir:
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
class TestWriteJunitReport:
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
output = str(tmp_path / "junit-results" / "runner-1.xml")
testcases = [
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
]
write_junit_report(output, testcases, 1)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "2"
assert root.get("failures") == "0"
assert len(root) == 2
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
output = str(tmp_path / "runner-2.xml")
testcases = [
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
]
write_junit_report(output, testcases, 2)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "1"
assert root.get("failures") == "1"
failure = root[0][0]
assert failure.tag == "failure"
assert failure.text == "Exit code: 1"
def test_creates_parent_directory(self, tmp_path: Path) -> None:
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
write_junit_report(output, [], 0)
assert Path(output).exists()
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -536,12 +516,14 @@ class TestCliMultiRole:
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(
@@ -550,131 +532,3 @@ class TestCliMultiRole:
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_junit_output_written(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 0
assert Path(junit_path).exists()
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
assert tree.getroot().get("failures") == "1"
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
"""JUnit report is written when a runner is cancelled by another runner's failure."""
from click.testing import CliRunner
real_sleep = time.sleep
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg"),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
root = tree.getroot()
assert root.get("failures") == "1"
# The failure message should mention cancellation
failure = root[0][0]
assert "Cancelled" in (failure.text or "")
@@ -208,3 +208,14 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.molecule.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+29 -29
View File
@@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.notify_failure import _configure_tea_login, main
from devx.gitea_cli import TeaCLIError
from devx.ci.notify_failure import main
from devx.gitea_cli import TeaCLIError, configure_tea_login
class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
@@ -36,7 +36,7 @@ class TestNotifyFailure:
mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
@@ -53,7 +53,7 @@ class TestNotifyFailure:
assert "issue #43" in result.output
mock_tea.add_label.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
"""When tea fails, the workflow fails — no fallback."""
@@ -70,7 +70,7 @@ class TestNotifyFailure:
assert result.exit_code != 0
assert "tea" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
"""If listing labels fails via tea, issue is still created without labels."""
@@ -87,7 +87,7 @@ class TestNotifyFailure:
assert result.exit_code == 0
assert "issue #50" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
"""If adding label fails via tea, issue is still reported as created."""
@@ -105,7 +105,7 @@ class TestNotifyFailure:
assert result.exit_code == 0
assert "issue #51" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(
@@ -113,10 +113,10 @@ class TestNotifyFailure:
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value=None)
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with tea not installed skips login and still creates issue."""
@@ -133,11 +133,11 @@ class TestNotifyFailure:
assert result.exit_code == 0
assert "issue #60" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue."""
"""--auto-login with no CI_GITEA_TOKEN skips login but raises before creating issue."""
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
@@ -147,25 +147,25 @@ class TestNotifyFailure:
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
class TestConfigureTeaLogin:
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no token prints skip message and returns."""
_configure_tea_login()
"""configure_tea_login with no token prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no tea binary prints skip message and returns."""
_configure_tea_login()
"""configure_tea_login with no tea binary prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_configures_tea(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
@@ -191,9 +191,9 @@ class TestConfigureTeaLogin:
# tea login add was called
assert mock_subprocess.call_count >= 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_skips_if_already_configured(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
+67 -6
View File
@@ -129,6 +129,18 @@ class TestCheckArchitectureCompliance:
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
@@ -190,6 +202,19 @@ class TestCheckBestPractices:
check_best_practices(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
@@ -239,6 +264,19 @@ class TestCheckSecurity:
check_security(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
@@ -295,6 +333,14 @@ class TestCheckI18n:
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
@@ -366,6 +412,14 @@ class TestCheckResourceManagement:
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
@@ -429,6 +483,13 @@ class TestCheckFunctionLength:
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}]
check_function_length(files, result)
assert not result.has_issues
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
@@ -622,7 +683,7 @@ class TestMain:
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@@ -633,7 +694,7 @@ class TestMain:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@@ -649,7 +710,7 @@ class TestMain:
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@@ -662,14 +723,14 @@ class TestMain:
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
def test_main_module_block() -> None:
+137
View File
@@ -0,0 +1,137 @@
"""Unit tests for devx.tools.pre_push_check."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.pre_push_check import (
cli,
extract_task_id,
get_current_branch,
task_exists,
validate,
)
class TestExtractTaskId:
def test_valid_branch(self) -> None:
assert extract_task_id("DEVX-42-fix-bug") == "DEVX-42"
def test_no_task_id(self) -> None:
assert extract_task_id("feature-branch") == ""
def test_empty_branch(self) -> None:
assert extract_task_id("") == ""
class TestGetCurrentBranch:
@patch("devx.tools.pre_push_check.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
assert get_current_branch() == "DEVX-42-fix"
@patch("devx.tools.pre_push_check.subprocess.run")
def test_failure(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=1)
assert get_current_branch() == ""
class TestTaskExists:
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
# First page: full page (50 items, none matching), second page: match
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
page2 = [{"identifier": "DEVX-42"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
class TestValidate:
def test_master_branch_skips(self) -> None:
validate("master")
def test_main_branch_skips(self) -> None:
validate("main")
def test_empty_branch_skips(self) -> None:
validate("")
def test_no_task_id_raises(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
validate("feature-branch")
@patch.dict("os.environ", {}, clear=True)
def test_no_token_warns(self) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_exists_passes(self, mock_exists: MagicMock) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=False)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_not_found_raises(self, mock_exists: MagicMock) -> None:
with pytest.raises(click.ClickException, match="not found"):
validate("DEVX-42-fix-bug")
class TestCli:
@patch("devx.tools.pre_push_check.get_current_branch", return_value="master")
def test_auto_detect_master(self, mock_branch: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_explicit_branch(self, mock_exists: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
assert "passed" in result.output
+285 -17
View File
@@ -1,5 +1,6 @@
"""Unit tests for devx.ci.publish."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
@@ -10,6 +11,8 @@ from devx.ci.publish import (
_default_gitea_registry_url,
build_package,
generate_release_notes,
get_latest_tag,
is_release_commit,
main,
publish_to_gitea_registry,
publish_to_pypi,
@@ -64,7 +67,8 @@ class TestGenerateReleaseNotes:
class TestBuildPackage:
@patch("devx.ci.publish.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
@patch("devx.ci.publish.Path.exists", return_value=False)
def test_success(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
build_package()
args, _ = mock_run.call_args
@@ -72,12 +76,23 @@ class TestBuildPackage:
assert args[0][2] == "build"
@patch("devx.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
@patch("devx.ci.publish.Path.exists", return_value=False)
def test_failure_raises(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
with pytest.raises(click.ClickException) as exc:
build_package()
assert "build" in str(exc.value)
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.shutil.rmtree")
@patch("devx.ci.publish.Path.exists", return_value=True)
def test_cleans_dist_before_build(
self, mock_exists: MagicMock, mock_rmtree: MagicMock, mock_run: MagicMock
) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
build_package()
mock_rmtree.assert_called_once_with(Path("dist"))
class TestPublishToPypi:
@patch("devx.ci.publish.subprocess.run")
@@ -109,11 +124,18 @@ class TestPublishToGiteaRegistry:
@patch("devx.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
with pytest.raises(click.ClickException) as exc:
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
assert "Gitea PyPI registry" in str(exc.value)
@patch("devx.ci.publish.subprocess.run")
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
"""409 Conflict (already published) should not raise — just continue."""
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
# Should not raise
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
class TestDefaultGiteaRegistryUrl:
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@@ -134,9 +156,16 @@ class TestDefaultGiteaRegistryUrl:
url = _default_gitea_registry_url()
assert "oblachno-oss" in url
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/")
def test_no_api_suffix(self) -> None:
"""URL without /api/v1 or /api suffix is used as-is."""
url = _default_gitea_registry_url()
assert url == "https://git.example.com/api/packages/myorg/pypi"
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@@ -149,6 +178,7 @@ class TestMain:
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -160,7 +190,7 @@ class TestMain:
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@@ -174,6 +204,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -182,7 +213,7 @@ class TestMain:
mock_gitea_publish.assert_called_once()
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@@ -196,6 +227,7 @@ class TestMain:
) -> None:
"""--registry-url flag publishes to the specified Gitea registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
@@ -207,7 +239,7 @@ class TestMain:
@patch.dict(
"os.environ",
{"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
{"CI_GITEA_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
clear=True,
)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -223,13 +255,14 @@ class TestMain:
) -> None:
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok")
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@@ -243,6 +276,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
@@ -250,14 +284,14 @@ class TestMain:
assert "PYPI_TOKEN not set" in result.output
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_repo_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@@ -271,21 +305,28 @@ class TestMain:
assert result.exit_code == 1
assert "build" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_publish_failure_raises_click(
def test_publish_failure_continues_to_gitea_release(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""PyPI publish failure is non-fatal — Gitea release is still created."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
assert result.exit_code == 0
assert "non-fatal" in result.output
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@@ -294,6 +335,7 @@ class TestMain:
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
@@ -301,7 +343,7 @@ class TestMain:
assert result.exit_code == 1
assert "Release creation failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@@ -310,6 +352,7 @@ class TestMain:
) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
@@ -317,3 +360,228 @@ class TestMain:
assert "skip" in result.output.lower()
mock_build.assert_not_called()
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_skips_release_creation_when_already_exists(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If the Gitea release already exists, skip creation (idempotent)."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
mock_tea.create_release.assert_not_called()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_proceeds_to_create_when_list_releases_fails(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If list_releases raises TeaCLIError, proceed to create the release."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_already_exists_is_idempotent(
self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
"""If create_release fails with 'already exists', treat as success."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_other_error_raises(
self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
class TestFromTag:
def test_get_latest_tag_success(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
result = get_latest_tag()
assert result == "v1.2.3"
def test_get_latest_tag_no_tags(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = get_latest_tag()
assert result is None
def test_is_release_commit_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
)
result = is_release_commit("v1.2.3")
assert result is True
def test_is_release_commit_no_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
result = is_release_commit("v1.2.3")
assert result is False
def test_is_release_commit_git_error(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = is_release_commit("v1.2.3")
assert result is False
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code != 0
assert "REPO argument is required" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=False)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "not a release commit" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
def test_no_tag_no_from_tag_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0
assert "Tag is required" in result.output
class TestPublishAutoLogin:
"""Tests for --auto-login flag in publish."""
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.publish.configure_tea_login")
@patch("devx.ci.publish.TeaCLI")
def test_auto_login_calls_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
"""--auto-login calls configure_tea_login before creating release."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build", "--auto-login"])
assert result.exit_code == 0
mock_login.assert_called_once()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.publish.configure_tea_login")
@patch("devx.ci.publish.TeaCLI")
def test_no_auto_login_skips_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
"""Without --auto-login, configure_tea_login is not called."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
mock_login.assert_not_called()
+16
View File
@@ -287,3 +287,19 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0
mock_sleep.assert_not_called()
class TestRepoRoot:
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert push_badges._repo_root() == tmp_path
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path
+90 -7
View File
@@ -1,5 +1,7 @@
"""Unit tests for scripts/ci/release.py."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
@@ -270,6 +272,15 @@ class TestVerifyTagConsistency:
mock_tags.return_value = []
assert verify_tag_consistency() == []
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_non_version_tags_ignored(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
"""Non-version tags like 'master' should be skipped, not crash."""
mock_tags.return_value = ["v0.2.0", "master", "v0.1.0"]
mock_cv.side_effect = ["0.2.0", "0.1.0"] # only version tags get checked
errors = verify_tag_consistency()
assert errors == []
class TestGetInitVersion:
def test_returns_version(self, tmp_path, monkeypatch) -> None:
@@ -499,6 +510,30 @@ class TestVerifyAlignment:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_latest_tag_skips_changelog_tag_check(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""When there is no latest tag, the CHANGELOG/tag match check is skipped."""
mock_lt.return_value = None # no tags
mock_tags.return_value = []
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@@ -747,6 +782,16 @@ class TestUpdateChangelog:
assert "# Changelog" not in content
assert "## [0.2.0]" in content
def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None:
"""Changelog input without any ## [ version section is inserted as-is."""
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
# No ## [ section in the cliff output — should not be stripped
update_changelog("Some raw text without version header")
content = changelog_file.read_text()
assert "Some raw text without version header" in content
class TestCommitReleaseChanges:
@patch("devx.ci.release.run_cmd")
@@ -757,7 +802,7 @@ class TestCommitReleaseChanges:
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
@@ -772,11 +817,23 @@ class TestCommitReleaseChanges:
class TestCreateAndPushTag:
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
create_and_push_tag("0.2.0", "changelog", dry_run=False)
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None:
github_output = tmp_path / "output.txt"
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
assert github_output.read_text() == "tag=v0.2.0\n"
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
with patch.dict(os.environ, {}, clear=True):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
# Should still create tag, just not write GITHUB_OUTPUT
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "v0.2.0"] in calls
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
@@ -803,7 +860,7 @@ class TestCreateAndPushTag:
# Should not create tag, but should ensure it's pushed
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
assert ["git", "push", "origin", "refs/tags/v0.1.0"] in calls
@patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@@ -1150,7 +1207,7 @@ class TestMain:
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_full_flow_tag_exists(
@@ -1175,7 +1232,33 @@ class TestMain:
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_skips_when_version_doesnt_bump(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when git-cliff doesn't bump the version."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "no version bump" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
+32 -4
View File
@@ -120,7 +120,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_already_exists(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="")
_configure_tea_login()
@@ -130,7 +130,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_success(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=0, stdout="", stderr="")
@@ -143,7 +143,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=1, stdout="", stderr="auth failed")
@@ -155,7 +155,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict(
"os.environ",
{"REPO_TOKEN": "tok123", "DEVX_GITEA_API_URL": "https://custom.example.com/api/v1"},
{"CI_GITEA_TOKEN": "tok123", "DEVX_GITEA_API_URL": "https://custom.example.com/api/v1"},
clear=True,
)
def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
@@ -186,6 +186,12 @@ class TestVerify:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None:
"""When a tool returns non-zero, it is skipped without raising."""
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_verify(".venv/bin") # Should not raise
class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@@ -304,6 +310,28 @@ class TestMain:
assert result.exit_code != 0
assert "Bin directory not found" in result.output
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_skip_install(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"])
assert result.exit_code == 0
mock_install_deps.assert_not_called()
assert "Skipping pip install" in result.output
def test_main_module_block(tmp_path: Path) -> None:
"""Test the __main__ block execution."""
+224 -37
View File
@@ -1,89 +1,255 @@
"""Unit tests for devx.molecule.start_docker."""
import os
from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner
from devx.molecule.start_docker import is_docker_ready, main, start_docker_daemon
from devx.molecule.start_docker import (
DOCKER_SOCK,
_diagnose_socket,
is_docker_ready,
main,
start_docker_daemon,
)
class TestIsDockerReady:
@patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
assert is_docker_ready() is True
mock_run.assert_called_once_with(["docker", "info"], capture_output=True, check=False)
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["docker", "info"]
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
@patch("devx.molecule.start_docker.subprocess.run")
def test_not_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1)
assert is_docker_ready() is False
@patch("devx.molecule.start_docker.subprocess.run")
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
"""Should check the socket specified by DOCKER_HOST env var."""
mock_run.return_value = MagicMock(returncode=0)
rootless = "unix:///run/user/999/docker.sock"
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
class TestDiagnoseSocket:
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None:
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(
stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n",
returncode=0,
text="",
),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.subprocess.run")
def test_docker_info_no_matching_lines(
self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock
) -> None:
"""docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines."""
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_host_socket_available(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None:
"""Should return immediately if host Docker is available."""
assert start_docker_daemon(timeout=5) is True
mock_ready.assert_called_once()
mock_diag.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_starts_successfully(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
def test_rootless_socket_available(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
# First loop iteration: dockerd not ready yet. Second: ready.
"""Should use rootless socket if host socket fails."""
# First check (host) fails, second check (rootless) succeeds
mock_ready.side_effect = [False, True]
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_found(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should find rootless socket at a different UID via glob scan."""
# Host fails, own rootless fails, alt rootless succeeds
mock_ready.side_effect = [False, False, True]
alt_sock = "/run/user/999/docker.sock"
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_skips_own(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should skip the own rootless socket in glob scan (already tried)."""
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
mock_ready.side_effect = [False, False, False, False, False, False]
own_sock = f"/run/user/{os.getuid()}/docker.sock"
alt_sock = "/run/user/999/docker.sock"
with (
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
patch("devx.molecule.start_docker.time.sleep"),
patch("devx.molecule.start_docker.subprocess.Popen"),
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
patch("builtins.open", mock_open(read_data="err")),
):
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_starts_local_daemon(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local daemon starts
mock_ready.side_effect = [False, False, False, False, True]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
mock_sleep.assert_called_once_with(1)
popen_args = mock_popen.call_args.args[0]
assert "dockerd" in popen_args
assert "--storage-driver" in popen_args
assert "vfs" in popen_args
assert "-H" in popen_args
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_after_timeout(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
assert start_docker_daemon(timeout=3) is False
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", mock_open(read_data="dockerd error log")):
assert start_docker_daemon(timeout=3) is False
mock_popen.assert_called_once()
assert mock_sleep.call_count == 3
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_ready_on_first_check(
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_log_read_error(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ready.return_value = True
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
mock_sleep.assert_not_called()
"""Should handle log read errors gracefully."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", side_effect=OSError("permission denied")):
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_custom_timeout(
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_local_daemon_ready_on_first_check(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
# 9 iterations not ready, 10th ready.
mock_ready.side_effect = [False] * 9 + [True]
assert start_docker_daemon(timeout=10) is True
assert mock_sleep.call_count == 9
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local ready on first loop check
mock_ready.side_effect = [False, False, True]
assert start_docker_daemon(timeout=5) is True
assert mock_popen.call_count == 1
assert mock_sleep.call_count == 1
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.environ")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_sets_docker_host(
self,
mock_ready: MagicMock,
mock_environ: MagicMock,
mock_diag: MagicMock,
) -> None:
"""DOCKER_HOST must be set so molecule connects to correct socket."""
start_docker_daemon(timeout=5)
mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
class TestMain:
@@ -105,3 +271,24 @@ class TestMain:
result = runner.invoke(main, ["--timeout", "60"])
assert result.exit_code == 0
mock_start.assert_called_once_with(60)
@patch("devx.molecule.start_docker.os.environ.get")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
mock_get.side_effect = lambda key, default="": (
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
)
with patch("builtins.open", mock_open()) as mock_file:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
mock_file.assert_called_with("/tmp/github_env", "a")
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should not crash when GITHUB_ENV is not set."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0

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