Compare commits

..
87 Commits
Author SHA1 Message Date
devx-ci-bot c12d9abc6d release: v0.26.4 [skip ci] 2026-06-28 15:01:52 +00:00
emil 40a65cb0a6 DEVX-97: fix: wrap all user-facing strings with _() for i18n completeness
Post-merge / vikunja (push) Successful in 13s
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 29s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 22s
Build Images / detect-type (push) Successful in 48s
Build Images / build-and-push (push) Successful in 4m3s
Build Images / cleanup (push) Successful in 2m6s
2026-06-28 15:01:07 +00:00
gitea-actions-bot 1a89738dd4 chore: update badge URLs to commit 36f474db [skip ci] 2026-06-28 14:56:08 +00:00
devx-ci-bot 9e604ea2c7 release: v0.26.3 [skip ci] 2026-06-28 14:55:48 +00:00
emil 4bb50bed58 DEVX-97: fix: pin all dependencies to exact versions for reproducibility
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 15s
Post-merge / release (push) Successful in 32s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 49s
Post-merge / publish (push) Successful in 24s
Build Images / detect-type (push) Waiting to run
Build Images / build-and-push (push) Blocked by required conditions
Build Images / cleanup (push) Blocked by required conditions
2026-06-28 14:55:03 +00:00
gitea-actions-bot 5bb9dce530 chore: update badge URLs to commit c04f826e [skip ci] 2026-06-28 14:42:36 +00:00
emil 73662a3bf0 DEVX-95: chore: pin all dependency versions to concrete releases
Post-merge / detect-type (push) Successful in 18s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / release (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 14s
Build Images / detect-type (push) Successful in 45s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 12s
Post-merge / badges (push) Successful in 34s
Build Images / build-and-push (push) Successful in 3m47s
Build Images / cleanup (push) Successful in 2m11s
2026-06-28 14:41:27 +00:00
gitea-actions-bot a1e87b1905 chore: update badge URLs to commit 991d923a [skip ci] 2026-06-28 14:38:20 +00:00
devx-ci-bot d8d0ad04a2 release: v0.26.2 [skip ci] 2026-06-28 14:38:13 +00:00
emil e836c09088 DEVX-96: fix: block admin merge override and auto-approve with review token
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / vikunja (push) Successful in 20s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Waiting to run
Build Images / cleanup (push) Blocked by required conditions
Build Images / detect-type (push) Successful in 45s
2026-06-28 14:37:36 +00:00
gitea-actions-bot 507436b134 chore: update badge URLs to commit 8dbdd563 [skip ci] 2026-06-28 12:15:13 +00:00
devx-ci-bot 32cec2c5ad release: v0.26.1 [skip ci] 2026-06-28 12:15:06 +00:00
emil 55c530eb00 DEVX-93: fix: force pip upgrade in setup-image to install new dependencies
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 11s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / badges (push) Successful in 28s
Build Images / detect-type (push) Successful in 41s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m25s
2026-06-28 12:14:31 +00:00
gitea-actions-bot 3928de4507 chore: update badge URLs to commit 7dc6d2ce [skip ci] 2026-06-28 01:59:24 +00:00
devx-ci-bot 8bb1813715 release: v0.26.0 [skip ci] 2026-06-28 01:59:17 +00:00
emil f7f53941a1 DEVX-92: feat: add distribute_items CI tool for parallel VM deployment
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 41s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m53s
Build Images / cleanup (push) Successful in 7m33s
2026-06-28 01:58:42 +00:00
gitea-actions-bot 5edfdaa7aa chore: update badge URLs to commit 3a6bff69 [skip ci] 2026-06-28 00:18:22 +00:00
devx-ci-bot 893da8ba34 release: v0.25.0 [skip ci] 2026-06-28 00:18:14 +00:00
emil 64a58874b6 DEVX-91: feat: add manual review support to pr_review (--event, --body, --checklist-confirmed)
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 34s
Post-merge / release (push) Successful in 27s
Post-merge / badges (push) Successful in 32s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m38s
Build Images / cleanup (push) Successful in 7m17s
2026-06-28 00:17:33 +00:00
gitea-actions-bot c1c2041ca4 chore: update badge URLs to commit 0fa6360d [skip ci] 2026-06-28 00:07:16 +00:00
devx-ci-bot 49ff8870b1 release: v0.24.1 [skip ci] 2026-06-28 00:07:07 +00:00
emil 4c1ecbf4fa DEVX-91: refactor: add find_task_by_identifier, config fallbacks for tools
Post-merge / release (push) Successful in 27s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 10s
Build Images / detect-type (push) Successful in 42s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 1m44s
2026-06-28 00:06:29 +00:00
gitea-actions-bot 93a1cb9945 chore: update badge URLs to commit 6c0ce9c6 [skip ci] 2026-06-27 23:39:13 +00:00
devx-ci-bot 507bc86b92 release: v0.24.0 [skip ci] 2026-06-27 23:38:58 +00:00
emil 81dc30ecff DEVX-91: feat: add pr_status, pr_logs, pr_label tools
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 30s
Build Images / detect-type (push) Successful in 48s
Post-merge / badges (push) Successful in 41s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 4m41s
Build Images / cleanup (push) Successful in 2m23s
2026-06-27 23:38:16 +00:00
gitea-actions-bot 3c421dd1ad chore: update badge URLs to commit d312f7b7 [skip ci] 2026-06-27 22:14:47 +00:00
devx-ci-bot 11ce99756c release: v0.23.4 [skip ci] 2026-06-27 22:14:38 +00:00
emil 6149167ba2 DEVX-90: fix: classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 16s
Post-merge / configure-repo (push) Successful in 10s
Build Images / detect-type (push) Successful in 31s
Post-merge / release (push) Successful in 23s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m46s
Build Images / cleanup (push) Successful in 1m29s
2026-06-27 22:14:05 +00:00
gitea-actions-bot 014ab0b63f chore: update badge URLs to commit 7430be68 [skip ci] 2026-06-27 22:07:28 +00:00
emil 2a3ee1ec96 DEVX-89: fix: add --auto-login to all notify_failure calls in workflows
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 12s
Post-merge / vikunja (push) Successful in 12s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / badges (push) Successful in 28s
2026-06-27 22:06:47 +00:00
gitea-actions-bot 5d4968eb21 chore: update badge URLs to commit 48324375 [skip ci] 2026-06-27 21:41:36 +00:00
devx-ci-bot 33cfbb0f41 release: v0.23.3 [skip ci] 2026-06-27 21:41:26 +00:00
emil 598238e4d6 DEVX-88: fix: correct clean_images delete URL and add retry with error handling
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Build Images / detect-type (push) Successful in 36s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 19s
Build Images / build-and-push (push) Successful in 2m55s
Build Images / cleanup (push) Successful in 5m47s
2026-06-27 21:40:49 +00:00
gitea-actions-bot 925b99b7db chore: update badge URLs to commit dd2ca2f7 [skip ci] 2026-06-27 21:20:07 +00:00
devx-ci-bot 16ed48bd26 release: v0.23.2 [skip ci] 2026-06-27 21:19:53 +00:00
emil 3b0500164b DEVX-87: fix: add skip-ci flag to release commits and concurrency to build-images
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 15s
Post-merge / vikunja (push) Successful in 13s
Build Images / detect-type (push) Successful in 31s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 36s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m50s
Build Images / cleanup (push) Successful in 5m24s
2026-06-27 21:19:17 +00:00
gitea-actions-bot 00a44ec5dc chore: update badge URLs to commit b62d5c3d [skip ci] 2026-06-27 19:42:25 +00:00
gitea-actions-bot b385c57621 chore: update badge URLs to commit 3e6359f8 [skip ci] 2026-06-27 19:41:36 +00:00
devx-ci-bot 3181b24f5e release: v0.23.1
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 44s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 32s
2026-06-27 19:40:59 +00:00
emil bc8478220c DEVX-86: fix: add rsync to ci-full image for molecule_docker
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / detect-type (push) Successful in 34s
Post-merge / release (push) Successful in 28s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / vikunja (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 44s
Post-merge / publish (push) Successful in 18s
Build Images / build-and-push (push) Successful in 3m45s
Build Images / cleanup (push) Successful in 2m17s
2026-06-27 19:40:20 +00:00
gitea-actions-bot a568c0899f chore: update badge URLs to commit 0f22063f [skip ci] 2026-06-27 18:06:46 +00:00
gitea-actions-bot 4216698ca8 chore: update badge URLs to commit 6823dfce [skip ci] 2026-06-27 18:06:11 +00:00
devx-ci-bot f3685b9029 release: v0.23.0
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 38s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 28s
2026-06-27 18:06:01 +00:00
emil 2e5470236e DEVX-85: feat: add devx-lint-dockerfiles to devx.mak, alias setup-image
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 11s
Build Images / detect-type (push) Successful in 36s
Post-merge / release (push) Successful in 26s
Post-merge / badges (push) Successful in 31s
Post-merge / publish (push) Successful in 17s
Build Images / build-and-push (push) Successful in 2m58s
Build Images / cleanup (push) Successful in 1m25s
2026-06-27 18:05:25 +00:00
gitea-actions-bot d2dcf8f7c4 chore: update badge URLs to commit 1d6085cf [skip ci] 2026-06-27 17:25:13 +00:00
emil 357e07a9a6 DEVX-84: refactor: remove hadolint on-the-fly install from setup-image
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 11s
Post-merge / vikunja (push) Successful in 12s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / badges (push) Successful in 27s
2026-06-27 17:24:34 +00:00
gitea-actions-bot ffb3976224 chore: update badge URLs to commit 22ed0d7b [skip ci] 2026-06-27 16:44:47 +00:00
gitea-actions-bot ad75b22f2a chore: update badge URLs to commit b800f158 [skip ci] 2026-06-27 16:44:13 +00:00
devx-ci-bot 37f867f6d8 release: v0.22.1
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 39s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 26s
Build Images / cleanup (push) Has been skipped
2026-06-27 16:44:03 +00:00
emil 233a0bc055 DEVX-83: fix: fail lint-dockerfiles when hadolint is missing
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 17s
Build Images / detect-type (push) Successful in 34s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / release (push) Successful in 29s
Post-merge / badges (push) Successful in 37s
Post-merge / publish (push) Successful in 18s
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 4m21s
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
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 13s
Post-merge / vikunja (push) Successful in 12s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 20s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 32s
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
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 33s
Post-merge / badges (push) Successful in 24s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
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
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / release (push) Successful in 20s
Post-merge / badges (push) Successful in 26s
Post-merge / publish (push) Successful in 14s
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
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 32s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 31s
2026-06-27 13:46:57 +00:00
emil 5c8d74015e DEVX-79: fix: gate auto-merge on release-dry-run and unmask failures
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / release (push) Successful in 30s
Post-merge / badges (push) Successful in 35s
Post-merge / publish (push) Successful in 18s
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)
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / release (push) Successful in 16s
Build Images / detect-type (push) Successful in 35s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 12s
Post-merge / badges (push) Successful in 36s
Build Images / build-and-push (push) Successful in 3m23s
Build Images / cleanup (push) Successful in 1m0s
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
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / detect-type (push) Successful in 36s
Post-merge / publish (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 32s
2026-06-27 13:24:07 +00:00
emil 9fb9be9c35 DEVX-77: fix: devx-setup-image configures Gitea PyPI registry and shows pip errors
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / release (push) Successful in 27s
Build Images / detect-type (push) Successful in 39s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m17s
Build Images / cleanup (push) Successful in 1m15s
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
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / detect-type (push) Successful in 38s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 28s
Build Images / cleanup (push) Has been skipped
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
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
Post-merge / publish (push) Successful in 17s
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / build-and-push (push) Successful in 3m6s
Post-merge / vikunja (push) Successful in 11s
Build Images / cleanup (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 18s
Build Images / detect-type (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 27s
Post-merge / release (push) Successful in 32s
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
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 16s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / vikunja (push) Successful in 10s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 25s
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
Post-merge / badges (push) Successful in 26s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 11s
Post-merge / vikunja (push) Successful in 11s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 18s
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
Post-merge / detect-type (push) Successful in 10s
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 40s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 29s
Build Images / cleanup (push) Has been skipped
2026-06-27 07:42:30 +00:00
emil ea7ddb2036 DEVX-73: fix: release publish failures and duplicate release commits
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 12s
Build Images / detect-type (push) Successful in 38s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 31s
Post-merge / publish (push) Failing after 15s
Build Images / build-and-push (push) Successful in 2m47s
Build Images / cleanup (push) Successful in 51s
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
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 34s
2026-06-27 07:20:10 +00:00
emil 8862ea4639 DEVX-72: ci: add hadolint Dockerfile linter to CI
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 40s
Post-merge / release (push) Failing after 26s
Build Images / build-and-push (push) Successful in 3m6s
Build Images / cleanup (push) Successful in 49s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / vikunja (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 35s
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
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 26s
Post-merge / release (push) Successful in 19s
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
87 changed files with 4563 additions and 994 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Gitea API token (required for CI scripts that interact with Gitea) # Gitea API token (required for CI scripts that interact with Gitea)
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications # Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
REPO_TOKEN= CI_GITEA_TOKEN=
# Vikunja API token (required for post-merge task updates) # Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens # Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+14 -9
View File
@@ -23,6 +23,10 @@ on:
- src/devx/** - src/devx/**
workflow_dispatch: workflow_dispatch:
concurrency:
group: build-images
cancel-in-progress: false
jobs: jobs:
detect-type: detect-type:
runs-on: docker runs-on: docker
@@ -54,19 +58,19 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release run: make setup-release
- name: Docker registry login - name: Docker registry login
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: | run: |
. .venv/bin/activate . .venv/bin/activate
echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images - name: Build and push tier images
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -95,7 +99,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -104,7 +108,8 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \ --workflow "build-images/build-and-push" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
cleanup: cleanup:
needs: [build-and-push] needs: [build-and-push]
@@ -119,7 +124,7 @@ jobs:
run: make setup-ci run: make setup-ci
- name: Clean up old image versions - name: Clean up old image versions
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
+49 -11
View File
@@ -8,11 +8,15 @@ on:
jobs: jobs:
quality: quality:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-quality run: make setup-image
- name: Lint all - name: Lint all
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -60,7 +64,11 @@ jobs:
detect-changes: detect-changes:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
outputs: outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }} user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps: steps:
@@ -68,7 +76,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
env: env:
@@ -84,32 +92,42 @@ jobs:
needs: [quality, detect-changes] needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true' if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - 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 - name: Release dry-run validation
env: env:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run || true python3 -m devx.ci.release --dry-run
pr-review: pr-review:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Run automated PR review - name: Run automated PR review
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
set -euo pipefail set -euo pipefail
@@ -122,24 +140,44 @@ jobs:
# Auto-merge runs after all CI checks pass. It reads the task ID # Auto-merge runs after all CI checks pass. It reads the task ID
# from the branch name, validates the PR title, and squash-merges. # 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). # Uses always() so it runs even when detect-changes skips (no user-facing changes).
needs: [quality, detect-changes, pr-review] needs: [quality, detect-changes, pr-review, release-dry-run]
if: >- if: >-
always() && always() &&
github.event_name == 'pull_request' && github.event_name == 'pull_request' &&
needs.quality.result == 'success' && needs.quality.result == 'success' &&
needs.pr-review.result == 'success' needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src PYTHONPATH: src
+66 -36
View File
@@ -33,7 +33,11 @@ on:
jobs: jobs:
detect-type: detect-type:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
outputs: outputs:
is-release: ${{ steps.check.outputs.is-release }} is-release: ${{ steps.check.outputs.is-release }}
steps: steps:
@@ -41,7 +45,7 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
env: env:
@@ -54,13 +58,17 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5 timeout-minutes: 5
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Validate latest commit message - name: Validate latest commit message
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -74,18 +82,22 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15 timeout-minutes: 15
defaults:
run:
shell: bash
outputs: outputs:
tag: ${{ steps.release-tag.outputs.tag }} tag: ${{ steps.release-tag.outputs.tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release run: make setup-image
- name: Configure git - name: Configure git
run: | run: |
git config user.name "devx-ci-bot" git config user.name "devx-ci-bot"
@@ -98,17 +110,10 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
if: steps.release-tag.outputs.tag == ''
run: |
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -n "$tag" ]; then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
fi
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -117,33 +122,37 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \ --workflow "post-merge/release" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
publish: publish:
needs: [release] needs: [release]
if: needs.release.outputs.tag != '' if: needs.release.outputs.tag != ''
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment - name: Set up environment
env: run: make setup-image EXTRAS=release
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Build and publish release - name: Build and publish release
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -152,22 +161,27 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \ --workflow "post-merge/publish" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
sync-wiki: sync-wiki:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Sync documentation to wiki - name: Sync documentation to wiki
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -175,7 +189,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -183,25 +197,30 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \ --workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
badges: badges:
needs: [detect-type] needs: [detect-type]
if: always() if: always()
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: master ref: master
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master - name: Fetch latest master
run: | run: |
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Generate and push badges - name: Generate and push badges
env: env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
@@ -211,7 +230,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -219,19 +238,24 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \ --workflow "post-merge/badges" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
vikunja: vikunja:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Update Vikunja task - name: Update Vikunja task
env: env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
@@ -243,7 +267,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -251,20 +275,25 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \ --workflow "post-merge/vikunja" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
configure-repo: configure-repo:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Ensure branch protection and labels - name: Ensure branch protection and labels
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -272,7 +301,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -280,4 +309,5 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \ --workflow "post-merge/configure-repo" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}" \
--auto-login
+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
+23 -11
View File
@@ -4,8 +4,8 @@
```bash ```bash
make setup # Create venv, install deps, set up hooks, install CI tools 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 install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
make pytest-cov # Unit tests with 100% coverage enforcement make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage make test-unit # Unit tests without coverage
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint) 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: `make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks) - **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin) - **actionlint, git-cliff, act_runner, tea, hadolint** 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`) - **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
## Workflow Verification (Before Push) ## Workflow Verification (Before Push)
@@ -62,18 +62,19 @@ src/devx/
│ ├── classify_changes.py # User-facing vs workflow-only change detection │ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master │ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation │ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review │ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
│ ├── post_merge.py # Vikunja task updates after merge │ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki │ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures) │ ├── 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) │ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling) │ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast │ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check │ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check │ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── 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) │ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── build_image.py # Build and push Docker images to Gitea registry │ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry │ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
@@ -83,7 +84,12 @@ src/devx/
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules) │ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns) │ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
│ ├── configure_repo.py # Branch protection and label setup │ ├── configure_repo.py # Branch protection and label setup
── generate_badges.py # Badge SVG generation ── generate_badges.py # Badge SVG generation
│ ├── create_task.py # Create Vikunja tasks
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
│ ├── pr_logs.py # Fetch logs for failed CI jobs
│ └── pr_label.py # Add labels to PRs (idempotent)
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field) ├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects) └── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery ├── discover_runners.py # Dynamic Gitea runner discovery
@@ -263,7 +269,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 The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
by `python -m devx.tools.install_tools` and configured by 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: **`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
- `create_issue()` — Create issues with labels - `create_issue()` — Create issues with labels
@@ -358,7 +364,7 @@ devx uses environment variables with `.env` file fallback for configuration.
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) | | `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID | | `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) | | `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 | | `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
### Per-Project Overrides ### Per-Project Overrides
@@ -398,13 +404,17 @@ projects.
| `devx-create-pr` | Create a PR with auto-derived title | | `devx-create-pr` | Create a PR with auto-derived title |
| `devx-push` | Push current branch to origin | | `devx-push` | Push current branch to origin |
| `devx-push-with-pr` | Push and create PR in one step | | `devx-push-with-pr` | Push and create PR in one step |
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
| `devx-check-config` | Validate devx configuration | | `devx-check-config` | Validate devx configuration |
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry | | `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
| `devx-env` | Create .env from .env.example | | `devx-env` | Create .env from .env.example |
| `devx-venv` | Create Python venv with version check | | `devx-venv` | Create Python venv with version check |
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts | | `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
| `devx-install-hooks` | Set git hooks path to hooks/ | | `devx-install-hooks` | Set git hooks path to hooks/ |
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea | | `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea, hadolint |
| `devx-install-checkmake` | Install checkmake (Makefile linter) | | `devx-install-checkmake` | Install checkmake (Makefile linter) |
| `devx-checkmake` | Lint Makefiles with checkmake | | `devx-checkmake` | Lint Makefiles with checkmake |
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) | | `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
@@ -428,6 +438,7 @@ projects.
| `devx-pre-push` | Run lint + tests before push | | `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data | | `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) | | `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-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry | | `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed | | `devx-build-images-dry-run` | Show what would be built/pushed |
@@ -441,6 +452,7 @@ projects.
- `DEVX_COV_PKG` — coverage package (default: `src/devx`) - `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`) - `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`) - `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_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`) - `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`) - `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
@@ -453,7 +465,7 @@ to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs | | 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-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges | | `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs | | `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): **Build process** (in `build-images.yml` workflow):
+127
View File
@@ -2,6 +2,133 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.26.4] - 2026-06-28
### Bug Fixes
- Wrap all user-facing strings with _() for i18n completeness
## [0.26.3] - 2026-06-28
### Bug Fixes
- Pin all dependencies to exact versions for reproducibility
## [0.26.2] - 2026-06-28
### Bug Fixes
- Block admin merge override and auto-approve with review token
## [0.26.1] - 2026-06-28
### Bug Fixes
- Force pip upgrade in setup-image to install new dependencies
## [0.26.0] - 2026-06-28
### Features
- Add distribute_items CI tool for parallel VM deployment
## [0.25.0] - 2026-06-28
### Features
- Add manual review support to pr_review (--event, --body, --checklist-confirmed)
## [0.24.1] - 2026-06-28
### Refactor
- Add find_task_by_identifier, config fallbacks for tools
## [0.24.0] - 2026-06-27
### Features
- Add pr_status, pr_logs, pr_label tools
## [0.23.4] - 2026-06-27
### Bug Fixes
- Add --auto-login to all notify_failure calls in workflows
- Classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
## [0.23.3] - 2026-06-27
### Bug Fixes
- Correct clean_images delete URL and add retry with error handling
## [0.23.2] - 2026-06-27
### Bug Fixes
- Add skip-ci flag to release commits and concurrency to build-images
## [0.23.1] - 2026-06-27
### Bug Fixes
- Add rsync to ci-full image for molecule_docker
## [0.23.0] - 2026-06-27
### Features
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
### Refactor
- Remove hadolint on-the-fly install from setup-image
## [0.22.1] - 2026-06-27
### Bug Fixes
- Checkout release tag in publish job
- Fail lint-dockerfiles when hadolint is missing
## [0.22.0] - 2026-06-27
### Features
- Document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table
## [0.21.2] - 2026-06-27
### Bug Fixes
- Gate auto-merge on release-dry-run and unmask failures
## [0.21.1] - 2026-06-27
### Bug Fixes
- Devx-setup-image configures Gitea PyPI registry and shows pip errors
## [0.21.0] - 2026-06-27
### Features
- Add --auto-login to publish, extract configure_tea_login to gitea_cli
### Bug Fixes
- Publish job uses setup-release for build + tea login
- Remove tag fallback step from release workflow
## [0.20.3] - 2026-06-27
### Bug Fixes
- Release publish failures and duplicate release commits
## [0.20.2] - 2026-06-27
## [0.20.2] - 2026-06-27 ## [0.20.2] - 2026-06-27
### Bug Fixes ### Bug Fixes
+19 -5
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images .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 PYTHON := python3
VENV := .venv VENV := .venv
@@ -25,14 +25,17 @@ setup-quality: $(VENV)/bin/activate .env install-tools
# Setup for release jobs (needs git-cliff, tea, lint tools) # Setup for release jobs (needs git-cliff, tea, lint tools)
setup-release: $(VENV)/bin/activate .env 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; \ $(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ 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) # 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: setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \ @if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env: .env:
@@ -96,9 +99,20 @@ create-pr: devx-create-pr
push-with-pr: devx-push-with-pr push-with-pr: devx-push-with-pr
git-push: devx-push git-push: devx-push
lint-all: lint workflow-lint lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed." @echo "[lint-all] All linting checks passed."
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@find docker -name 'Dockerfile*' -exec hadolint {} +
@echo "[lint-dockerfiles] All Dockerfiles passed."
test-unit: devx-test-unit test-unit: devx-test-unit
pytest-cov: devx-pytest-cov pytest-cov: devx-pytest-cov
+21 -7
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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) [![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/917159e3043b69a79e75f1976c1018be1d8de629/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -326,10 +326,24 @@ The config system loads `.env` automatically via `python-dotenv`.
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) | | `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts | | `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) | | `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
| `REPO_TOKEN` | — | Gitea API token | | `CI_GITEA_TOKEN` | — | Gitea API token (see scopes below) |
| `CI_GITEA_USERNAME` | — | Gitea username for registry authentication |
| `VIKUNJA_TOKEN` | — | Vikunja API token | | `VIKUNJA_TOKEN` | — | Vikunja API token |
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) | | `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
#### CI_GITEA_TOKEN scopes
The `CI_GITEA_TOKEN` is a single Gitea Personal Access Token used across all
workflows. It requires these scopes:
| Scope | Purpose |
|-------|---------|
| `read:repository` | Read repos, PRs, issues, branches |
| `write:repository` | Push commits, merge PRs, create tags/releases, create issues, set branch protection, push wiki |
| `read:package` | Pull packages from Gitea PyPI registry, pull Docker images |
| `write:package` | Publish packages to Gitea PyPI registry, push Docker images |
| `read:organization` | Query org-level runners for molecule test distribution |
### Per-project overrides ### Per-project overrides
Projects using devx can override the default API URLs and language by setting Projects using devx can override the default API URLs and language by setting
+6
View File
@@ -9,6 +9,12 @@
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install rsync (required by molecule_docker for file sync between host and test containers)
RUN apt-get update && apt-get install -y --no-install-recommends rsync \
&& rm -rf /var/lib/apt/lists/*
# Install devx[release,molecule,deploy] from local source # Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \ RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
+5
View File
@@ -15,3 +15,8 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
# Install CI/CD binary tools # Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \ RUN python3 -m devx.tools.install_tools --tool actionlint \
&& python3 -m devx.tools.install_checkmake && 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
+7 -7
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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) [![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/917159e3043b69a79e75f1976c1018be1d8de629/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/917159e3043b69a79e75f1976c1018be1d8de629/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -149,7 +149,7 @@ fallback. Key variables:
| `DEVX_REPO_NAME` | **(must be set)** | Repository name | | `DEVX_REPO_NAME` | **(must be set)** | Repository name |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) | | `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) | | `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `REPO_TOKEN` | — | Gitea API token | | `CI_GITEA_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token | | `VIKUNJA_TOKEN` | — | Vikunja API token |
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
+2 -2
View File
@@ -245,7 +245,7 @@ for retrying on git push failures.
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue 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 creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `REPO_TOKEN` and `DEVX_GITEA_API_URL` before creating CLI login profile from `CI_GITEA_TOKEN` and `DEVX_GITEA_API_URL` before creating
the issue. the issue.
### `post_merge.py` ### `post_merge.py`
@@ -569,7 +569,7 @@ push_badges.py:
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
reliable, official support. It is installed by reliable, official support. It is installed by
`python -m devx.tools.install_tools` and configured by `python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`). `python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
`devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that `devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that
tea does not support (wiki management, commit status, runner discovery, tea does not support (wiki management, commit status, runner discovery,
+2 -2
View File
@@ -305,7 +305,7 @@ post-merge workflow when it creates and pushes a new version tag.
and the project itself and the project itself
2. **Install CI tools** — git-cliff and tea via 2. **Install CI tools** — git-cliff and tea via
`python -m devx.tools.install_tools` `python -m devx.tools.install_tools`
3. **Configure tea login** — `tea login add` using `REPO_TOKEN` 3. **Configure tea login** — `tea login add` using `CI_GITEA_TOKEN`
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`: 4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
- Build the package with `python -m build` - Build the package with `python -m build`
- Publish to the Gitea PyPI registry (default) using `twine upload - Publish to the Gitea PyPI registry (default) using `twine upload
@@ -377,7 +377,7 @@ python -m devx.ci.pr_review <pr_number> <owner/repo>
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue 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 creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `REPO_TOKEN`. CLI login profile from `CI_GITEA_TOKEN`.
```bash ```bash
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \ python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
+3 -3
View File
@@ -148,7 +148,7 @@ devx ci integration-guard -- -x -v --tb=short test_a.py
Environment variables: Environment variables:
- `GITEA_URL` — base URL of the Gitea instance - `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`) - `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`) - `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index) - `MATRIX_INDEX` — current matrix index (runner-index)
@@ -171,7 +171,7 @@ Options:
- `--run-id <id>` — CI run ID (required) - `--run-id <id>` — CI run ID (required)
- `--workflow <name>` — workflow name (required) - `--workflow <name>` — workflow name (required)
- `--commit <sha>` — commit SHA (required) - `--commit <sha>` — commit SHA (required)
- `--auto-login` — configure tea CLI login from `REPO_TOKEN` before creating - `--auto-login` — configure tea CLI login from `CI_GITEA_TOKEN` before creating
the issue the issue
### `devx ci post-merge` ### `devx ci post-merge`
@@ -462,7 +462,7 @@ Options:
Environment variables: Environment variables:
- `GITEA_URL` — base URL of the Gitea instance - `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`) - `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`) - `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index) - `MATRIX_INDEX` — current matrix index (runner-index)
+36 -27
View File
@@ -13,10 +13,13 @@ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
] ]
# All dependencies are pinned to exact versions for full reproducibility.
# Update pinned versions in a dedicated PR with verification.
dependencies = [ dependencies = [
"requests>=2.34.2", "requests==2.34.2",
"python-dotenv>=1.2.2", "python-dotenv==1.2.2",
"click>=8.4.1", "click==8.4.2",
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
] ]
[project.scripts] [project.scripts]
@@ -28,44 +31,44 @@ version = {attr = "devx.__version__"}
[project.optional-dependencies] [project.optional-dependencies]
# Test runners (pytest + coverage + parallel execution) # Test runners (pytest + coverage + parallel execution)
ci = [ ci = [
"pytest>=9.1.0", "pytest==9.1.1",
"pytest-cov>=7.1.0", "pytest-cov==7.1.0",
"pytest-xdist>=3.8", "pytest-xdist==3.8.0",
] ]
# Lint and type-checking tools (quality job, badge generation) # Lint and type-checking tools (quality job, badge generation)
lint = [ lint = [
"ruff>=0.15.17", "ruff==0.15.20",
"pyright>=1.1.410", "pyright==1.1.411",
"bandit>=1.8.2", "bandit==1.9.4",
"pip-audit>=2.10", "pip-audit==2.10.1",
"pre-commit>=4.6.0", "pre-commit==4.6.0",
] ]
# Release tools (build + publish to PyPI/Gitea registry) # Release tools (build + publish to PyPI/Gitea registry)
release = [ release = [
"build>=1.5.0", "build==1.5.0",
"twine>=6.2.0", "twine==6.2.0",
] ]
# Molecule testing (for projects with Ansible roles) # Molecule testing (for projects with Ansible roles)
molecule = [ molecule = [
"molecule>=26.4.0", "molecule==26.4.0",
"molecule-docker>=2.1.0", "molecule-docker==2.1.0",
"ansible-lint>=26.4.0", "ansible-lint==26.4.0",
"ansible-core>=2.15,<2.17", "ansible-core==2.21.1",
] ]
# Deploy tools (for infra staging/production deployments) # Deploy tools (for infra staging/production deployments)
deploy = [ deploy = [
"ansible-core>=2.15,<2.17", "ansible-core==2.21.1",
"boto3>=1.34", "boto3==1.43.36",
"docker>=7.0", "docker==7.1.0",
"jinja2>=3.1", "jinja2==3.1.6",
"pyyaml>=6.0", "pyyaml==6.0.3",
"cryptography>=41.0", "cryptography==49.0.0",
] ]
# Full dev environment (local development) # Full dev environment (local development)
dev = [ dev = [
"devx[ci,lint,release,molecule]", "devx[ci,lint,release,molecule]",
"build>=1.3.0", "build==1.5.0",
"twine>=6.2.0", "twine==6.2.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -97,6 +100,8 @@ indent-style = "space"
[tool.pyright] [tool.pyright]
include = ["src"] include = ["src"]
pythonVersion = "3.12" pythonVersion = "3.12"
venvPath = "."
venv = ".venv"
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"] strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -139,8 +144,12 @@ infrastructure_overrides = [
] ]
# User-facing overrides — safety override for broad infrastructure patterns # User-facing overrides — safety override for broad infrastructure patterns
# (empty — add when an infrastructure pattern is too broad) # devx workflow files (.gitea/**) are reference implementations that
user_facing_overrides = [] # downstream repos (grm, infra) copy from. Changes to them affect how
# consumer projects run their CI, so they must trigger a release.
user_facing_overrides = [
".gitea/**",
]
# Tag patterns — additional categories for CI conditional execution # Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure) # Orthogonal to release impact (user-facing vs infrastructure)
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.20.2" __version__ = "0.26.4"
+130 -93
View File
@@ -4,10 +4,16 @@ from __future__ import annotations
import json import json
import logging import logging
import time
from typing import Any from typing import Any
import requests import requests
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.exceptions import APIError from devx.exceptions import APIError
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
return status, message return status, message
def _is_retryable(e: Exception) -> bool: class _TransientHTTPError(requests.HTTPError):
"""Check if an exception is a transient error worth retrying.""" """HTTP error with a retryable status code (wrapped for tenacity)."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError): class _RetryableRequestError(Exception):
status, _ = _parse_error(e) """Connection/timeout error wrapped for tenacity retry."""
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
def _execute_request(
session: requests.Session,
method: str,
url: str,
**kwargs: Any,
) -> requests.Response:
"""Execute a single HTTP request, wrapping transient errors for tenacity.
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
tenacity will retry.
"""
try:
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if status in RETRY_STATUS_CODES:
# Wrap in _TransientHTTPError so tenacity retries it
raise _TransientHTTPError(message, response=e.response) from e
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
raise _RetryableRequestError(str(e)) from e
# Tenacity retry decorator shared by both clients.
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
_retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def _request_with_retry(
session: requests.Session,
url: str,
method: str,
**kwargs: Any,
) -> requests.Response:
"""Execute an HTTP request with tenacity-managed retry logic.
On exhaustion, the last exception is translated to :class:`APIError`.
"""
try:
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
except _TransientHTTPError as e:
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
raise APIError(status, str(e)) from e
except _RetryableRequestError as e:
raise APIError(0, str(e)) from e
class GiteaClient: class GiteaClient:
@@ -56,49 +117,7 @@ class GiteaClient:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}" return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path) return _request_with_retry(self._session, self._url(path), method, **kwargs)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
# -- repo settings -- # -- repo settings --
@@ -223,6 +242,20 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}/files") r = self._request("GET", f"/pulls/{pr_number}/files")
return r.json() return r.json()
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
"""Attach labels to a PR/issue by name.
Args:
pr_number: PR or issue number.
label_names: List of label names to attach.
"""
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
"""Return label names currently attached to a PR/issue."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return [label.get("name", "") for label in r.json()]
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]: def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request.""" """Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits") r = self._request("GET", f"/pulls/{pr_number}/commits")
@@ -301,6 +334,31 @@ class GiteaClient:
return existing return existing
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease) return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
# -- actions (CI/CD) --
def list_action_runs(self, **params: Any) -> dict[str, Any]:
"""List workflow runs for the repository.
Returns the raw API response dict (includes ``workflow_runs`` and
``total_count`` keys per Gitea API).
"""
r = self._request("GET", "/actions/runs", params=params)
return r.json()
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
"""List jobs for a specific workflow run."""
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
data = r.json()
return data.get("jobs", [])
def get_action_job_logs(self, job_id: str | int) -> str:
"""Fetch logs for a specific CI job.
Returns the raw log text. Raises APIError if logs are unavailable.
"""
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
return r.text
class VikunjaClient: class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling.""" """Low-level Vikunja REST API client with connection pooling."""
@@ -312,47 +370,7 @@ class VikunjaClient:
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}" url = f"{self._base_url}{path}"
last_exc: Exception | None = None return _request_with_retry(self._session, url, method, **kwargs)
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
def list_tasks(self, **params: Any) -> list[dict[str, Any]]: def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params) r = self._request("GET", "/tasks", params=params)
@@ -368,6 +386,25 @@ class VikunjaClient:
r = self._request("GET", f"/projects/{project_id}/tasks", params=params) r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json() return r.json()
def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None:
"""Find a task by its identifier (e.g. ``DEVX-42``) in a project.
Paginates through all tasks in the project. Returns the task dict
or None if not found.
"""
page = 1
while True:
tasks = self.list_project_tasks(project_id, page=page, per_page=per_page)
if not tasks:
break
for t in tasks:
if t.get("identifier") == identifier:
return t
if len(tasks) < per_page:
break
page += 1
return None
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]: 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. """Create a task in a project and return the created task dict.
+100
View File
@@ -2,8 +2,14 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click
from devx.config import TASK_ID_RE
from devx.i18n import _
def get_latest_tag() -> str: def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists.""" """Get the latest git tag, or empty string if none exists."""
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
if result.returncode != 0: if result.returncode != 0:
return "" return ""
return result.stdout.strip() return result.stdout.strip()
def run_cmd(
args: list[str],
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process.
Args:
args: Command and arguments as a list.
check: If True, raise :class:`click.ClickException` on non-zero exit.
capture: If True, capture stdout/stderr. If False, inherit parent's.
"""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def extract_task_id(text: str) -> str:
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
no task ID is found.
"""
match = TASK_ID_RE.search(text)
return match.group(0) if match else ""
def write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_ENV`` file.
Multi-line values use the heredoc syntax required by Gitea Actions.
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
"""
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a", encoding="utf-8") as f: # noqa: PTH123
if "\n" in value:
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
"""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT scheduling.
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
Args:
items: Items to distribute.
weights: Parallel list of integer weights (higher = heavier).
max_runners: Number of runner groups to create.
Returns:
A list of ``max_runners`` lists, each containing the items assigned
to that runner.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
+21 -27
View File
@@ -17,12 +17,11 @@ This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits. while the squashed commit follows conventional commits.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number> CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
""" """
import os import os
import re import re
import subprocess # nosec B404
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -30,11 +29,11 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient from devx.api_clients import GiteaClient, VikunjaClient
from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import ( from devx.config import (
CONVENTIONAL_RE, CONVENTIONAL_RE,
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
GITEA_API_URL, GITEA_API_URL,
TASK_ID_RE,
TASK_PREFIX, TASK_PREFIX,
VIKUNJA_API_URL, VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
@@ -48,20 +47,6 @@ PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv() load_dotenv()
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() or result.stdout.strip(),
)
)
return result
def read_taskid(branch: str) -> str: def read_taskid(branch: str) -> str:
"""Read task ID from branch name. """Read task ID from branch name.
@@ -92,9 +77,8 @@ def read_taskid(branch: str) -> str:
def extract_task_id(branch: str) -> str: def extract_task_id(branch: str) -> str:
"""Extract DEVX-N task identifier from branch name (legacy fallback).""" """Extract task identifier from branch name (delegates to shared utility)."""
match = TASK_ID_RE.search(branch) return _extract_task_id(branch)
return match.group(0) if match else ""
def validate_pr_title(pr_title: str, task_id: str) -> None: def validate_pr_title(pr_title: str, task_id: str) -> None:
@@ -174,15 +158,25 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str: def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits. """Extract the conventional commit message from PR commits.
Iterates commits in reverse order (newest first) to find the first Picks the highest-priority conventional commit message from the PR.
message matching the conventional commit format. Falls back to the Priority: feat > fix > refactor > docs > chore > other.
newest commit message if none match. Falls back to the newest commit message if none match.
""" """
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
best_msg = ""
best_score = 0
for commit in reversed(commits): for commit in reversed(commits):
commit_info = commit.get("commit", {}) commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0] message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
if CONVENTIONAL_RE.match(message): m = CONVENTIONAL_RE.match(message)
return message if m:
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
score = priority.get(prefix, 0)
if score > best_score:
best_score = score
best_msg = message
if best_msg:
return best_msg
# Fallback: use the newest commit's first line # Fallback: use the newest commit's first line
if commits: if commits:
commit_info = commits[-1].get("commit", {}) commit_info = commits[-1].get("commit", {})
@@ -196,9 +190,9 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo") @click.argument("repo")
@click.argument("pr_number") @click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: 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: 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 # Validate PR number is an integer
try: try:
+9 -6
View File
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
Usage:: Usage::
# CI (with VIKUNJA_TOKEN and REPO_TOKEN): # CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\ python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\ --branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\ --pr-title "$PR_TITLE" \\
@@ -34,7 +34,7 @@ skipped (with a warning) — this allows local pre-push hooks to run
without CI secrets. In CI, the token is always set and the check is without CI secrets. In CI, the token is always set and the check is
mandatory. mandatory.
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
branch-name and PR-title-format checks run (local mode). branch-name and PR-title-format checks run (local mode).
""" """
@@ -53,6 +53,7 @@ from devx.config import (
VIKUNJA_API_URL, VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
) )
from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -98,9 +99,9 @@ def is_branch_behind_master(branch: str) -> bool:
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None: def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API. """Fetch the PR title from the Gitea API.
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched. Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
""" """
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token or "/" not in repo: if not token or "/" not in repo:
return None return None
owner, repo_name = repo.split("/", 1) owner, repo_name = repo.split("/", 1)
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
try: try:
pr = client.get_pr(pr_number) pr = client.get_pr(pr_number)
return str(pr.get("title", "")) return str(pr.get("title", ""))
except Exception: except APIError:
return None return None
@@ -189,7 +190,9 @@ def cli(
if pr_title is None: if pr_title is None:
# Local mode without PR — only validate branch name # Local mode without PR — only validate branch name
if pr_number is not None: if pr_number is not None:
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).")) raise click.ClickException(
_("Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).")
)
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).") click.echo("[pre-merge-check] 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.") click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
return return
+20 -26
View File
@@ -138,7 +138,7 @@ from typing import Any
import click import click
from devx.ci._shared import get_latest_tag from devx.ci._shared import get_latest_tag, write_github_output
from devx.i18n import _ from devx.i18n import _
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -393,14 +393,15 @@ class ChangeClassifier:
tags = self._compute_tags(file_path) tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety) # 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides: for pattern in self._user_overrides:
return FileClassification( if _matches_glob(file_path, pattern):
path=file_path, return FileClassification(
is_user_facing=True, path=file_path,
reason="User-facing override (safety override)", is_user_facing=True,
matched_rule="user_facing_overrides", reason=f"User-facing override (matches '{pattern}')",
tags=tags, matched_rule="user_facing_overrides",
) tags=tags,
)
# 2. Infrastructure overrides # 2. Infrastructure overrides
if file_path in self._infra_overrides: if file_path in self._infra_overrides:
@@ -583,15 +584,8 @@ def has_user_facing_changes(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _write_github_output(key: str, value: str) -> None: # ---------------------------------------------------------------------------
"""Append a key=value line to the $GITHUB_OUTPUT file.""" # Classification logic
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -632,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
force = True force = True
if force and github_output: if force and github_output:
_write_github_output("user-facing-changed", "true") write_github_output("user-facing-changed", "true")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true") write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.") click.echo("Forced user-facing-changed=true via --force flag.")
return return
@@ -642,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
base = get_latest_tag() base = get_latest_tag()
if not base: if not base:
if github_output: if github_output:
_write_github_output("user-facing-changed", "true") write_github_output("user-facing-changed", "true")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true") write_github_output(f"{tag}-changed", "true")
click.echo("No tags found — treating all changes as user-facing.") click.echo("No tags found — treating all changes as user-facing.")
return return
if quiet: if quiet:
@@ -656,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
files = get_changed_files(base, head) files = get_changed_files(base, head)
if not files: if not files:
if github_output: if github_output:
_write_github_output("user-facing-changed", "false") write_github_output("user-facing-changed", "false")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "false") write_github_output(f"{tag}-changed", "false")
click.echo(f"No changes between {base} and {head}.") click.echo(f"No changes between {base} and {head}.")
return return
if quiet: if quiet:
@@ -670,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
result = classifier.classify(files) result = classifier.classify(files)
if github_output: if github_output:
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false") write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false") write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}") click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags: for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}") click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
+6 -13
View File
@@ -12,12 +12,14 @@ Usage::
from __future__ import annotations from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
from devx.ci._shared import write_github_output
from devx.i18n import _
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+") RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
@@ -39,26 +41,17 @@ def is_release_commit(message: str) -> bool:
return bool(RELEASE_RE.match(message)) return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command() @click.command()
def main() -> None: def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT.""" """Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
msg = get_commit_message() msg = get_commit_message()
click.echo(f"Commit message: {msg}") click.echo(_("Commit message: {msg}", msg=msg))
is_release = is_release_commit(msg) is_release = is_release_commit(msg)
write_github_output("is-release", "true" if is_release else "false") write_github_output("is-release", "true" if is_release else "false")
if is_release: if is_release:
click.echo("Release commit — skipping all post-merge jobs.") click.echo(_("Release commit — skipping all post-merge jobs."))
else: else:
click.echo("Regular merge commit — running all post-merge jobs.") click.echo(_("Regular merge commit — running all post-merge jobs."))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+19 -15
View File
@@ -29,7 +29,8 @@ import os
import click import click
import requests import requests
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
else: else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True) click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True) click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners # 2. Organization-level runners
try: try:
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
else: else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True) click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True) click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope) # 3. Instance-level runners (requires admin scope)
try: try:
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
elif r.status_code != 403: # 403 is expected without admin scope elif r.status_code != 403: # 403 is expected without admin scope
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True) click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True) click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total return total
@@ -148,12 +152,12 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
if repo is None: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "devx") repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
count = get_runner_count(GITEA_API_URL, token, owner, repo) count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count) indices = generate_indices(count)
@@ -162,11 +166,11 @@ def main(
gh_output = os.environ.get("GITHUB_OUTPUT") gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output: if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set") raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123 with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n") f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n") f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}") click.echo(_("Runner count: {count}", count=count))
click.echo(f"Runner indices: {indices}") click.echo(_("Runner indices: {indices}", indices=indices))
return return
if output_count: if output_count:
@@ -178,8 +182,8 @@ def main(
return return
# Default: output both as key=value pairs for CI consumption # Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}") click.echo(_("count={count}", count=count))
click.echo(f"indices={json.dumps(indices)}") click.echo(_("indices={indices}", indices=json.dumps(indices)))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+18 -30
View File
@@ -26,6 +26,7 @@ import os
import click import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _ from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
the runner with the least total weight. the runner with the least total weight.
""" """
weights = [_file_weight(f) for f in files] weights = [_file_weight(f) for f in files]
groups: list[list[str]] = [[] for _ in range(max_runners)] return lpt_distribute(files, weights, max_runners)
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, f in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(f)
loads[min_runner] += weights[orig_idx]
return groups
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]: def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
return groups[runner_index] return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
if "\n" in value:
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
@click.command() @click.command()
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.") @click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option( @click.option(
@@ -122,26 +102,34 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
groups = distribute(files, max_runners) groups = distribute(files, max_runners)
for i, group in enumerate(groups): for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)" labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}") click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return return
if skip_if_excess and github_env and runner_index > max_runners: if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}") click.echo(
_write_github_env("ASSIGNED_FILES", "") _(
_write_github_env("SKIP", "true") "Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_FILES", "")
write_github_env("SKIP", "true")
return return
if runner_index < 1: if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)") raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1 zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners) assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned) encoded = "\n".join(assigned)
if github_env: if github_env:
_write_github_env("ASSIGNED_FILES", encoded) write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false") write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}") click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
return return
click.echo(encoded) click.echo(encoded)
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Distribute a list of items across N parallel runners using LPT scheduling.
Generic item distribution for CI matrix jobs. Items are read from a JSON
array on stdin (or from a file via --items-file), sorted for deterministic
ordering, then assigned to *max_runners* groups using LPT (Longest
Processing Time first) scheduling.
Each item is a string (e.g. an Ansible ``--limit`` pattern like
``observability`` or ``infra-314-vm``). Optionally, items can be objects
with ``{"id": "...", "weight": N}`` to provide explicit weights.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
Usage::
echo '["observability", "infra-314-vm"]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 \\
--github-env --skip-if-excess
# With weights:
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 --github-env
"""
from __future__ import annotations
import json
import sys
import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
DEFAULT_WEIGHT = 1
def parse_items(raw: str) -> list[str]:
"""Parse a JSON array into a list of item identifier strings.
Accepts both plain string arrays (``["a", "b"]``) and object arrays
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
"""Parse a JSON array into (items, weights) lists.
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
weights: list[int] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
weights.append(DEFAULT_WEIGHT)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items, weights
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
Items are sorted by weight (descending), then assigned to the runner
with the least total weight.
"""
return lpt_distribute(items, weights, max_runners)
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of items assigned to *runner_index* (0-based)."""
groups = distribute(items, weights, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
@click.command()
@click.option(
"--items-file",
type=click.Path(exists=True, file_okay=True, path_type=None),
default=None,
help="Read items from a JSON file instead of stdin.",
)
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--github-env",
is_flag=True,
default=False,
help="Write ASSIGNED_ITEMS and SKIP to $GITHUB_ENV.",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def main(
items_file: str | None,
runner_index: int | None,
max_runners: int,
github_env: bool,
skip_if_excess: bool,
) -> None:
# Read items from file or stdin
if items_file is not None:
with open(items_file, encoding="utf-8") as f: # noqa: PTH123
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
raw = "[]"
items, weights = parse_weighted_items(raw)
if runner_index is None:
groups = distribute(items, weights, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_ITEMS", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = items_for_runner(items, weights, zero_based, max_runners)
encoded = " ".join(assigned)
if github_env:
write_github_env("ASSIGNED_ITEMS", encoded)
write_github_env("SKIP", "false")
click.echo(
_(
"Assigned {count} items to runner {runner_index}: {encoded}",
count=len(assigned),
runner_index=runner_index,
encoded=encoded,
)
)
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+7 -6
View File
@@ -17,7 +17,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. 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). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -36,6 +36,7 @@ import time
import click import click
from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.molecule.molecule_ci_guard import ( from devx.molecule.molecule_ci_guard import (
poll_for_other_failures, poll_for_other_failures,
@@ -49,17 +50,17 @@ POLL_INTERVAL = 10
def cli(pytest_args: tuple[str, ...]) -> None: def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection.""" """Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "") 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")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests") job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") repository = os.environ.get("GITEA_REPOSITORY", "")
owner, _sep, repo = repository.partition("/") owner, _sep, repo = repository.partition("/")
if not owner or not repo: if not owner or not repo:
owner, repo = "oblachno-oss", "devx" owner, repo = REPO_OWNER, REPO_NAME
if not all([gitea_url, token, run_id]): 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() stop_event = threading.Event()
failed_event = threading.Event() failed_event = threading.Event()
@@ -85,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
cmd = [sys.executable, "-m", "pytest"] cmd = [sys.executable, "-m", "pytest"]
cmd.extend(pytest_args) cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}") click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
process = subprocess.Popen( # nosec B603 process = subprocess.Popen( # nosec B603
cmd, cmd,
+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. for issue creation tea must be installed and configured.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \ CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
@@ -14,7 +14,7 @@ Usage:
--auto-login --auto-login
With ``--auto-login``, the script configures the tea CLI login profile 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. eliminating the need for a separate ``tea login add`` step in the workflow.
""" """
@@ -22,14 +22,12 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil
import subprocess # nosec B404
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL 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 _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -37,49 +35,6 @@ load_dotenv()
logger = logging.getLogger("devx") 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: def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index. """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", "--auto-login",
is_flag=True, is_flag=True,
default=False, 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: 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: 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: if auto_login:
_configure_tea_login() configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})" title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = ( body = (
+5 -5
View File
@@ -13,7 +13,8 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import VikunjaClient from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
def extract_task_id(commit_msg: str) -> str: def extract_task_id(commit_msg: str) -> str:
"""Extract DEVX-N task identifier from the first line of commit message.""" """Extract task identifier from the first line of commit message (delegates to shared utility)."""
first_line = commit_msg.split("\n")[0] first_line = commit_msg.split("\n")[0]
match = TASK_ID_RE.search(first_line) return _extract_task_id(first_line)
return match.group(0) if match else ""
def extract_conventional_msg(commit_msg: str) -> str: def extract_conventional_msg(commit_msg: str) -> str:
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
- ``DEVX-N <message>`` (current, space-separated) - ``DEVX-N <message>`` (current, space-separated)
""" """
first_line = commit_msg.split("\n")[0] first_line = commit_msg.split("\n")[0]
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line) return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int: def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
+99 -5
View File
@@ -17,7 +17,7 @@ Checks performed:
8. Commit conventions conventional commit format on branch commits 8. Commit conventions conventional commit format on branch commits
Usage: 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 from __future__ import annotations
@@ -520,19 +520,113 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
return client.create_review(pr_number, event=event, body=body, comments=comments) return client.create_review(pr_number, event=event, body=body, comments=comments)
def _post_manual_review(
client: GiteaClient,
pr_number: str,
event: str,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
dry_run: bool,
) -> None:
"""Post a manual review with validation for APPROVE events."""
if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters."))
if event == "APPROVE":
if not checklist_confirmed:
raise click.ClickException(
_("--checklist-confirmed is required for APPROVE events."),
)
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
cat_nums: list[int] = []
for c in cats:
try:
cat_nums.append(int(c))
except ValueError:
raise click.ClickException(
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
) from None
if len(cat_nums) < 8:
raise click.ClickException(
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
)
click.echo(f"Manual review event: {event}")
click.echo(f"Body: {body[:80]}...")
if checklist_confirmed:
click.echo(f"Checklist confirmed: {checklist_categories}")
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = client.create_review(pr_number, event=event, body=body)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
review_id=review_id,
pr_number=pr_number,
event=event,
)
)
@click.command() @click.command()
@click.argument("pr_number") @click.argument("pr_number")
@click.argument("repo") @click.argument("repo")
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.") @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: @click.option(
"""Run automated PR review and post results to Gitea.""" "--event",
token = os.environ.get("REPO_TOKEN", "") type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
default=None,
help="Post a manual review with the given event (skips automated checks).",
)
@click.option("--body", default=None, help="Review body text (required with --event).")
@click.option(
"--checklist-confirmed",
is_flag=True,
default=False,
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
)
@click.option(
"--checklist-categories",
default=None,
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
)
def main(
pr_number: str,
repo: str,
dry_run: bool,
event: str | None,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
) -> None:
"""Run automated PR review and post results to Gitea.
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks).
"""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: 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("/") owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None:
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
return
result = run_review(client, pr_number) result = run_review(client, pr_number)
body = build_review_body(result) body = build_review_body(result)
+19 -8
View File
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL`` ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the --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 2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow. ``twine upload -u __token__ -p <token>`` flow.
3. **Skip** if neither is configured, only the Gitea release is created. 3. **Skip** if neither is configured, only the Gitea release is created.
Usage: Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo> CI_GITEA_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> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
""" """
import os import os
@@ -28,8 +28,8 @@ from pathlib import Path
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str:
base = base[: -len("/api/v1")] base = base[: -len("/api/v1")]
elif base.endswith("/api"): elif base.endswith("/api"):
base = base[: -len("/api")] base = base[: -len("/api")]
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
return f"{base}/api/packages/{owner}/pypi" return f"{base}/api/packages/{owner}/pypi"
@@ -221,12 +221,20 @@ def is_release_commit(tag: str) -> bool:
help="Auto-detect latest tag and check if HEAD is a release commit. " 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.", "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( def main(
tag: str | None, tag: str | None,
repo: str | None, repo: str | None,
registry_url: str | None, registry_url: str | None,
skip_build: bool, skip_build: bool,
from_tag: bool, from_tag: bool,
auto_login: bool,
) -> None: ) -> None:
if repo is None: if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
@@ -245,9 +253,9 @@ def main(
if not tag: if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag).")) raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("REPO_TOKEN", "") gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
if not gitea_token: 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", "") pypi_token = os.environ.get("PYPI_TOKEN", "")
@@ -287,6 +295,9 @@ def main(
tea = TeaCLI(repo=repo) tea = TeaCLI(repo=repo)
if auto_login:
configure_tea_login()
# Check if release already exists (idempotent — avoids failure when # Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows) # called multiple times, e.g. by both post-merge and publish workflows)
try: try:
+22 -11
View File
@@ -28,6 +28,8 @@ from typing import Any
import click import click
from devx.i18n import _
def _repo_root() -> Path: def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd.""" """Resolve repo root from GITHUB_WORKSPACE or cwd."""
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
""" """
_run(["git", "fetch", "origin", branch]) # nosec B607 _run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607 _run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}") click.echo(_("Synced to latest origin/{branch}", branch=branch))
def generate_badges(output_dir: str) -> None: def generate_badges(output_dir: str) -> None:
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir]) _run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg")) badges = list(Path(output_dir).glob("*.svg"))
if not badges: if not badges:
raise click.ClickException("No badge SVG files generated") raise click.ClickException(_("No badge SVG files generated"))
click.echo(f"Generated {len(badges)} badge files") click.echo(_("Generated {count} badge files", count=len(badges)))
def push_to_badges_branch(badges_dir: str) -> str: def push_to_badges_branch(badges_dir: str) -> str:
@@ -99,12 +101,12 @@ def push_to_badges_branch(badges_dir: str) -> str:
_run(["git", "add", "./*.svg"]) # nosec B607 _run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607 _run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607 _run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch") click.echo(_("Badges pushed to badges branch"))
# Get the commit SHA of the badges branch # Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607 result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip() sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}") click.echo(_("Badges commit SHA: {sha}", sha=sha))
return sha return sha
@@ -142,11 +144,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
new_content = update_badge_urls(content, badges_sha) new_content = update_badge_urls(content, badges_sha)
if new_content != content: if new_content != content:
filepath.write_text(new_content) filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}") click.echo(_("Updated badge URLs in {filename}", filename=filename))
updated_any = True updated_any = True
if not updated_any: if not updated_any:
click.echo("No badge URLs found to update — README already up to date") click.echo(_("No badge URLs found to update — README already up to date"))
return return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607 _run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
@@ -160,7 +162,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
] ]
) # nosec B607 ) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607 _run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}") click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
@click.command() @click.command()
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
except (subprocess.CalledProcessError, RuntimeError) as exc: except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc last_error = exc
if attempt < retries: if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}") click.echo(
_(
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
attempt=attempt,
retries=retries,
error=exc,
)
)
time.sleep(10) time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError): with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch) fetch_latest_master(branch)
else: else:
click.echo(f"Badge push failed after {retries} attempts: {exc}") click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}") raise click.ClickException(
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+31 -39
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. checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage: 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 python3 -m devx.ci.release --verify # Check tag/version/release alignment
""" """
@@ -37,13 +37,12 @@ from __future__ import annotations
import os import os
import re import re
import subprocess # nosec B404
import sys import sys
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=. from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _ from devx.i18n import _
@@ -54,25 +53,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml" CLIFF_CONFIG = "cliff.toml"
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def tag_exists(tag: str) -> bool: def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists.""" """Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False) result = run_cmd(["git", "tag", "-l", tag], check=False)
@@ -217,7 +197,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
def update_init_version(new_version: str) -> None: def update_init_version(new_version: str) -> None:
"""Update __version__ in __init__.py.""" """Update __version__ in __init__.py."""
with open(INIT_FILE) as f: with open(INIT_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE): if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE)) raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
@@ -228,7 +208,7 @@ def update_init_version(new_version: str) -> None:
count=1, count=1,
flags=re.MULTILINE, flags=re.MULTILINE,
) )
with open(INIT_FILE, "w") as f: with open(INIT_FILE, "w", encoding="utf-8") as f:
f.write(updated) f.write(updated)
@@ -245,10 +225,10 @@ def update_changelog(changelog: str) -> None:
changelog = changelog[section_match.start() :] changelog = changelog[section_match.start() :]
try: try:
with open(CHANGELOG_FILE) as f: with open(CHANGELOG_FILE, encoding="utf-8") as f:
existing = f.read() existing = f.read()
except FileNotFoundError: except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f: with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(changelog + "\n") f.write(changelog + "\n")
return return
@@ -261,7 +241,7 @@ def update_changelog(changelog: str) -> None:
else: else:
# No version sections found — append # No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n" updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f: with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(updated) f.write(updated)
@@ -279,7 +259,7 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0: if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date.")) click.echo(_("No staged changes — version and changelog already up to date."))
return False return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"]) run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
return True return True
@@ -317,18 +297,16 @@ def run_tests() -> None:
click.echo(_("Tests passed.")) click.echo(_("Tests passed."))
def _write_github_output(tag: str) -> None: def _write_release_tag(tag: str) -> None:
"""Write the release tag to GITHUB_OUTPUT for downstream jobs. """Write the release tag to GITHUB_OUTPUT for downstream jobs.
This allows a publish job (needs: release) to read the tag via This allows a publish job (needs: release) to read the tag via
``${{ needs.release.outputs.tag }}`` instead of relying on ``${{ needs.release.outputs.tag }}`` instead of relying on
tag-push event triggering a separate workflow. tag-push event triggering a separate workflow.
""" """
github_output = os.environ.get("GITHUB_OUTPUT") if not os.environ.get("GITHUB_OUTPUT"):
if not github_output:
return return
with open(github_output, "a") as f: # noqa: PTH123 write_github_output("tag", tag)
f.write(f"tag={tag}\n")
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag)) click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
@@ -360,7 +338,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
if not dry_run: if not dry_run:
# Ensure the existing tag is pushed # Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag) _write_release_tag(tag)
return False return False
tag_msg = f"Release v{new_version}\n\n{changelog}" tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run: if dry_run:
@@ -368,7 +346,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"]) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag) _write_release_tag(tag)
return True return True
@@ -380,7 +358,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
def get_init_version() -> str | None: def get_init_version() -> str | None:
"""Read __version__ from the version file.""" """Read __version__ from the version file."""
try: try:
with open(INIT_FILE) as f: with open(INIT_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE) match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None return match.group(1) if match else None
@@ -391,7 +369,7 @@ def get_init_version() -> str | None:
def get_changelog_versions() -> list[str]: def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order.""" """Extract version numbers from CHANGELOG.md headers, in order."""
try: try:
with open(CHANGELOG_FILE) as f: with open(CHANGELOG_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE) return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError: except FileNotFoundError:
@@ -634,7 +612,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag, tag=release_tag,
) )
) )
_write_github_output(release_tag) _write_release_tag(release_tag)
return return
# Tag is missing — recover by creating and pushing it # Tag is missing — recover by creating and pushing it
click.echo( click.echo(
@@ -669,6 +647,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
return return
current_tag = get_latest_tag() 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( click.echo(
_( _(
"Bumping version: {current} -> v{new_version}", "Bumping version: {current} -> v{new_version}",
@@ -691,7 +683,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog)) click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE)) click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE)) click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version)) click.echo(_("[dry-run] Would commit: release: v{version} [skip ci]", version=new_version))
click.echo(_("[dry-run] Would push commit to master")) click.echo(_("[dry-run] Would push commit to master"))
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version)) click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
return return
+8 -8
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} - Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage: 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 from __future__ import annotations
@@ -28,7 +28,7 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
@@ -49,7 +49,7 @@ def load_mapping() -> dict[str, str]:
Validates that the mapping is a dict of string-to-string pairs. Validates that the mapping is a dict of string-to-string pairs.
""" """
with open(MAPPING_FILE) as f: with open(MAPPING_FILE, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, dict): if not isinstance(data, dict):
raise click.ClickException( raise click.ClickException(
@@ -64,7 +64,7 @@ def load_mapping() -> dict[str, str]:
def read_doc_content(file_path: str) -> str: def read_doc_content(file_path: str) -> str:
"""Read markdown content from a docs file.""" """Read markdown content from a docs file."""
full_path = DOCS_DIR / file_path full_path = DOCS_DIR / file_path
with open(full_path) as f: with open(full_path, encoding="utf-8") as f:
return f.read() return f.read()
@@ -225,13 +225,13 @@ def verify_wiki_integrity(
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.", help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
) )
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None: def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not 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: if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
repo_name = os.environ.get("DEVX_REPO_NAME", "devx") repo_name = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
else: else:
owner, repo_name = repo.split("/") owner, repo_name = repo.split("/")
+1 -1
View File
@@ -69,7 +69,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
if commit_msg_file == "-": if commit_msg_file == "-":
msg = sys.stdin.read().strip() msg = sys.stdin.read().strip()
else: else:
with open(commit_msg_file) as f: with open(commit_msg_file, encoding="utf-8") as f:
msg = f.read().strip() msg = f.read().strip()
else: else:
raise click.ClickException(_("Provide a commit message file or use --git.")) raise click.ClickException(_("Provide a commit message file or use --git."))
+1
View File
@@ -74,6 +74,7 @@ VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly. # Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
# No default: prevents silent 404s when the wrong owner is used. # No default: prevents silent 404s when the wrong owner is used.
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "") REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
REPO_NAME = _get("repo_name", "DEVX_REPO_NAME", "")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.) # Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX") TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
+52
View File
@@ -40,15 +40,67 @@ Usage::
from __future__ import annotations from __future__ import annotations
import json import json
import os
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
from typing import Any from typing import Any
import click
from devx.config import GITEA_API_URL
from devx.i18n import _
class TeaCLIError(Exception): class TeaCLIError(Exception):
"""Raised when a tea CLI command fails.""" """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: class TeaCLI:
"""Wrapper around the ``tea`` Gitea CLI tool. """Wrapper around the ``tea`` Gitea CLI tool.
+88 -21
View File
@@ -51,17 +51,19 @@ DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
DEVX_GITEA_PYPI_ORG ?= oblachno-oss DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows DEVX_WORKFLOW_DIR ?= .gitea/workflows
DEVX_DOCKERFILE_PATHS ?= docker
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured. # PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' # Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable. # CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \ CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \ _PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \ 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 $(DEVX_BIN)/pip
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config .PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake .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-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts .PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
@@ -69,6 +71,7 @@ DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
.PHONY: devx-clean devx-pre-push .PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed .PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
.PHONY: devx-test-unit devx-pytest-cov .PHONY: devx-test-unit devx-pytest-cov
.PHONY: devx-setup-image devx-lint-dockerfiles
# ── Vikunja task and PR management ──────────────────────────────────────────── # ── Vikunja task and PR management ────────────────────────────────────────────
@@ -92,15 +95,54 @@ devx-check-config:
# Push and create PR in one step # Push and create PR in one step
devx-push-with-pr: devx-push devx-create-pr devx-push-with-pr: devx-push devx-create-pr
# Check CI status for a PR (auto-detects current branch's PR)
# Usage: make devx-pr-status
# make devx-pr-status PR=42
# make devx-pr-status PR=42 WAIT=1 TIMEOUT=600
devx-pr-status:
@$(DEVX_PYTHON) -m devx.tools.pr_status \
$(if $(PR),--pr $(PR)) \
$(if $(WAIT),--wait) \
$(if $(TIMEOUT),--timeout $(TIMEOUT))
# Fetch logs for failed CI jobs on a PR
# Usage: make devx-pr-logs
# make devx-pr-logs PR=42
# make devx-pr-logs PR=42 JOB=quality TAIL=50
devx-pr-logs:
@$(DEVX_PYTHON) -m devx.tools.pr_logs \
$(if $(PR),--pr $(PR)) \
$(if $(JOB),--job $(JOB)) \
$(if $(TAIL),--tail $(TAIL))
# Add a label to a PR (default: ready-to-merge)
# Usage: make devx-pr-label
# make devx-pr-label PR=42
# make devx-pr-label PR=42 LABEL=ready-to-merge
devx-pr-label:
@$(DEVX_PYTHON) -m devx.tools.pr_label \
$(if $(PR),--pr $(PR)) \
--label $(or $(LABEL),ready-to-merge)
# Usage: make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13
# make devx-pr-review PR=42 EVENT=REQUEST_CHANGES BODY="..."
# make devx-pr-review PR=42 (auto review)
devx-pr-review:
@$(DEVX_PYTHON) -m devx.ci.pr_review \
$(PR) $(DEVX_REPO_OWNER)/$(DEVX_REPO_NAME) \
$(if $(EVENT),--event $(EVENT)) \
$(if $(BODY),--body "$(BODY)") \
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
# ── Environment setup ───────────────────────────────────────────────────────── # ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other # Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env. # private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
devx-configure-gitea-pypi: devx-configure-gitea-pypi:
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ @if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \ CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \ 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 (REPO_TOKEN present)." echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
# Create .env from .env.example if it doesn't exist # Create .env from .env.example if it doesn't exist
devx-env: devx-env:
@@ -173,7 +215,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure. # Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release # Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA # Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
devx-notify-failure: devx-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \ @. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
@@ -250,22 +292,47 @@ devx-clean:
@find . -type f -name "*.pyc" -delete 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 @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 ───────────────────────────────────────────────────── # ── Pre-built image setup ─────────────────────────────────────────────────────
# #
# When running inside a pre-built Docker runner image (ci-base, ci-quality, # 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 # ci-full), all deps are already installed in /opt/venv. This target links
# the venv and installs the project itself (no-deps, fast). # the venv and installs the project itself (with optional extras).
# Falls back to devx-setup-ci if /opt/venv is not present (local dev). #
# 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: devx-setup-image:
@if [ -d /opt/venv ]; then \ @if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
ln -sf /opt/venv $(DEVX_VENV); \ _U="$${CI_GITEA_USERNAME:-emil}"; \
. $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \ 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; \
echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \ pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else \ echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \ else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
$(MAKE) devx-setup-ci; \
fi
# ── Docker image build / push / cleanup ─────────────────────────────────────── # ── Docker image build / push / cleanup ───────────────────────────────────────
# #
+1
View File
@@ -0,0 +1 @@
"""Molecule testing helpers for Ansible projects."""
+5 -5
View File
@@ -29,7 +29,7 @@ import os
import click import click
import requests import requests
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -142,12 +142,12 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
if repo is None: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "devx") repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
count = get_runner_count(GITEA_API_URL, token, owner, repo) count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count) indices = generate_indices(count)
@@ -156,7 +156,7 @@ def main(
gh_output = os.environ.get("GITHUB_OUTPUT") gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output: if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set") raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123 with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n") f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n") f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}") click.echo(f"Runner count: {count}")
+5 -24
View File
@@ -25,6 +25,7 @@ from pathlib import Path
import click import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _ from devx.i18n import _
from devx.molecule.platforms import PLATFORMS, load_platforms from devx.molecule.platforms import PLATFORMS, load_platforms
@@ -216,22 +217,8 @@ def _scenario_weight(scenario: str, role: str | None = None) -> int:
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]: 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). """Distribute *items* across *max_runners* using LPT (delegates to shared utility)."""
return lpt_distribute(items, weights, max_runners)
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]]: def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
@@ -283,14 +270,8 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
def _write_github_env(key: str, value: str) -> None: def _write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_ENV file.""" """Append a key=value line to the $GITHUB_ENV file (delegates to shared utility)."""
import os write_github_env(key, value)
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command() @click.command()
+6 -5
View File
@@ -22,7 +22,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. 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). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -43,6 +43,7 @@ from pathlib import Path
import click import click
import requests import requests
from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
POLL_INTERVAL = 10 POLL_INTERVAL = 10
@@ -163,17 +164,17 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") 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")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") repository = os.environ.get("GITEA_REPOSITORY", "")
owner, _sep, repo = repository.partition("/") owner, _sep, repo = repository.partition("/")
if not owner or not repo: if not owner or not repo:
owner, repo = "oblachno-oss", "devx" owner, repo = REPO_OWNER, REPO_NAME
if not all([gitea_url, token, run_id]): if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
# When devx is installed as a pip package, __file__ resolves to the # When devx is installed as a pip package, __file__ resolves to the
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE # site-packages directory, not the repo root. Use GITHUB_WORKSPACE
+1 -1
View File
@@ -41,7 +41,7 @@ def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, s
path = Path(platforms_file) path = Path(platforms_file)
if not path.is_file(): if not path.is_file():
return PLATFORMS return PLATFORMS
with path.open() as f: with path.open(encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, list) or not data: if not isinstance(data, list) or not data:
return PLATFORMS return PLATFORMS
+2 -2
View File
@@ -168,7 +168,7 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
click.echo(_("Docker daemon failed to start")) click.echo(_("Docker daemon failed to start"))
click.echo("--- dockerd log ---") click.echo("--- dockerd log ---")
try: try:
with open(log_file.name) as f: with open(log_file.name, encoding="utf-8") as f:
log_content = f.read() log_content = f.read()
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content) click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
except OSError as e: except OSError as e:
@@ -191,7 +191,7 @@ def main(timeout: int) -> None:
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps # Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
github_env = os.environ.get("GITHUB_ENV") github_env = os.environ.get("GITHUB_ENV")
if github_env and os.environ.get("DOCKER_HOST"): if github_env and os.environ.get("DOCKER_HOST"):
with open(github_env, "a") as f: with open(github_env, "a", encoding="utf-8") as f:
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n") f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV") click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
sys.exit(0) sys.exit(0)
+24
View File
@@ -0,0 +1,24 @@
"""Shared utilities for tools modules."""
from __future__ import annotations
import platform
import click
def arch_string() -> str:
"""Return the architecture string used by release assets.
Maps ``platform.machine()`` to the common release asset naming:
``amd64`` for x86_64, ``arm64`` for aarch64.
Raises:
click.ClickException: If the architecture is not supported.
"""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
+7 -12
View File
@@ -34,9 +34,8 @@ The manifest file is a JSON list of dicts, each with:
- ``context``: build context directory (optional, defaults to repo root) - ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``) - ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``) Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_USERNAME``) environment environment variables, matching the existing CI workflow patterns.
variables, matching the existing CI workflow patterns.
""" """
from __future__ import annotations from __future__ import annotations
@@ -89,7 +88,7 @@ def load_manifest(path: str | Path) -> list[ImageSpec]:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
raise click.ClickException(_("Manifest file not found: {path}", path=p)) raise click.ClickException(_("Manifest file not found: {path}", path=p))
with p.open() as f: # noqa: PTH123 with p.open(encoding="utf-8") as f: # noqa: PTH123
data = json.load(f) data = json.load(f)
if not isinstance(data, list): if not isinstance(data, list):
raise click.ClickException(_("Manifest must be a JSON list")) raise click.ClickException(_("Manifest must be a JSON list"))
@@ -221,13 +220,9 @@ def push_image(
def _get_registry_creds() -> tuple[str, str]: def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables. """Get registry credentials from environment variables."""
token = os.environ.get("CI_GITEA_TOKEN", "")
Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and username = os.environ.get("CI_GITEA_USERNAME", "")
REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns.
"""
token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "")
username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "")
return username, token return username, token
@@ -312,7 +307,7 @@ def main(
username, token = _get_registry_creds() username, token = _get_registry_creds()
if not token or not username: if not token or not username:
raise click.ClickException( raise click.ClickException(
_("Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars") _("Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars")
) )
if not registry_login(registry, username, token, dry_run=dry_run): if not registry_login(registry, username, token, dry_run=dry_run):
raise click.ClickException(_("Registry login failed")) raise click.ClickException(_("Registry login failed"))
+2 -4
View File
@@ -13,7 +13,6 @@ Usage::
from __future__ import annotations from __future__ import annotations
import re import re
import sys
import tomllib import tomllib
from pathlib import Path from pathlib import Path
@@ -27,8 +26,7 @@ def cli() -> None:
"""Validate devx configuration in pyproject.toml.""" """Validate devx configuration in pyproject.toml."""
path = Path("pyproject.toml") path = Path("pyproject.toml")
if not path.exists(): if not path.exists():
click.echo(_("pyproject.toml not found in current directory.")) raise click.ClickException(_("pyproject.toml not found in current directory."))
sys.exit(1)
with open(path, "rb") as f: # noqa: PTH123 with open(path, "rb") as f: # noqa: PTH123
data = tomllib.load(f) data = tomllib.load(f)
@@ -65,7 +63,7 @@ def cli() -> None:
if errors: if errors:
for err in errors: for err in errors:
click.echo(f"ERROR: {err}", err=True) click.echo(f"ERROR: {err}", err=True)
sys.exit(1) raise click.ClickException(_("Configuration validation failed."))
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent.")) click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
+21 -33
View File
@@ -23,12 +23,12 @@ Usage::
from __future__ import annotations from __future__ import annotations
import argparse
import fnmatch import fnmatch
import subprocess # nosec B404 import subprocess # nosec B404
import sys
from pathlib import Path from pathlib import Path
import click
from devx.config import _load_pyproject_devx from devx.config import _load_pyproject_devx
from devx.i18n import _ from devx.i18n import _
@@ -203,49 +203,37 @@ def _find_missing_tests(
return missing return missing
def main(argv: list[str] | None = None) -> int: @click.command()
parser = argparse.ArgumentParser( @click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)"))
description=_("Check that changed files have corresponding tests"), @click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0"))
) def cli(staged_only: bool, warn_only: bool) -> None:
parser.add_argument( """Check that changed files have corresponding tests."""
"--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() repo_root = Path.cwd()
rules, skip_patterns, test_indicators, skip_extensions = _load_rules() rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
files = _changed_files(args.staged_only, repo_root) files = _changed_files(staged_only, repo_root)
if not files: if not files:
print(_("[check_test_coverage] No changed files to check.")) click.echo(_("[check_test_coverage] No changed files to check."))
return 0 return
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions) missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
if not missing: if not missing:
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.") click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
return 0 return
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr) click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True)
for f, reason in missing.items(): for f, reason in missing.items():
print(f" {f}", file=sys.stderr) click.echo(f" {f}", err=True)
print(f" -> {reason}", file=sys.stderr) click.echo(f" -> {reason}", err=True)
print( click.echo(
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.", _("\n[check_test_coverage] Fix: add the missing test file(s) before committing."),
file=sys.stderr, err=True,
) )
if args.warn_only: if not warn_only:
return 0 raise click.ClickException(_("Missing tests for changed files."))
return 1
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
sys.exit(main()) cli() # pragma: no cover
+63 -20
View File
@@ -28,18 +28,19 @@ Usage::
--keep 2 \\ --keep 2 \\
--dry-run --dry-run
Authentication uses ``REPO_TOKEN`` environment variable. Authentication uses ``CI_GITEA_TOKEN`` environment variable.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import time
from typing import Any from typing import Any
import click import click
import requests import requests
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
@@ -85,15 +86,37 @@ def delete_package_version(
token: str, token: str,
*, *,
timeout: int = 30, timeout: int = 30,
package_type: str = "container",
max_retries: int = 3,
) -> bool: ) -> bool:
"""Delete a specific version of a container package. """Delete a specific version of a container package.
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
Returns True on success, False on failure. Returns True on success, False on failure.
""" """
url = f"{api_url}/packages/{owner}/{name}/{version}" url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout) for attempt in range(max_retries):
return resp.status_code in (204, 200) try:
resp = requests.delete(url, headers=headers, timeout=timeout)
except requests.RequestException:
if attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
if resp.status_code in (204, 200):
return True
# 404 means already deleted — treat as success
if resp.status_code == 404:
return True
# 5xx is transient — retry
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
time.sleep(2**attempt)
continue
return False
return False
def sort_versions_by_date( def sort_versions_by_date(
@@ -128,8 +151,8 @@ def select_for_deletion(
@click.command() @click.command()
@click.option( @click.option(
"--owner", "--owner",
required=True, default=None,
help="Package owner (user or org).", help="Package owner (user or org, default: from [tool.devx] repo_owner).",
) )
@click.option( @click.option(
"--name", "--name",
@@ -157,24 +180,29 @@ def select_for_deletion(
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).", help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
) )
def main( def main(
owner: str, owner: str | None,
names: tuple[str, ...], names: tuple[str, ...],
keep: int, keep: int,
dry_run: bool, dry_run: bool,
api_url: str | None, api_url: str | None,
) -> None: ) -> None:
"""Clean up old Docker image versions from a Gitea registry.""" """Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("REPO_TOKEN environment variable required")) raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
if not owner:
owner = REPO_OWNER
if not owner:
raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner."))
base_url = api_url or GITEA_API_URL base_url = api_url or GITEA_API_URL
total_deleted = 0 total_deleted = 0
total_kept = 0 total_kept = 0
total_failed = 0
for name in names: for name in names:
click.echo(f"\n{'=' * 60}") click.echo(_("\n{separator}", separator="=" * 60))
click.echo(f"Package: {owner}/{name}") click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
click.echo(f"{'=' * 60}") click.echo(_("{separator}", separator="=" * 60))
try: try:
versions = list_package_versions(base_url, owner, name, token) versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -182,39 +210,54 @@ def main(
_("Failed to list versions for {name}: {error}", name=name, error=exc), _("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True, err=True,
) )
total_failed += 1
continue continue
if not versions: if not versions:
click.echo(_("No versions found.")) click.echo(_("No versions found."))
continue continue
click.echo(f"Found {len(versions)} version(s):") click.echo(_("Found {count} version(s):", count=len(versions)))
for v in sort_versions_by_date(versions): for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})") click.echo(
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
)
to_delete = select_for_deletion(versions, keep) to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete) kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}") click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
if dry_run: if dry_run:
for v in to_delete: for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}") click.echo(_(" [dry-run] Would delete: {version}", version=v.get("version", "?")))
total_kept += kept_count total_kept += kept_count
continue continue
deleted_count = 0 deleted_count = 0
failed_count = 0
for v in to_delete: for v in to_delete:
version = str(v.get("version", "")) version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token): if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}") click.echo(_(" Deleted: {version}", version=version))
deleted_count += 1 deleted_count += 1
else: else:
click.echo(f" FAILED to delete: {version}", err=True) click.echo(_(" FAILED to delete: {version}", version=version), err=True)
failed_count += 1
total_deleted += deleted_count total_deleted += deleted_count
total_kept += kept_count total_kept += kept_count
total_failed += failed_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.") click.echo(
_(
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
deleted=total_deleted,
kept=total_kept,
failed=total_failed,
)
)
if total_failed > 0:
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+14 -7
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. fallback to ``GiteaClient`` if tea is not installed.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo CI_GITEA_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 --owner my-org
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,7 +19,7 @@ from typing import Any, cast
import click import click
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
@@ -50,11 +50,17 @@ def _default_branch_protection_config() -> dict[str, Any]:
"push_whitelist_usernames": [], "push_whitelist_usernames": [],
"enable_status_check": True, "enable_status_check": True,
"status_check_contexts": _default_status_checks(), "status_check_contexts": _default_status_checks(),
"required_approvals": 0, "required_approvals": 1,
"dismiss_stale_approvals": True, "dismiss_stale_approvals": True,
"block_on_outdated_branch": True, "block_on_outdated_branch": True,
"block_on_rejected_reviews": True, "block_on_rejected_reviews": True,
"block_on_official_review_requests": True, "block_on_official_review_requests": True,
# Prevent admins from force-merging PRs that don't meet branch
# protection requirements (e.g. missing approvals). Without this,
# an admin token can bypass the approval gate via force_merge=true,
# allowing merges that failed the auto-merge CI job to reach master
# and trigger the post-merge release pipeline.
"block_admin_merge_override": True,
} }
@@ -102,7 +108,7 @@ def configure_repo(
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config. api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
""" """
if not token: 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 url = api_url or GITEA_API_URL
client = GiteaClient(url, token, owner, repo) client = GiteaClient(url, token, owner, repo)
@@ -123,6 +129,7 @@ def configure_repo(
click.echo(_(" - Dismiss stale approvals: yes")) click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes")) click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes")) click.echo(_(" - Block rejected reviews: yes"))
click.echo(_(" - Block admin merge override: yes"))
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"])) checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks)) click.echo(_(" - Required status checks: {checks}", checks=checks))
@@ -148,10 +155,10 @@ def configure_repo(
) )
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None: 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.""" """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: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
if not repo: if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.")) raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
+17 -22
View File
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
from devx.config import ( from devx.config import (
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
GITEA_API_URL, GITEA_API_URL,
REPO_NAME,
REPO_OWNER, REPO_OWNER,
TASK_ID_RE, TASK_ID_RE,
TASK_PREFIX, TASK_PREFIX,
@@ -46,15 +47,17 @@ load_dotenv()
def get_repo_name() -> str: def get_repo_name() -> str:
"""Auto-detect repository name from env vars or git remote.""" """Auto-detect repository name from env vars, pyproject.toml, or git remote."""
name = os.environ.get("DEVX_REPO_NAME", "") name = os.environ.get("DEVX_REPO_NAME", "")
if name: if name:
return name return name
github_repo = os.environ.get("GITHUB_REPOSITORY", "") github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if github_repo and "/" in github_repo: if github_repo and "/" in github_repo:
return github_repo.split("/", 1)[1] return github_repo.split("/", 1)[1]
if REPO_NAME:
return REPO_NAME
raise click.ClickException( raise click.ClickException(
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."), _("Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."),
) )
@@ -73,24 +76,16 @@ def get_vikunja_task_title(task_id: str) -> str:
if not token: if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
while True: if not task:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE) raise click.ClickException(
if not tasks: _(
break "Could not find Vikunja task {task_id} in project {project_id}.",
matches = [t for t in tasks if t.get("identifier") == task_id] task_id=task_id,
if matches: project_id=VIKUNJA_PROJECT_ID,
return str(matches[0].get("title", "")) ),
if len(tasks) < DEFAULT_PER_PAGE: )
break return str(task.get("title", ""))
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: def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
@@ -123,9 +118,9 @@ def create_pr(
), ),
) )
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR.")) raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
vikunja_title = get_vikunja_task_title(task_id) vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}" pr_title = f"{task_id}: {vikunja_title}"
+40 -28
View File
@@ -27,6 +27,8 @@ from pathlib import Path
import click import click
from devx.i18n import _
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%" # Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%") _COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed") _PASSED_RE = re.compile(r"(\d+) passed")
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
""" """
pkg = detect_package_name(repo_root) pkg = detect_package_name(repo_root)
if pkg is None: if pkg is None:
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'") click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
return "unknown" return "unknown"
init_file = repo_root / "src" / pkg / "__init__.py" init_file = repo_root / "src" / pkg / "__init__.py"
if not init_file.exists(): if not init_file.exists():
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'") click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
return "unknown" return "unknown"
content = init_file.read_text() content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match: if match:
return match.group(1) return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'") click.echo(
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
)
return "unknown" return "unknown"
@@ -278,11 +282,11 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
""" """
cov_target = detect_coverage_target(repo_root) cov_target = detect_coverage_target(repo_root)
if cov_target is None: if cov_target is None:
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)") click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey") return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
testpaths = detect_testpaths(repo_root) testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}") click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
cmd = [ cmd = [
sys.executable, sys.executable,
@@ -302,18 +306,18 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
if coverage is not None: if coverage is not None:
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage)) cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else: else:
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})") click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
cov_badge = make_badge("coverage", "unknown", "red") cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined) test_count = extract_test_count(combined)
if test_count is not None: if test_count is not None:
tests_badge = 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: else:
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})") click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
tests_badge = make_badge("tests", "unknown", "red") tests_badge = make_badge("tests", "unknown", "red")
return cov_badge, tests_badge return cov_badge, tests_badge
@@ -328,8 +332,8 @@ def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
doc_pct = extract_doc_coverage(stdout) doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None: if doc_pct is not None:
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) 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(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
click.echo(f" stderr: {stderr.strip()[:200]}") click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
return make_badge("docs", "unknown", "red") return make_badge("docs", "unknown", "red")
@@ -348,7 +352,7 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
([sys.executable, "-m", "pyright"], "pyright"), ([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"), ([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
]: ]:
rc, _, stderr = run_command(cmd, cwd=repo_root) rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
if rc == 0: if rc == 0:
results.append(True) results.append(True)
tool_names.append(f"{name}: pass") tool_names.append(f"{name}: pass")
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results.append(False) results.append(False)
# Distinguish "tool not installed" from "tool found issues" # Distinguish "tool not installed" from "tool found issues"
if "No module named" in stderr or "not found" in stderr.lower(): if "No module named" in stderr or "not found" in stderr.lower():
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)") click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
results[-1] = True results[-1] = True
tool_names.append(f"{name}: not installed (skipped)") tool_names.append(f"{name}: not installed (skipped)")
else: else:
tool_names.append(f"{name}: FAIL") tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})") click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
click.echo(f" stderr: {stderr.strip()[:200]}") click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
all_pass = all(results) all_pass = all(results)
click.echo(f" Quality checks: {', '.join(tool_names)}") click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red") return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
@@ -377,28 +381,28 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
repo_root: Repository root (auto-detected if None). repo_root: Repository root (auto-detected if None).
""" """
root = repo_root or resolve_repo_root() root = repo_root or resolve_repo_root()
click.echo(f" Repo root: {root}") click.echo(_(" Repo root: {root}", root=root))
pkg = detect_package_name(root) pkg = detect_package_name(root)
click.echo(f" Package: {pkg or 'none'}") click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
badges: dict[str, dict[str, str | int]] = {} badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run) # 1. Code coverage + test count (single pytest-cov run)
click.echo(" Collecting coverage and tests...") click.echo(_(" Collecting coverage and tests..."))
cov_badge, tests_badge = collect_coverage_and_tests(root) cov_badge, tests_badge = collect_coverage_and_tests(root)
badges["coverage"] = cov_badge badges["coverage"] = cov_badge
badges["tests"] = tests_badge badges["tests"] = tests_badge
# 2. Documentation coverage # 2. Documentation coverage
click.echo(" Collecting doc coverage...") click.echo(_(" Collecting doc coverage..."))
badges["docs"] = collect_doc_coverage(root) badges["docs"] = collect_doc_coverage(root)
# 3. Code quality (ruff + pyright + bandit) # 3. Code quality (ruff + pyright + bandit)
click.echo(" Collecting code quality...") click.echo(_(" Collecting code quality..."))
badges["quality"] = collect_quality(root) badges["quality"] = collect_quality(root)
# 4. Version # 4. Version
click.echo(" Collecting version...") click.echo(_(" Collecting version..."))
version = read_version(root) version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue") badges["version"] = make_badge("version", f"v{version}", "blue")
@@ -411,7 +415,7 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"])) svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg" path = output_dir / f"{name}.svg"
path.write_text(svg) path.write_text(svg)
click.echo(f" Generated: {path}") click.echo(_(" Generated: {path}", path=path))
return badges return badges
@@ -431,11 +435,19 @@ def cli(output_dir: str, repo_root: str | None) -> None:
"""Generate self-contained SVG badge files from project metrics.""" """Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir) out = Path(output_dir)
root = Path(repo_root) if repo_root else None root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...") click.echo(_("Generating badges in {out}...", out=out))
badges = generate_badges(out, repo_root=root) badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:") click.echo(_("\nGenerated {count} badges:", count=len(badges)))
for name, badge in badges.items(): for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})") click.echo(
_(
" {name}: {label}={message} ({color})",
name=name,
label=badge["label"],
message=badge["message"],
color=badge["color"],
)
)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+1 -1
View File
@@ -109,7 +109,7 @@ def _generate(prefix: str) -> str:
@click.option( @click.option(
"--prefix", "--prefix",
default=TASK_PREFIX, default=TASK_PREFIX,
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').", help="Task ID prefix for commit preprocessor (default: from [tool.devx] task_prefix in pyproject.toml).",
) )
@click.option( @click.option(
"--output", "--output",
+10 -15
View File
@@ -7,7 +7,6 @@ pre-built Linux binary from the official GitHub releases.
from __future__ import annotations from __future__ import annotations
import platform
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
import urllib.request import urllib.request
@@ -15,6 +14,8 @@ from pathlib import Path
import click import click
from devx.tools._shared import arch_string
CHECKMAKE_VERSION = "0.3.2" CHECKMAKE_VERSION = "0.3.2"
RELEASE_URL_TEMPLATE = ( RELEASE_URL_TEMPLATE = (
"https://github.com/checkmake/checkmake/releases/download/" "https://github.com/checkmake/checkmake/releases/download/"
@@ -23,18 +24,11 @@ RELEASE_URL_TEMPLATE = (
TARGET_PATH = Path("/usr/local/bin/checkmake") TARGET_PATH = Path("/usr/local/bin/checkmake")
def _arch() -> str:
"""Return the architecture string used by checkmake releases."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _install_with_go() -> bool: def _install_with_go() -> bool:
"""Install checkmake using go install if Go is available.""" """Install checkmake using go install if Go is available.
Returns True if the installation succeeded, False if Go is not installed.
"""
go_bin = shutil.which("go") go_bin = shutil.which("go")
if go_bin is None: if go_bin is None:
return False return False
@@ -51,12 +45,13 @@ def _install_with_go() -> bool:
def _download_binary() -> None: def _download_binary() -> None:
"""Download the prebuilt checkmake binary for the current architecture.""" """Download the prebuilt checkmake binary for the current architecture."""
url = RELEASE_URL_TEMPLATE.format(arch=_arch()) url = RELEASE_URL_TEMPLATE.format(arch=arch_string())
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310 urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
TARGET_PATH.chmod(0o755) TARGET_PATH.chmod(0o755)
def main() -> None: @click.command()
def cli() -> None:
"""Install checkmake if not already present.""" """Install checkmake if not already present."""
if shutil.which("checkmake") is not None: if shutil.which("checkmake") is not None:
return return
@@ -66,4 +61,4 @@ def main() -> None:
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover cli() # pragma: no cover
+23 -8
View File
@@ -6,6 +6,7 @@ Handles installation of:
- git-cliff (changelog generator) - git-cliff (changelog generator)
- act_runner (Gitea Actions local runner, optional) - act_runner (Gitea Actions local runner, optional)
- tea (Gitea CLI official command-line tool for Gitea API operations) - 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. Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available. Idempotent: skips tools that are already available.
@@ -39,15 +40,14 @@ ACT_RUNNER_VERSION = "0.2.11"
TEA_VERSION = "0.14.1" TEA_VERSION = "0.14.1"
HADOLINT_VERSION = "2.12.0"
def _arch() -> str: def _arch() -> str:
"""Return the architecture string used by release assets.""" """Return the architecture string used by release assets (delegates to shared utility)."""
machine = platform.machine().lower() from devx.tools._shared import arch_string
if machine in {"x86_64", "amd64"}:
return "amd64" return arch_string()
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _ensure_target_dir() -> Path: def _ensure_target_dir() -> Path:
@@ -161,7 +161,20 @@ def install_tea() -> bool:
return True 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: def _install_tool(name: str) -> bool:
@@ -174,6 +187,8 @@ def _install_tool(name: str) -> bool:
return install_act_runner() return install_act_runner()
if name == "tea": if name == "tea":
return install_tea() return install_tea()
if name == "hadolint":
return install_hadolint()
raise click.ClickException(f"Unknown tool: {name}") raise click.ClickException(f"Unknown tool: {name}")
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Add a label to a pull request (idempotent).
Commonly used to add the ``ready-to-merge`` label after CI passes and
review is complete. The operation is idempotent if the label is already
attached, it succeeds without error.
Usage::
# Add ready-to-merge to PR #42
python -m devx.tools.pr_label --pr 42 --label ready-to-merge
# Add label to current branch's PR
python -m devx.tools.pr_label --label ready-to-merge
# Add multiple labels
python -m devx.tools.pr_label --pr 42 --label ready-to-merge --label reviewed
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr
load_dotenv()
@click.command()
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
@click.option("--label", "labels", multiple=True, required=True, help="Label name(s) to add (can be repeated).")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(
pr_number: int | None,
labels: tuple[str, ...],
owner: str | None,
repo: str | None,
) -> None:
"""Add one or more labels to a pull request (idempotent)."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
if pr_number is None:
pr_number = _get_current_branch_pr(client)
label_list = list(labels)
existing = client.get_pr_label_names(pr_number)
to_add = [lbl for lbl in label_list if lbl not in existing]
already = [lbl for lbl in label_list if lbl in existing]
if already:
for lbl in already:
click.echo(_("Label '{label}' already on PR #{pr}.", label=lbl, pr=pr_number))
if to_add:
client.add_pr_label(pr_number, to_add)
for lbl in to_add:
click.echo(_("Added label '{label}' to PR #{pr}.", label=lbl, pr=pr_number))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Fetch logs for failed CI jobs on a pull request.
Lists CI jobs for the latest workflow run of a PR's branch, then fetches
and prints the logs of any failed jobs. Useful for diagnosing CI failures
without navigating the web UI.
Usage::
# Show failed job logs for PR #42
python -m devx.tools.pr_logs --pr 42
# Show failed job logs for current branch's PR
python -m devx.tools.pr_logs
# Show logs for a specific job (by name)
python -m devx.tools.pr_logs --pr 42 --job quality
# Show last N lines of each failed job's logs
python -m devx.tools.pr_logs --pr 42 --tail 50
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr
load_dotenv()
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
"""Fetch the head SHA of a PR."""
pr = client.get_pr(pr_number)
return pr.get("head", {}).get("sha", "")
def _find_latest_run_by_sha(client: GiteaClient, sha: str) -> dict | None:
"""Find the latest workflow run for a commit SHA.
Gitea Actions API doesn't set head_branch for pull_request events,
so we filter by head_sha instead.
"""
data = client.list_action_runs(limit=50)
for run in data.get("workflow_runs", []):
if run.get("head_sha", "").startswith(sha):
return run
return None
def _find_failed_jobs(jobs: list[dict]) -> list[dict]:
"""Return jobs with conclusion 'failure'."""
return [j for j in jobs if j.get("conclusion") == "failure"]
def _find_job_by_name(jobs: list[dict], name: str) -> dict | None:
"""Find a job by name (case-insensitive partial match)."""
name_lower = name.lower()
for j in jobs:
if name_lower in j.get("name", "").lower():
return j
return None
def _print_job_summary(jobs: list[dict]) -> None:
"""Print a summary table of all jobs and their status."""
for j in jobs:
name = j.get("name", "?")
conclusion = j.get("conclusion", "pending")
status = j.get("status", "?")
symbol = "[FAIL]" if conclusion == "failure" else "[OK]" if conclusion == "success" else f"[{conclusion}]"
click.echo(f" {symbol} {name} (status: {status}, conclusion: {conclusion})")
def _print_failed_steps(job: dict) -> list[int]:
"""Print failed steps for a job. Returns list of failed step numbers."""
failed_steps = []
for step in job.get("steps", []):
if step.get("conclusion") == "failure":
name = step.get("name", "?")
num = step.get("number", "?")
click.echo(f" FAILED step #{num}: {name}")
failed_steps.append(num)
return failed_steps
def _print_logs(client: GiteaClient, job_id: int, tail: int = 0) -> None:
"""Fetch and print logs for a job. If tail > 0, print only last N lines."""
try:
logs = client.get_action_job_logs(job_id)
except APIError as e:
click.echo(_(" Could not fetch logs: {error}", error=str(e)))
return
if tail > 0:
lines = logs.strip().split("\n")
if len(lines) > tail:
click.echo(f" ... (showing last {tail} of {len(lines)} lines)")
logs = "\n".join(lines[-tail:])
for line in logs.split("\n"):
click.echo(f" {line}")
@click.command()
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
@click.option("--job", default=None, help="Job name to show logs for (partial match, case-insensitive).")
@click.option("--tail", type=int, default=80, show_default=True, help="Show last N lines of logs (0 = all).")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(
pr_number: int | None,
job: str | None,
tail: int,
owner: str | None,
repo: str | None,
) -> None:
"""Fetch logs for failed CI jobs on a pull request."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
if pr_number is None:
pr_number = _get_current_branch_pr(client)
click.echo(_("Fetching logs for PR #{pr_number}...", pr_number=pr_number))
sha = _get_pr_sha(client, pr_number)
if not sha:
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
run = _find_latest_run_by_sha(client, sha)
if not run:
raise click.ClickException(_("No workflow runs found for SHA {sha}.", sha=sha[:8]))
run_id = run.get("id", 0)
run_status = run.get("status", "?")
click.echo(_("Latest run: #{run_id} (status: {status})", run_id=run_id, status=run_status))
click.echo("")
jobs = client.get_action_run_jobs(run_id)
if not jobs:
click.echo(_("No jobs found for run #{run_id}.", run_id=run_id))
return
_print_job_summary(jobs)
click.echo("")
if job:
target = _find_job_by_name(jobs, job)
if not target:
raise click.ClickException(_("No job matching '{job}' found.", job=job))
click.echo(f"Logs for job '{target.get('name', '?')}' (id={target.get('id')}):")
_print_failed_steps(target)
click.echo("")
_print_logs(client, target["id"], tail)
else:
failed = _find_failed_jobs(jobs)
if not failed:
click.echo(_("No failed jobs."))
return
for fj in failed:
click.echo(f"Logs for failed job '{fj.get('name', '?')}' (id={fj.get('id')}):")
_print_failed_steps(fj)
click.echo("")
_print_logs(client, fj["id"], tail)
click.echo("")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Check CI status for a pull request or commit.
Displays the status of all CI checks for a PR (or a specific commit SHA).
Optionally polls until all checks complete (``--wait``).
Usage::
# Check status of PR #42
python -m devx.tools.pr_status --pr 42
# Check status of current branch's PR
python -m devx.tools.pr_status
# Wait for all checks to complete (timeout 600s)
python -m devx.tools.pr_status --pr 42 --wait --timeout 600
# Check a specific commit SHA
python -m devx.tools.pr_status --sha abc1234
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _
from devx.tools.create_pr import get_repo_name
load_dotenv()
# Status symbols for terminal output
_STATUS_SYMBOLS = {
"success": "[OK]",
"failure": "[FAIL]",
"error": "[FAIL]",
"pending": "[..]",
"skipped": "[SKIP]",
"none": "[--]",
}
def _get_symbol(status: str) -> str:
return _STATUS_SYMBOLS.get(status, f"[{status}]")
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
"""Fetch the head SHA of a PR."""
pr = client.get_pr(pr_number)
return pr.get("head", {}).get("sha", "")
def _get_current_branch_pr(client: GiteaClient) -> int:
"""Find the open PR for the current git branch."""
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(_("Could not detect current branch: {error}", error=result.stderr.strip()))
branch = result.stdout.strip()
prs = client.list_prs(state="open")
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return int(pr["number"])
raise click.ClickException(_("No open PR found for branch '{branch}'.", branch=branch))
def print_status(client: GiteaClient, sha: str) -> str:
"""Print CI check statuses for a commit SHA. Returns the overall state."""
statuses = client.get_commit_status(sha)
if not statuses:
click.echo(_("No CI checks found for commit {sha}.", sha=sha[:8]))
return "none"
overall = "success"
for s in statuses:
context = s.get("context", "?")
status = s.get("status", "pending")
symbol = _get_symbol(status)
click.echo(f" {symbol} {context}")
if status in ("failure", "error"):
overall = "failure"
elif status == "pending" and overall != "failure":
overall = "pending"
elif status == "skipped" and overall == "success":
overall = "success"
click.echo(f"\n Overall: {_get_symbol(overall)} {overall}")
return overall
def wait_for_completion(
client: GiteaClient,
sha: str,
timeout: int = 600,
interval: int = 30,
) -> str:
"""Poll CI status until all checks complete or timeout. Returns final state."""
click.echo(_("Waiting for CI checks to complete (timeout: {timeout}s)...", timeout=timeout))
deadline = time.time() + timeout
while time.time() < deadline:
state = print_status(client, sha)
if state in ("success", "failure", "error", "none"):
return state
click.echo(f" ...still pending, retrying in {interval}s\n")
time.sleep(interval)
click.echo(_("Timeout reached after {timeout}s.", timeout=timeout))
return "pending"
@click.command()
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
@click.option("--sha", default=None, help="Commit SHA to check (alternative to --pr).")
@click.option("--wait", "do_wait", is_flag=True, help="Poll until all checks complete.")
@click.option("--timeout", type=int, default=600, show_default=True, help="Wait timeout in seconds.")
@click.option("--interval", type=int, default=30, show_default=True, help="Poll interval in seconds.")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(
pr_number: int | None,
sha: str | None,
do_wait: bool,
timeout: int,
interval: int,
owner: str | None,
repo: str | None,
) -> None:
"""Check CI status for a pull request or commit."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
if sha is None:
if pr_number is None:
pr_number = _get_current_branch_pr(client)
click.echo(_("Checking status for PR #{pr_number}...", pr_number=pr_number))
pr = client.get_pr(pr_number)
sha = pr.get("head", {}).get("sha", "")
if not sha:
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
click.echo(_("Commit: {sha}", sha=sha[:12]))
click.echo("")
state = wait_for_completion(client, sha, timeout, interval) if do_wait else print_status(client, sha)
if state in ("failure", "error"):
raise click.ClickException(_("CI checks failed."))
if state == "pending" and do_wait:
raise click.ClickException(_("CI checks did not complete within timeout."))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+1 -11
View File
@@ -59,17 +59,7 @@ def task_exists(task_id: str) -> bool:
if not token: if not token:
return False return False
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
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: def validate(branch: str) -> None:
+4 -4
View File
@@ -64,19 +64,19 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> 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. 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") tea_bin = shutil.which("tea")
if tea_bin is None: if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.") click.echo("tea: not installed — run 'make install-tools' to install it.")
return return
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not 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 return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
+857 -65
View File
@@ -55,6 +55,14 @@
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
}, },
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"de": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"en": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"pl": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"ru": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"zh": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}."
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
@@ -71,6 +79,14 @@
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report." "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
}, },
"\nGenerated {count} badges:": {
"bg": "\nGenerated {count} badges:",
"de": "\nGenerated {count} badges:",
"en": "\nGenerated {count} badges:",
"pl": "\nGenerated {count} badges:",
"ru": "\nGenerated {count} badges:",
"zh": "\nGenerated {count} badges:"
},
"\nIntegrity check FAILED ({count} issues):": { "\nIntegrity check FAILED ({count} issues):": {
"bg": "\nIntegrity check FAILED ({count} issues):", "bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):", "de": "\nIntegrity check FAILED ({count} issues):",
@@ -87,6 +103,14 @@
"ru": "\nIntegrity check passed — all {count} pages verified.", "ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified." "zh": "\nIntegrity check passed — all {count} pages verified."
}, },
"\nKeeping {kept}, would delete {count}": {
"bg": "\nKeeping {kept}, would delete {count}",
"de": "\nKeeping {kept}, would delete {count}",
"en": "\nKeeping {kept}, would delete {count}",
"pl": "\nKeeping {kept}, would delete {count}",
"ru": "\nKeeping {kept}, would delete {count}",
"zh": "\nKeeping {kept}, would delete {count}"
},
"\nLatest tag: {tag}": { "\nLatest tag: {tag}": {
"bg": "\nLatest tag: {tag}", "bg": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}", "de": "\nLatest tag: {tag}",
@@ -119,6 +143,14 @@
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"\nRunning full wiki integrity check...": { "\nRunning full wiki integrity check...": {
"bg": "\nRunning full wiki integrity check...", "bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...", "de": "\nRunning full wiki integrity check...",
@@ -183,6 +215,14 @@
"ru": "\nWorkflow-only changes ({count}):", "ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):" "zh": "\nWorkflow-only changes ({count}):"
}, },
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
},
"\n[dry-run] Changelog:\n{changelog}": { "\n[dry-run] Changelog:\n{changelog}": {
"bg": "\n[dry-run] Changelog:\n{changelog}", "bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}", "de": "\n[dry-run] Changelog:\n{changelog}",
@@ -199,6 +239,14 @@
"ru": "\n{label} files changed ({count}):", "ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):" "zh": "\n{label} files changed ({count}):"
}, },
"\n{separator}": {
"bg": "\n{separator}",
"de": "\n{separator}",
"en": "\n{separator}",
"pl": "\n{separator}",
"ru": "\n{separator}",
"zh": "\n{separator}"
},
"\n{tag} files ({count}):": { "\n{tag} files ({count}):": {
"bg": "\n{tag} files ({count}):", "bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):", "de": "\n{tag} files ({count}):",
@@ -207,6 +255,38 @@
"ru": "\n{tag} files ({count}):", "ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):" "zh": "\n{tag} files ({count}):"
}, },
" Could not fetch logs: {error}": {
"en": " Could not fetch logs: {error}",
"bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}"
},
" pytest stderr (last 300 chars): {stderr}": {
"bg": " pytest stderr (last 300 chars): {stderr}",
"de": " pytest stderr (last 300 chars): {stderr}",
"en": " pytest stderr (last 300 chars): {stderr}",
"pl": " pytest stderr (last 300 chars): {stderr}",
"ru": " pytest stderr (last 300 chars): {stderr}",
"zh": " pytest stderr (last 300 chars): {stderr}"
},
" pytest stdout (last 300 chars): {stdout}": {
"bg": " pytest stdout (last 300 chars): {stdout}",
"de": " pytest stdout (last 300 chars): {stdout}",
"en": " pytest stdout (last 300 chars): {stdout}",
"pl": " pytest stdout (last 300 chars): {stdout}",
"ru": " pytest stdout (last 300 chars): {stdout}",
"zh": " pytest stdout (last 300 chars): {stdout}"
},
" stderr: {stderr}": {
"bg": " stderr: {stderr}",
"de": " stderr: {stderr}",
"en": " stderr: {stderr}",
"pl": " stderr: {stderr}",
"ru": " stderr: {stderr}",
"zh": " stderr: {stderr}"
},
" - Auto-delete branch after merge: yes": { " - Auto-delete branch after merge: yes": {
"bg": " - Автоматично изтриване на клон след сливане: да", "bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja", "de": " - Branch nach Merge automatisch löschen: ja",
@@ -215,6 +295,14 @@
"ru": " - Автоудаление ветки после слияния: да", "ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是" "zh": " - 合并后自动删除分支: 是"
}, },
" - Block admin merge override: yes": {
"bg": " - Блокиране на admin merge override: да",
"de": " - Admin-Merge-Override blockieren: ja",
"en": " - Block admin merge override: yes",
"pl": " - Blokuj admin merge override: tak",
"ru": " - Блокировать admin merge override: да",
"zh": " - 阻止管理员合并覆盖:是"
},
" - Block outdated branches: yes": { " - Block outdated branches: yes": {
"bg": " - Блокиране на остарели клонове: да", "bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja", "de": " - Veraltete Branches blockieren: ja",
@@ -263,6 +351,38 @@
"ru": " - Требуемые проверки статуса: {checks}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}" "zh": " - 必需状态检查: {checks}"
}, },
" Collecting code quality...": {
"bg": " Collecting code quality...",
"de": " Collecting code quality...",
"en": " Collecting code quality...",
"pl": " Collecting code quality...",
"ru": " Collecting code quality...",
"zh": " Collecting code quality..."
},
" Collecting coverage and tests...": {
"bg": " Collecting coverage and tests...",
"de": " Collecting coverage and tests...",
"en": " Collecting coverage and tests...",
"pl": " Collecting coverage and tests...",
"ru": " Collecting coverage and tests...",
"zh": " Collecting coverage and tests..."
},
" Collecting doc coverage...": {
"bg": " Collecting doc coverage...",
"de": " Collecting doc coverage...",
"en": " Collecting doc coverage...",
"pl": " Collecting doc coverage...",
"ru": " Collecting doc coverage...",
"zh": " Collecting doc coverage..."
},
" Collecting version...": {
"bg": " Collecting version...",
"de": " Collecting version...",
"en": " Collecting version...",
"pl": " Collecting version...",
"ru": " Collecting version...",
"zh": " Collecting version..."
},
" Created: {title}": { " Created: {title}": {
"bg": " Created: {title}", "bg": " Created: {title}",
"de": " Created: {title}", "de": " Created: {title}",
@@ -271,6 +391,14 @@
"ru": " Created: {title}", "ru": " Created: {title}",
"zh": " Created: {title}" "zh": " Created: {title}"
}, },
" Deleted: {version}": {
"bg": " Deleted: {version}",
"de": " Deleted: {version}",
"en": " Deleted: {version}",
"pl": " Deleted: {version}",
"ru": " Deleted: {version}",
"zh": " Deleted: {version}"
},
" FAIL: {title} — content mismatch or empty!": { " FAIL: {title} — content mismatch or empty!": {
"bg": " FAIL: {title} — content mismatch or empty!", "bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!", "de": " FAIL: {title} — content mismatch or empty!",
@@ -279,6 +407,22 @@
"ru": " FAIL: {title} — content mismatch or empty!", "ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!" "zh": " FAIL: {title} — content mismatch or empty!"
}, },
" FAILED to delete: {version}": {
"bg": " FAILED to delete: {version}",
"de": " FAILED to delete: {version}",
"en": " FAILED to delete: {version}",
"pl": " FAILED to delete: {version}",
"ru": " FAILED to delete: {version}",
"zh": " FAILED to delete: {version}"
},
" Generated: {path}": {
"bg": " Generated: {path}",
"de": " Generated: {path}",
"en": " Generated: {path}",
"pl": " Generated: {path}",
"ru": " Generated: {path}",
"zh": " Generated: {path}"
},
" MISSING: devx {cmd}": { " MISSING: devx {cmd}": {
"bg": " ЛИПСВА: devx {cmd}", "bg": " ЛИПСВА: devx {cmd}",
"de": " FEHLT: devx {cmd}", "de": " FEHLT: devx {cmd}",
@@ -335,6 +479,38 @@
"ru": " OK: {title} ({chars} chars)", "ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)" "zh": " OK: {title} ({chars} chars)"
}, },
" Package: {pkg}": {
"bg": " Package: {pkg}",
"de": " Package: {pkg}",
"en": " Package: {pkg}",
"pl": " Package: {pkg}",
"ru": " Package: {pkg}",
"zh": " Package: {pkg}"
},
" Quality checks: {checks}": {
"bg": " Quality checks: {checks}",
"de": " Quality checks: {checks}",
"en": " Quality checks: {checks}",
"pl": " Quality checks: {checks}",
"ru": " Quality checks: {checks}",
"zh": " Quality checks: {checks}"
},
" Repo root: {root}": {
"bg": " Repo root: {root}",
"de": " Repo root: {root}",
"en": " Repo root: {root}",
"pl": " Repo root: {root}",
"ru": " Repo root: {root}",
"zh": " Repo root: {root}"
},
" Test paths: {testpaths}": {
"bg": " Test paths: {testpaths}",
"de": " Test paths: {testpaths}",
"en": " Test paths: {testpaths}",
"pl": " Test paths: {testpaths}",
"ru": " Test paths: {testpaths}",
"zh": " Test paths: {testpaths}"
},
" Updated: {title}": { " Updated: {title}": {
"bg": " Updated: {title}", "bg": " Updated: {title}",
"de": " Updated: {title}", "de": " Updated: {title}",
@@ -343,6 +519,118 @@
"ru": " Updated: {title}", "ru": " Updated: {title}",
"zh": " Updated: {title}" "zh": " Updated: {title}"
}, },
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"de": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"en": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"pl": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"ru": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"zh": " WARNING: Could not extract coverage from pytest output (rc={rc})"
},
" WARNING: Could not extract doc coverage (rc={rc})": {
"bg": " WARNING: Could not extract doc coverage (rc={rc})",
"de": " WARNING: Could not extract doc coverage (rc={rc})",
"en": " WARNING: Could not extract doc coverage (rc={rc})",
"pl": " WARNING: Could not extract doc coverage (rc={rc})",
"ru": " WARNING: Could not extract doc coverage (rc={rc})",
"zh": " WARNING: Could not extract doc coverage (rc={rc})"
},
" WARNING: Could not extract test count from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract test count from pytest output (rc={rc})",
"de": " WARNING: Could not extract test count from pytest output (rc={rc})",
"en": " WARNING: Could not extract test count from pytest output (rc={rc})",
"pl": " WARNING: Could not extract test count from pytest output (rc={rc})",
"ru": " WARNING: Could not extract test count from pytest output (rc={rc})",
"zh": " WARNING: Could not extract test count from pytest output (rc={rc})"
},
" WARNING: No Python package found under src/ — version badge will show 'unknown'": {
"bg": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"de": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"en": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"pl": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"ru": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"zh": " WARNING: No Python package found under src/ — version badge will show 'unknown'"
},
" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'": {
"bg": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"de": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"en": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"pl": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"ru": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"zh": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'"
},
" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)": {
"bg": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"de": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"en": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"pl": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"ru": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"zh": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"
},
" WARNING: {init_file} not found — version badge will show 'unknown'": {
"bg": " WARNING: {init_file} not found — version badge will show 'unknown'",
"de": " WARNING: {init_file} not found — version badge will show 'unknown'",
"en": " WARNING: {init_file} not found — version badge will show 'unknown'",
"pl": " WARNING: {init_file} not found — version badge will show 'unknown'",
"ru": " WARNING: {init_file} not found — version badge will show 'unknown'",
"zh": " WARNING: {init_file} not found — version badge will show 'unknown'"
},
" WARNING: {name} failed (rc={rc})": {
"bg": " WARNING: {name} failed (rc={rc})",
"de": " WARNING: {name} failed (rc={rc})",
"en": " WARNING: {name} failed (rc={rc})",
"pl": " WARNING: {name} failed (rc={rc})",
"ru": " WARNING: {name} failed (rc={rc})",
"zh": " WARNING: {name} failed (rc={rc})"
},
" WARNING: {name} not installed — skipping (counted as pass)": {
"bg": " WARNING: {name} not installed — skipping (counted as pass)",
"de": " WARNING: {name} not installed — skipping (counted as pass)",
"en": " WARNING: {name} not installed — skipping (counted as pass)",
"pl": " WARNING: {name} not installed — skipping (counted as pass)",
"ru": " WARNING: {name} not installed — skipping (counted as pass)",
"zh": " WARNING: {name} not installed — skipping (counted as pass)"
},
" [dry-run] Would delete: {version}": {
"bg": " [dry-run] Would delete: {version}",
"de": " [dry-run] Would delete: {version}",
"en": " [dry-run] Would delete: {version}",
"pl": " [dry-run] Would delete: {version}",
"ru": " [dry-run] Would delete: {version}",
"zh": " [dry-run] Would delete: {version}"
},
" {name}: {label}={message} ({color})": {
"bg": " {name}: {label}={message} ({color})",
"de": " {name}: {label}={message} ({color})",
"en": " {name}: {label}={message} ({color})",
"pl": " {name}: {label}={message} ({color})",
"ru": " {name}: {label}={message} ({color})",
"zh": " {name}: {label}={message} ({color})"
},
" {version} (created: {created})": {
"bg": " {version} (created: {created})",
"de": " {version} (created: {created})",
"en": " {version} (created: {created})",
"pl": " {version} (created: {created})",
"ru": " {version} (created: {created})",
"zh": " {version} (created: {created})"
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
},
"--checklist-confirmed is required for APPROVE events.": {
"en": "--checklist-confirmed is required for APPROVE events.",
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
},
"--push requires --registry": { "--push requires --registry": {
"bg": "--push requires --registry", "bg": "--push requires --registry",
"de": "--push requires --registry", "de": "--push requires --registry",
@@ -375,6 +663,14 @@
"ru": "API poll warning: {exc}", "ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}" "zh": "API poll warning: {exc}"
}, },
"Added label '{label}' to PR #{pr}.": {
"en": "Added label '{label}' to PR #{pr}.",
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
"pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": { "Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.", "bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.", "de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
@@ -399,6 +695,54 @@
"ru": "Another molecule runner failed. Stopping this runner early.", "ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early." "zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Assigned {count} files to runner {runner_index}": {
"bg": "Assigned {count} files to runner {runner_index}",
"de": "Assigned {count} files to runner {runner_index}",
"en": "Assigned {count} files to runner {runner_index}",
"pl": "Assigned {count} files to runner {runner_index}",
"ru": "Assigned {count} files to runner {runner_index}",
"zh": "Assigned {count} files to runner {runner_index}"
},
"Assigned {count} items to runner {runner_index}: {encoded}": {
"bg": "Assigned {count} items to runner {runner_index}: {encoded}",
"de": "Assigned {count} items to runner {runner_index}: {encoded}",
"en": "Assigned {count} items to runner {runner_index}: {encoded}",
"pl": "Assigned {count} items to runner {runner_index}: {encoded}",
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
},
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"en": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"pl": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"ru": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"zh": "Badge push attempt {attempt}/{retries} failed — retrying: {error}"
},
"Badge push failed after {retries} attempts: {error}": {
"bg": "Badge push failed after {retries} attempts: {error}",
"de": "Badge push failed after {retries} attempts: {error}",
"en": "Badge push failed after {retries} attempts: {error}",
"pl": "Badge push failed after {retries} attempts: {error}",
"ru": "Badge push failed after {retries} attempts: {error}",
"zh": "Badge push failed after {retries} attempts: {error}"
},
"Badges commit SHA: {sha}": {
"bg": "Badges commit SHA: {sha}",
"de": "Badges commit SHA: {sha}",
"en": "Badges commit SHA: {sha}",
"pl": "Badges commit SHA: {sha}",
"ru": "Badges commit SHA: {sha}",
"zh": "Badges commit SHA: {sha}"
},
"Badges pushed to badges branch": {
"bg": "Badges pushed to badges branch",
"de": "Badges pushed to badges branch",
"en": "Badges pushed to badges branch",
"pl": "Badges pushed to badges branch",
"ru": "Badges pushed to badges branch",
"zh": "Badges pushed to badges branch"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
@@ -463,13 +807,53 @@
"ru": "Bumping version: {current} -> v{new_version}", "ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}" "zh": "Bumping version: {current} -> v{new_version}"
}, },
"Check that changed files have corresponding tests": { "CI checks did not complete within timeout.": {
"bg": "Check that changed files have corresponding tests", "en": "CI checks did not complete within timeout.",
"de": "Check that changed files have corresponding tests", "bg": "CI checks did not complete within timeout.",
"en": "Check that changed files have corresponding tests", "de": "CI checks did not complete within timeout.",
"pl": "Check that changed files have corresponding tests", "pl": "CI checks did not complete within timeout.",
"ru": "Check that changed files have corresponding tests", "ru": "CI checks did not complete within timeout.",
"zh": "Check that changed files have corresponding tests" "zh": "CI checks did not complete within timeout."
},
"CI checks failed.": {
"en": "CI checks failed.",
"bg": "CI checks failed.",
"de": "CI checks failed.",
"pl": "CI checks failed.",
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"CI_GITEA_TOKEN is not set.": {
"en": "CI_GITEA_TOKEN is not set.",
"bg": "CI_GITEA_TOKEN is not set.",
"de": "CI_GITEA_TOKEN is not set.",
"pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
}, },
"Checking CLI command documentation...": { "Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...", "bg": "Checking CLI command documentation...",
@@ -479,6 +863,14 @@
"ru": "Checking CLI command documentation...", "ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..." "zh": "Checking CLI command documentation..."
}, },
"Checking status for PR #{pr_number}...": {
"en": "Checking status for PR #{pr_number}...",
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..."
},
"Command failed ({cmd}): {stderr}": { "Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}", "bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}", "de": "Command failed ({cmd}): {stderr}",
@@ -487,6 +879,22 @@
"ru": "Command failed ({cmd}): {stderr}", "ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}" "zh": "Command failed ({cmd}): {stderr}"
}, },
"Commit message: {msg}": {
"bg": "Commit message: {msg}",
"de": "Commit message: {msg}",
"en": "Commit message: {msg}",
"pl": "Commit message: {msg}",
"ru": "Commit message: {msg}",
"zh": "Commit message: {msg}"
},
"Commit: {sha}": {
"en": "Commit: {sha}",
"bg": "Commit: {sha}",
"de": "Commit: {sha}",
"pl": "Commit: {sha}",
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
},
"Comparing {base}..{head} ({count} files changed)": { "Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)", "bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)", "de": "Comparing {base}..{head} ({count} files changed)",
@@ -503,6 +911,14 @@
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", "ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
}, },
"Configuration validation failed.": {
"en": "Configuration validation failed.",
"bg": "Configuration validation failed.",
"de": "Configuration validation failed.",
"pl": "Configuration validation failed.",
"ru": "Configuration validation failed.",
"zh": "Configuration validation failed."
},
"Configuring branch protection for {branch}...": { "Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...", "bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...", "de": "Konfiguriere Branch-Schutz für {branch}...",
@@ -519,6 +935,14 @@
"ru": "Настройка параметров репозитория...", "ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..." "zh": "正在配置仓库设置..."
}, },
"Configuring tea login '{name}' for {url}...": {
"bg": "Configuring tea login '{name}' for {url}...",
"de": "Configuring tea login '{name}' for {url}...",
"en": "Configuring tea login '{name}' for {url}...",
"pl": "Configuring tea login '{name}' for {url}...",
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
},
"Could not detect current branch: {error}": { "Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}", "bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}", "de": "Aktueller Branch konnte nicht erkannt werden: {error}",
@@ -527,6 +951,14 @@
"ru": "Не удалось определить текущую ветку: {error}", "ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}" "zh": "无法检测当前分支: {error}"
}, },
"Could not determine head SHA for PR #{pr_number}.": {
"en": "Could not determine head SHA for PR #{pr_number}.",
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
"pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
},
"Could not extract conventional commit message from PR commits.": { "Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.", "bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.", "de": "Could not extract conventional commit message from PR commits.",
@@ -535,13 +967,13 @@
"ru": "Could not extract conventional commit message from PR commits.", "ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits." "zh": "Could not extract conventional commit message from PR commits."
}, },
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": { "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", "bg": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", "de": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", "en": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", "pl": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", "ru": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)." "zh": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found)."
}, },
"Could not find Vikunja task {task_id} in project {project_id}.": { "Could not find Vikunja task {task_id} in project {project_id}.": {
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.", "bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
@@ -655,13 +1087,13 @@
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
}, },
"ERROR: REPO_TOKEN is not set.": { "ERROR: CI_GITEA_TOKEN is not set.": {
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.", "bg": "ГРЕШКА: CI_GITEA_TOKEN не е зададен.",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.", "de": "FEHLER: CI_GITEA_TOKEN ist nicht gesetzt.",
"en": "ERROR: REPO_TOKEN is not set.", "en": "ERROR: CI_GITEA_TOKEN is not set.",
"pl": "BŁĄD: REPO_TOKEN nie jest ustawiony.", "pl": "BŁĄD: CI_GITEA_TOKEN nie jest ustawiony.",
"ru": "ОШИБКА: REPO_TOKEN не задан.", "ru": "ОШИБКА: CI_GITEA_TOKEN не задан.",
"zh": "错误:未设置 REPO_TOKEN。" "zh": "错误:未设置 CI_GITEA_TOKEN。"
}, },
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
@@ -695,6 +1127,14 @@
"ru": "ERROR: mapping.json not found at {path}", "ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}" "zh": "ERROR: mapping.json not found at {path}"
}, },
"Each item must be a string or an object with 'id', got {type}": {
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
"en": "Each item must be a string or an object with 'id', got {type}",
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
},
"FAILED: {count} undocumented dependency/ies": { "FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies", "bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies", "de": "FAILED: {count} undocumented dependency/ies",
@@ -727,6 +1167,14 @@
"ru": "Failed to create issue via tea: {error}", "ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}" "zh": "Failed to create issue via tea: {error}"
}, },
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"Failed to list versions for {name}: {error}": { "Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}", "bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}", "de": "Failed to list versions for {name}: {error}",
@@ -735,6 +1183,14 @@
"ru": "Failed to list versions for {name}: {error}", "ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}" "zh": "Failed to list versions for {name}: {error}"
}, },
"Fetching logs for PR #{pr_number}...": {
"en": "Fetching logs for PR #{pr_number}...",
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
},
"Found {count} existing wiki pages.": { "Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.", "bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.", "de": "Found {count} existing wiki pages.",
@@ -759,13 +1215,29 @@
"ru": "Found {count} stale documentation reference(s)", "ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)" "zh": "Found {count} stale documentation reference(s)"
}, },
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "Found {count} version(s):": {
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "bg": "Found {count} version(s):",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "Found {count} version(s):",
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "en": "Found {count} version(s):",
"pl": "GITEA_URL/REPO_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.", "pl": "Found {count} version(s):",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "ru": "Found {count} version(s):",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." "zh": "Found {count} version(s):"
},
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"en": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"pl": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
},
"Generated {count} badge files": {
"bg": "Generated {count} badge files",
"de": "Generated {count} badge files",
"en": "Generated {count} badge files",
"pl": "Generated {count} badge files",
"ru": "Generated {count} badge files",
"zh": "Generated {count} badge files"
}, },
"Generated {file} with prefix '{prefix}'.": { "Generated {file} with prefix '{prefix}'.": {
"bg": "Generated {file} with prefix '{prefix}'.", "bg": "Generated {file} with prefix '{prefix}'.",
@@ -775,6 +1247,14 @@
"ru": "Generated {file} with prefix '{prefix}'.", "ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'." "zh": "Generated {file} with prefix '{prefix}'."
}, },
"Generating badges in {out}...": {
"bg": "Generating badges in {out}...",
"de": "Generating badges in {out}...",
"en": "Generating badges in {out}...",
"pl": "Generating badges in {out}...",
"ru": "Generating badges in {out}...",
"zh": "Generating badges in {out}..."
},
"Gitea PyPI registry: {tag} already published — continuing.": { "Gitea PyPI registry: {tag} already published — continuing.": {
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.", "bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.", "de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
@@ -903,6 +1383,38 @@
"ru": "Integration tests passed.", "ru": "Integration tests passed.",
"zh": "Integration tests passed." "zh": "Integration tests passed."
}, },
"Invalid checklist category: {cat}. Must be numbers.": {
"en": "Invalid checklist category: {cat}. Must be numbers.",
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
},
"Items input must be a JSON array, got {type}": {
"bg": "Входните данни трябва да са JSON масив, получено {type}",
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
"en": "Items input must be a JSON array, got {type}",
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
"zh": "输入必须是 JSON 数组,得到 {type}"
},
"Label '{label}' already on PR #{pr}.": {
"en": "Label '{label}' already on PR #{pr}.",
"bg": "Label '{label}' already on PR #{pr}.",
"de": "Label '{label}' already on PR #{pr}.",
"pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}."
},
"Latest run: #{run_id} (status: {status})": {
"en": "Latest run: #{run_id} (status: {status})",
"bg": "Latest run: #{run_id} (status: {status})",
"de": "Latest run: #{run_id} (status: {status})",
"pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
@@ -959,6 +1471,14 @@
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
}, },
"Missing tests for changed files.": {
"en": "Missing tests for changed files.",
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
"pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files."
},
"Module {mod} has no main() function": { "Module {mod} has no main() function": {
"bg": "Модул {mod} няма функция main()", "bg": "Модул {mod} няма функция main()",
"de": "Modul {mod} hat keine main()-Funktion", "de": "Modul {mod} hat keine main()-Funktion",
@@ -1015,6 +1535,30 @@
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"No CI checks found for commit {sha}.": {
"en": "No CI checks found for commit {sha}.",
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
"pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
},
"No badge SVG files generated": {
"bg": "No badge SVG files generated",
"de": "No badge SVG files generated",
"en": "No badge SVG files generated",
"pl": "No badge SVG files generated",
"ru": "No badge SVG files generated",
"zh": "No badge SVG files generated"
},
"No badge URLs found to update — README already up to date": {
"bg": "No badge URLs found to update — README already up to date",
"de": "No badge URLs found to update — README already up to date",
"en": "No badge URLs found to update — README already up to date",
"pl": "No badge URLs found to update — README already up to date",
"ru": "No badge URLs found to update — README already up to date",
"zh": "No badge URLs found to update — README already up to date"
},
"No changes between {base} and {head}.": { "No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.", "bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.", "de": "No changes between {base} and {head}.",
@@ -1023,6 +1567,38 @@
"ru": "No changes between {base} and {head}.", "ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}." "zh": "No changes between {base} and {head}."
}, },
"No failed jobs.": {
"en": "No failed jobs.",
"bg": "No failed jobs.",
"de": "No failed jobs.",
"pl": "No failed jobs.",
"ru": "No failed jobs.",
"zh": "No failed jobs."
},
"No job matching '{job}' found.": {
"en": "No job matching '{job}' found.",
"bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found."
},
"No jobs found for run #{run_id}.": {
"en": "No jobs found for run #{run_id}.",
"bg": "No jobs found for run #{run_id}.",
"de": "No jobs found for run #{run_id}.",
"pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}."
},
"No open PR found for branch '{branch}'.": {
"en": "No open PR found for branch '{branch}'.",
"bg": "No open PR found for branch '{branch}'.",
"de": "No open PR found for branch '{branch}'.",
"pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
},
"No staged changes — version and changelog already up to date.": { "No staged changes — version and changelog already up to date.": {
"bg": "No staged changes — version and changelog already up to date.", "bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.", "de": "No staged changes — version and changelog already up to date.",
@@ -1087,6 +1663,14 @@
"ru": "No versions found.", "ru": "No versions found.",
"zh": "No versions found." "zh": "No versions found."
}, },
"No workflow runs found for SHA {sha}.": {
"en": "No workflow runs found for SHA {sha}.",
"bg": "No workflow runs found for SHA {sha}.",
"de": "No workflow runs found for SHA {sha}.",
"pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.", "bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.", "de": "Note: Self-approval not allowed. Posting COMMENT instead.",
@@ -1263,6 +1847,22 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
},
"Package: {owner}/{name}": {
"bg": "Package: {owner}/{name}",
"de": "Package: {owner}/{name}",
"en": "Package: {owner}/{name}",
"pl": "Package: {owner}/{name}",
"ru": "Package: {owner}/{name}",
"zh": "Package: {owner}/{name}"
},
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": { "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME", "bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert", "de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
@@ -1359,6 +1959,14 @@
"ru": "Push failed for {tag}: {error}", "ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}" "zh": "Push failed for {tag}: {error}"
}, },
"Pushed README update with badge SHA {sha}": {
"bg": "Pushed README update with badge SHA {sha}",
"de": "Pushed README update with badge SHA {sha}",
"en": "Pushed README update with badge SHA {sha}",
"pl": "Pushed README update with badge SHA {sha}",
"ru": "Pushed README update with badge SHA {sha}",
"zh": "Pushed README update with badge SHA {sha}"
},
"Pushed release commit to master.": { "Pushed release commit to master.": {
"bg": "Pushed release commit to master.", "bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.", "de": "Pushed release commit to master.",
@@ -1383,29 +1991,13 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).", "ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)." "zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
}, },
"REPO_TOKEN environment variable required": { "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
"bg": "REPO_TOKEN environment variable required", "bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"de": "REPO_TOKEN environment variable required", "de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"en": "REPO_TOKEN environment variable required", "en": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"pl": "REPO_TOKEN environment variable required", "pl": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"ru": "REPO_TOKEN environment variable required", "ru": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"zh": "REPO_TOKEN environment variable required" "zh": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars"
},
"REPO_TOKEN is not set. Required to create a PR.": {
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "REPO_TOKEN is not set. Required to create a PR.",
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
},
"Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars": {
"bg": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"de": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"en": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"pl": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"ru": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"zh": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars"
}, },
"Registry login failed": { "Registry login failed": {
"bg": "Registry login failed", "bg": "Registry login failed",
@@ -1423,6 +2015,22 @@
"ru": "Registry login failed: {error}", "ru": "Registry login failed: {error}",
"zh": "Registry login failed: {error}" "zh": "Registry login failed: {error}"
}, },
"Regular merge commit — running all post-merge jobs.": {
"bg": "Regular merge commit — running all post-merge jobs.",
"de": "Regular merge commit — running all post-merge jobs.",
"en": "Regular merge commit — running all post-merge jobs.",
"pl": "Regular merge commit — running all post-merge jobs.",
"ru": "Regular merge commit — running all post-merge jobs.",
"zh": "Regular merge commit — running all post-merge jobs."
},
"Release commit — skipping all post-merge jobs.": {
"bg": "Release commit — skipping all post-merge jobs.",
"de": "Release commit — skipping all post-merge jobs.",
"en": "Release commit — skipping all post-merge jobs.",
"pl": "Release commit — skipping all post-merge jobs.",
"ru": "Release commit — skipping all post-merge jobs.",
"zh": "Release commit — skipping all post-merge jobs."
},
"Release creation failed: {error}": { "Release creation failed: {error}": {
"bg": "Release creation failed: {error}", "bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}", "de": "Release creation failed: {error}",
@@ -1463,13 +2071,13 @@
"ru": "Repository in owner/name format", "ru": "Repository in owner/name format",
"zh": "Repository in owner/name format" "zh": "Repository in owner/name format"
}, },
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": { "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", "en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.", "bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.", "de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.", "pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", "ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。" "zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
}, },
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": { "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.", "bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
@@ -1479,6 +2087,14 @@
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
}, },
"Review body must be at least 50 characters.": {
"en": "Review body must be at least 50 characters.",
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
},
"Roles directory not found: {path}": { "Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}", "bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}", "de": "Roles directory not found: {path}",
@@ -1487,6 +2103,14 @@
"ru": "Roles directory not found: {path}", "ru": "Roles directory not found: {path}",
"zh": "Roles directory not found: {path}" "zh": "Roles directory not found: {path}"
}, },
"Runner count: {count}": {
"bg": "Runner count: {count}",
"de": "Runner count: {count}",
"en": "Runner count: {count}",
"pl": "Runner count: {count}",
"ru": "Runner count: {count}",
"zh": "Runner count: {count}"
},
"Runner index {index} out of range (0..{max})": { "Runner index {index} out of range (0..{max})": {
"bg": "Индексът на runner {index} е извън диапазона (0..{max})", "bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
@@ -1495,6 +2119,30 @@
"ru": "Индекс runner {index} вне диапазона (0..{max})", "ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})" "zh": "Runner 索引 {index} 超出范围 (0..{max})"
}, },
"Runner index {runner_index} is out of range (must be >= 1)": {
"bg": "Runner index {runner_index} is out of range (must be >= 1)",
"de": "Runner index {runner_index} is out of range (must be >= 1)",
"en": "Runner index {runner_index} is out of range (must be >= 1)",
"pl": "Runner index {runner_index} is out of range (must be >= 1)",
"ru": "Runner index {runner_index} is out of range (must be >= 1)",
"zh": "Runner index {runner_index} is out of range (must be >= 1)"
},
"Runner indices: {indices}": {
"bg": "Runner indices: {indices}",
"de": "Runner indices: {indices}",
"en": "Runner indices: {indices}",
"pl": "Runner indices: {indices}",
"ru": "Runner indices: {indices}",
"zh": "Runner indices: {indices}"
},
"Runner {i}: {labels}": {
"bg": "Runner {i}: {labels}",
"de": "Runner {i}: {labels}",
"en": "Runner {i}: {labels}",
"pl": "Runner {i}: {labels}",
"ru": "Runner {i}: {labels}",
"zh": "Runner {i}: {labels}"
},
"Running lint checks...": { "Running lint checks...": {
"bg": "Running lint checks...", "bg": "Running lint checks...",
"de": "Running lint checks...", "de": "Running lint checks...",
@@ -1511,6 +2159,14 @@
"ru": "Running tests...", "ru": "Running tests...",
"zh": "Running tests..." "zh": "Running tests..."
}, },
"Running: {cmd}": {
"bg": "Running: {cmd}",
"de": "Running: {cmd}",
"en": "Running: {cmd}",
"pl": "Running: {cmd}",
"ru": "Running: {cmd}",
"zh": "Running: {cmd}"
},
"Running: {scenario} on {platform}": { "Running: {scenario} on {platform}": {
"bg": "Running: {scenario} on {platform}", "bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}", "de": "Running: {scenario} on {platform}",
@@ -1543,6 +2199,22 @@
"ru": "Skipping commit push — no staged changes.", "ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes." "zh": "Skipping commit push — no staged changes."
}, },
"Skipping — runner index {runner_index} > max runners {max_runners}": {
"bg": "Skipping — runner index {runner_index} > max runners {max_runners}",
"de": "Skipping — runner index {runner_index} > max runners {max_runners}",
"en": "Skipping — runner index {runner_index} > max runners {max_runners}",
"pl": "Skipping — runner index {runner_index} > max runners {max_runners}",
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
},
"Synced to latest origin/{branch}": {
"bg": "Synced to latest origin/{branch}",
"de": "Synced to latest origin/{branch}",
"en": "Synced to latest origin/{branch}",
"pl": "Synced to latest origin/{branch}",
"ru": "Synced to latest origin/{branch}",
"zh": "Synced to latest origin/{branch}"
},
"Syncing {count} documentation pages to wiki...": { "Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...", "bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...", "de": "Syncing {count} documentation pages to wiki...",
@@ -1623,6 +2295,14 @@
"ru": "Tests passed.", "ru": "Tests passed.",
"zh": "Tests passed." "zh": "Tests passed."
}, },
"Timeout reached after {timeout}s.": {
"en": "Timeout reached after {timeout}s.",
"bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": { "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).", "bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).", "de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
@@ -1647,6 +2327,14 @@
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
}, },
"Updated badge URLs in {filename}": {
"bg": "Updated badge URLs in {filename}",
"de": "Updated badge URLs in {filename}",
"en": "Updated badge URLs in {filename}",
"pl": "Updated badge URLs in {filename}",
"ru": "Updated badge URLs in {filename}",
"zh": "Updated badge URLs in {filename}"
},
"Updated version in {init}": { "Updated version in {init}": {
"bg": "Updated version in {init}", "bg": "Updated version in {init}",
"de": "Updated version in {init}", "de": "Updated version in {init}",
@@ -1695,6 +2383,14 @@
"ru": "Version file: {file}", "ru": "Version file: {file}",
"zh": "Version file: {file}" "zh": "Version file: {file}"
}, },
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "",
"de": "",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "",
"ru": "",
"zh": ""
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
@@ -1735,6 +2431,14 @@
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
}, },
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
},
"Warning: could not fetch tags from origin.": { "Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.", "bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.", "de": "Warning: could not fetch tags from origin.",
@@ -1743,6 +2447,54 @@
"ru": "Warning: could not fetch tags from origin.", "ru": "Warning: could not fetch tags from origin.",
"zh": "Warning: could not fetch tags from origin." "zh": "Warning: could not fetch tags from origin."
}, },
"Warning: instance-level runners query failed: {error}": {
"bg": "Warning: instance-level runners query failed: {error}",
"de": "Warning: instance-level runners query failed: {error}",
"en": "Warning: instance-level runners query failed: {error}",
"pl": "Warning: instance-level runners query failed: {error}",
"ru": "Warning: instance-level runners query failed: {error}",
"zh": "Warning: instance-level runners query failed: {error}"
},
"Warning: instance-level runners query returned HTTP {status}": {
"bg": "Warning: instance-level runners query returned HTTP {status}",
"de": "Warning: instance-level runners query returned HTTP {status}",
"en": "Warning: instance-level runners query returned HTTP {status}",
"pl": "Warning: instance-level runners query returned HTTP {status}",
"ru": "Warning: instance-level runners query returned HTTP {status}",
"zh": "Warning: instance-level runners query returned HTTP {status}"
},
"Warning: org-level runners query failed: {error}": {
"bg": "Warning: org-level runners query failed: {error}",
"de": "Warning: org-level runners query failed: {error}",
"en": "Warning: org-level runners query failed: {error}",
"pl": "Warning: org-level runners query failed: {error}",
"ru": "Warning: org-level runners query failed: {error}",
"zh": "Warning: org-level runners query failed: {error}"
},
"Warning: org-level runners query returned HTTP {status}": {
"bg": "Warning: org-level runners query returned HTTP {status}",
"de": "Warning: org-level runners query returned HTTP {status}",
"en": "Warning: org-level runners query returned HTTP {status}",
"pl": "Warning: org-level runners query returned HTTP {status}",
"ru": "Warning: org-level runners query returned HTTP {status}",
"zh": "Warning: org-level runners query returned HTTP {status}"
},
"Warning: repo-level runners query failed: {error}": {
"bg": "Warning: repo-level runners query failed: {error}",
"de": "Warning: repo-level runners query failed: {error}",
"en": "Warning: repo-level runners query failed: {error}",
"pl": "Warning: repo-level runners query failed: {error}",
"ru": "Warning: repo-level runners query failed: {error}",
"zh": "Warning: repo-level runners query failed: {error}"
},
"Warning: repo-level runners query returned HTTP {status}": {
"bg": "Warning: repo-level runners query returned HTTP {status}",
"de": "Warning: repo-level runners query returned HTTP {status}",
"en": "Warning: repo-level runners query returned HTTP {status}",
"pl": "Warning: repo-level runners query returned HTTP {status}",
"ru": "Warning: repo-level runners query returned HTTP {status}",
"zh": "Warning: repo-level runners query returned HTTP {status}"
},
"Wiki integrity check failed — {count} issue(s)": { "Wiki integrity check failed — {count} issue(s)": {
"bg": "Wiki integrity check failed — {count} issue(s)", "bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)", "de": "Wiki integrity check failed — {count} issue(s)",
@@ -1799,13 +2551,13 @@
"ru": "[check_test_coverage] No changed files to check.", "ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check." "zh": "[check_test_coverage] No changed files to check."
}, },
"[dry-run] Would commit: release: v{version}": { "[dry-run] Would commit: release: v{version} [skip ci]": {
"bg": "[dry-run] Would commit: release: v{version}", "bg": "[dry-run] Would commit: release: v{version} [skip ci]",
"de": "[dry-run] Would commit: release: v{version}", "de": "[dry-run] Would commit: release: v{version} [skip ci]",
"en": "[dry-run] Would commit: release: v{version}", "en": "[dry-run] Would commit: release: v{version} [skip ci]",
"pl": "[dry-run] Utworzono by commit: release: v{version}", "pl": "[dry-run] Utworzono by commit: release: v{version} [skip ci]",
"ru": "[dry-run] Would commit: release: v{version}", "ru": "[dry-run] Would commit: release: v{version} [skip ci]",
"zh": "[dry-run] Would commit: release: v{version}" "zh": "[dry-run] Would commit: release: v{version} [skip ci]"
}, },
"[dry-run] Would create tag: v{version}": { "[dry-run] Would create tag: v{version}": {
"bg": "[dry-run] Would create tag: v{version}", "bg": "[dry-run] Would create tag: v{version}",
@@ -1879,6 +2631,14 @@
"ru": "завершён", "ru": "завершён",
"zh": "已完成" "zh": "已完成"
}, },
"count={count}": {
"bg": "count={count}",
"de": "count={count}",
"en": "count={count}",
"pl": "count={count}",
"ru": "count={count}",
"zh": "count={count}"
},
"devx version mismatch across extras: {detail}": { "devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}", "bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}", "de": "devx-Versionskonflikt zwischen Extras: {detail}",
@@ -1943,6 +2703,14 @@
"ru": "неактивен", "ru": "неактивен",
"zh": "未激活" "zh": "未激活"
}, },
"indices={indices}": {
"bg": "indices={indices}",
"de": "indices={indices}",
"en": "indices={indices}",
"pl": "indices={indices}",
"ru": "indices={indices}",
"zh": "indices={indices}"
},
"mapping.json keys and values must be strings, got {k}={v}": { "mapping.json keys and values must be strings, got {k}={v}": {
"bg": "mapping.json keys and values must be strings, got {k}={v}", "bg": "mapping.json keys and values must be strings, got {k}={v}",
"de": "mapping.json keys and values must be strings, got {k}={v}", "de": "mapping.json keys and values must be strings, got {k}={v}",
@@ -1975,6 +2743,22 @@
"ru": "pyproject.toml не найден в текущей директории.", "ru": "pyproject.toml не найден в текущей директории.",
"zh": "在当前目录中未找到 pyproject.toml。" "zh": "在当前目录中未找到 pyproject.toml。"
}, },
"tea login '{name}' already configured.": {
"bg": "tea login '{name}' already configured.",
"de": "tea login '{name}' already configured.",
"en": "tea login '{name}' already configured.",
"pl": "tea login '{name}' already configured.",
"ru": "tea login '{name}' already configured.",
"zh": "tea login '{name}' already configured."
},
"tea not installed — skipping login configuration.": {
"bg": "tea not installed — skipping login configuration.",
"de": "tea not installed — skipping login configuration.",
"en": "tea not installed — skipping login configuration.",
"pl": "tea not installed — skipping login configuration.",
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
},
"unknown": { "unknown": {
"bg": "неизвестен", "bg": "неизвестен",
"de": "unbekannt", "de": "unbekannt",
@@ -1990,5 +2774,13 @@
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.", "pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
"ru": "{file} already exists. Use --force to overwrite.", "ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite." "zh": "{file} already exists. Use --force to overwrite."
},
"{separator}": {
"bg": "{separator}",
"de": "{separator}",
"en": "{separator}",
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
} }
} }
+123 -25
View File
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
import requests import requests
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error from devx.api_clients import GiteaClient, VikunjaClient, _parse_error
from devx.config import ( from devx.config import (
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
@@ -507,7 +507,7 @@ class TestGiteaClient:
assert result["id"] == 1 assert result["id"] == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff.""" """Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -520,7 +520,7 @@ class TestGiteaClient:
assert client._session.request.call_count == 3 assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2 assert mock_sleep.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable.""" """Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -532,7 +532,7 @@ class TestGiteaClient:
assert result.json() == {"ok": True} assert result.json() == {"ok": True}
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None: def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error.""" """Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -545,7 +545,7 @@ class TestGiteaClient:
assert client._session.request.call_count == 1 assert client._session.request.call_count == 1
mock_sleep.assert_not_called() mock_sleep.assert_not_called()
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors.""" """Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -555,7 +555,7 @@ class TestGiteaClient:
assert result.json() == {"ok": True} assert result.json() == {"ok": True}
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503.""" """Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -567,7 +567,7 @@ class TestGiteaClient:
assert exc_info.value.status == 503 assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors.""" """Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -691,7 +691,7 @@ class TestVikunjaClient:
json={"id": 42, "title": "My task", "done": True}, json={"id": 42, "title": "My task", "done": True},
) )
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None: def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock() mock_resp = MagicMock()
@@ -713,7 +713,7 @@ class TestVikunjaClient:
client.list_tasks() client.list_tasks()
assert "connection failed" in str(exc_info.value) assert "connection failed" in str(exc_info.value)
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None: def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503.""" """VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -725,7 +725,7 @@ class TestVikunjaClient:
assert len(result) == 1 assert len(result) == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors.""" """VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -735,7 +735,7 @@ class TestVikunjaClient:
assert len(result) == 1 assert len(result) == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503.""" """VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -747,7 +747,7 @@ class TestVikunjaClient:
assert exc_info.value.status == 503 assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors.""" """VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -781,21 +781,119 @@ class TestVikunjaClient:
call_kwargs = client._session.request.call_args.kwargs call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["description"] == "" assert call_kwargs["json"]["description"] == ""
def test_find_task_by_identifier_found(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-42", "title": "Found"}])
)
result = client.find_task_by_identifier(6, "DEVX-42", per_page=50)
assert result is not None
assert result["title"] == "Found"
class TestIsRetryable: def test_find_task_by_identifier_not_found(self) -> None:
def test_connection_error_is_retryable(self) -> None: client = VikunjaClient("https://work.example.com", "tok")
assert _is_retryable(requests.ConnectionError("refused")) is True client._session.request = MagicMock(
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-2"}])
)
result = client.find_task_by_identifier(6, "DEVX-99", per_page=50)
assert result is None
def test_timeout_is_retryable(self) -> None: def test_find_task_by_identifier_empty_project(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response([]))
result = client.find_task_by_identifier(6, "DEVX-1", per_page=50)
assert result is None
def test_429_is_retryable(self) -> None: def test_find_task_by_identifier_paginates(self) -> None:
err = _mock_http_error(429, "rate limited") client = VikunjaClient("https://work.example.com", "tok")
assert _is_retryable(err) is True full_page = [{"identifier": f"DEVX-{i}"} for i in range(50)]
client._session.request = MagicMock(
side_effect=[
_mock_response(full_page),
_mock_response([{"identifier": "DEVX-50", "title": "Found on page 2"}]),
]
)
result = client.find_task_by_identifier(6, "DEVX-50", per_page=50)
assert result is not None
assert result["title"] == "Found on page 2"
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
def test_generic_exception_is_not_retryable(self) -> None: class TestGiteaClientPrLabels:
assert _is_retryable(ValueError("oops")) is False def test_add_pr_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.add_pr_label(42, ["ready-to-merge"])
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues/42/labels",
timeout=DEFAULT_TIMEOUT,
json={"labels": ["ready-to-merge"]},
)
def test_add_pr_label_multiple(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.add_pr_label(42, ["ready-to-merge", "reviewed"])
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["labels"] == ["ready-to-merge", "reviewed"]
def test_get_pr_label_names(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug"}, {"name": "ready-to-merge"}]))
result = client.get_pr_label_names(42)
assert result == ["bug", "ready-to-merge"]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/42/labels",
timeout=DEFAULT_TIMEOUT,
)
class TestGiteaClientActions:
def test_list_action_runs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"workflow_runs": [{"id": 1, "status": "completed"}], "total_count": 1})
)
result = client.list_action_runs(branch="feature-branch", limit=1)
assert result["total_count"] == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/runs",
timeout=DEFAULT_TIMEOUT,
params={"branch": "feature-branch", "limit": 1},
)
def test_get_action_run_jobs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"jobs": [{"id": 100, "name": "quality", "conclusion": "failure"}]})
)
result = client.get_action_run_jobs(1410)
assert len(result) == 1
assert result[0]["name"] == "quality"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/runs/1410/jobs",
timeout=DEFAULT_TIMEOUT,
)
def test_get_action_run_jobs_empty(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
result = client.get_action_run_jobs(1410)
assert result == []
def test_get_action_job_logs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.text = "log line 1\nlog line 2"
mock_resp.raise_for_status = MagicMock()
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_action_job_logs(10026)
assert "log line 1" in result
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
timeout=DEFAULT_TIMEOUT,
)
+36 -13
View File
@@ -6,12 +6,12 @@ import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci._shared import run_cmd
from devx.ci.auto_merge import ( from devx.ci.auto_merge import (
extract_conventional_msg, extract_conventional_msg,
extract_task_id, extract_task_id,
main, main,
read_taskid, read_taskid,
run_cmd,
validate_pr_title, validate_pr_title,
validate_pr_title_matches_vikunja, validate_pr_title_matches_vikunja,
) )
@@ -195,6 +195,29 @@ class TestExtractConventionalMsg:
] ]
assert extract_conventional_msg(commits) == "feat: add feature" assert extract_conventional_msg(commits) == "feat: add feature"
def test_prefers_feat_over_refactor(self) -> None:
"""When both feat and refactor commits exist, feat wins."""
commits = [
{"commit": {"message": "refactor: add find_task_by_identifier"}},
{"commit": {"message": "fix: remove hardcoded fallbacks"}},
{"commit": {"message": "feat: add manual review support"}},
]
assert extract_conventional_msg(commits) == "feat: add manual review support"
def test_prefers_fix_over_docs(self) -> None:
commits = [
{"commit": {"message": "docs: update README"}},
{"commit": {"message": "fix: resolve bug"}},
]
assert extract_conventional_msg(commits) == "fix: resolve bug"
def test_scope_in_prefix(self) -> None:
commits = [
{"commit": {"message": "refactor(ci): cleanup code"}},
{"commit": {"message": "feat(api): add endpoint"}},
]
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
# -- run_cmd -- # -- run_cmd --
@@ -217,7 +240,7 @@ class TestRunCmd:
class TestMain: 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.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow( def test_full_merge_flow(
@@ -239,14 +262,14 @@ class TestMain:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout") 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: def test_no_token_raises(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"]) result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0 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") @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] def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -256,7 +279,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "No task ID" in result.output 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") @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] def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -266,7 +289,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "format" in result.output.lower() 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.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_raises_no_rebase( def test_merge_behind_master_raises_no_rebase(
@@ -298,7 +321,7 @@ class TestMain:
# Must NOT have called merge_pr twice (no retry after rebase) # Must NOT have called merge_pr twice (no retry after rebase)
assert mock_client.merge_pr.call_count == 1 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.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises( def test_merge_failure_raises(
@@ -321,7 +344,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "Merge failed" in result.output 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.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises( def test_no_conventional_msg_raises(
@@ -342,7 +365,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "conventional commit" in result.output.lower() 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: def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise.""" """Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -351,7 +374,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "PR number must be an integer" in result.output 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: def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise.""" """Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -360,7 +383,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "owner/name" in result.output 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.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_does_not_force_push( def test_merge_behind_master_does_not_force_push(
@@ -376,7 +399,7 @@ class TestMain:
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master") mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run: with patch("devx.ci._shared.run_cmd") as mock_run:
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
+170 -16
View File
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
from devx.tools.clean_images import delete_package_version from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204) mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
# Verify URL includes container type
url = mock_del.call_args.args[0]
assert "/container/" in url
def test_delete_package_version_404_treated_as_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert ( assert (
delete_package_version( delete_package_version(
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
def test_delete_package_version_failure(self) -> None: def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404) mock_resp = MagicMock(status_code=403)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert ( assert (
delete_package_version( delete_package_version(
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
is False is False
) )
def test_delete_package_version_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
responses = [
MagicMock(status_code=500),
MagicMock(status_code=502),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
responses = [
req.ConnectionError("network down"),
MagicMock(status_code=204),
]
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=3,
)
is True
)
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
import requests as req
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
side_effect=req.ConnectionError("network down"),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch(
"devx.tools.clean_images.requests.delete",
return_value=MagicMock(status_code=500),
):
with patch("devx.tools.clean_images.time.sleep"):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=2,
)
is False
)
def test_delete_package_version_zero_retries(self) -> None:
from devx.tools.clean_images import delete_package_version
with patch("devx.tools.clean_images.requests.delete") as mock_del:
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
max_retries=0,
)
is False
)
mock_del.assert_not_called()
class TestCLIBuildImage: class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None: def test_single_image_build(self, tmp_path: Path) -> None:
@@ -442,7 +565,7 @@ class TestCLIBuildImage:
dockerfile.touch() dockerfile.touch()
runner = CliRunner() runner = CliRunner()
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="") login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}): 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): with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
result = runner.invoke( result = runner.invoke(
build_image.main, build_image.main,
@@ -458,7 +581,7 @@ class TestCLIBuildImage:
build_result = MagicMock(returncode=0) build_result = MagicMock(returncode=0)
login_result = MagicMock(returncode=0, stderr="", stdout="") login_result = MagicMock(returncode=0, stderr="", stdout="")
push_result = MagicMock(returncode=1, stderr="push failed", stdout="") push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
with patch( with patch(
"devx.tools.build_image.subprocess.run", "devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result], side_effect=[login_result, build_result, push_result],
@@ -482,7 +605,7 @@ class TestCLICleanImages:
{"version": "0.3.0", "created_at": "2025-03-01"}, {"version": "0.3.0", "created_at": "2025-03-01"},
] ]
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke( result = runner.invoke(
clean_main, clean_main,
@@ -511,7 +634,7 @@ class TestCLICleanImages:
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.json.return_value = [] mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock() mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke( result = runner.invoke(
clean_main, clean_main,
@@ -532,7 +655,7 @@ class TestCLICleanImages:
] ]
list_resp.raise_for_status = MagicMock() list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=204) delete_resp = MagicMock(status_code=204)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): 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.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke( result = runner.invoke(
@@ -548,7 +671,7 @@ class TestCLICleanImages:
from devx.tools.clean_images import main as clean_main from devx.tools.clean_images import main as clean_main
runner = CliRunner() runner = CliRunner()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch( with patch(
"devx.tools.clean_images.requests.get", "devx.tools.clean_images.requests.get",
side_effect=req.ConnectionError("network down"), side_effect=req.ConnectionError("network down"),
@@ -557,7 +680,7 @@ class TestCLICleanImages:
clean_main, clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
) )
assert result.exit_code == 0 assert result.exit_code != 0
assert "Failed to list" in result.output assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None: def test_delete_failure_in_cli(self) -> None:
@@ -571,13 +694,44 @@ class TestCLICleanImages:
{"version": "0.3.0", "created_at": "2025-03-01"}, {"version": "0.3.0", "created_at": "2025-03-01"},
] ]
list_resp.raise_for_status = MagicMock() list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500) delete_resp = MagicMock(status_code=403)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): 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.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke( with patch("devx.tools.clean_images.time.sleep"):
clean_main, result = runner.invoke(
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], clean_main,
) ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
assert result.exit_code == 0 )
assert "FAILED" in result.output assert result.exit_code != 0
assert "FAILED" in result.output
@patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss")
def test_owner_from_config(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "oblachno-oss/ci-base" in result.output
@patch("devx.tools.clean_images.REPO_OWNER", "")
def test_no_owner_raises(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
result = runner.invoke(
clean_main,
["--name", "ci-base"],
)
assert result.exit_code != 0
assert "owner" in result.output.lower()
+6 -4
View File
@@ -78,7 +78,7 @@ class TestGetPrTitleFromGitea:
assert get_pr_title_from_gitea("owner/repo", 1) is None assert get_pr_title_from_gitea("owner/repo", 1) is None
def test_returns_none_with_invalid_repo(self) -> None: def test_returns_none_with_invalid_repo(self) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
assert get_pr_title_from_gitea("invalid", 1) is None assert get_pr_title_from_gitea("invalid", 1) is None
@patch("devx.ci.check_auto_merge_ready.GiteaClient") @patch("devx.ci.check_auto_merge_ready.GiteaClient")
@@ -86,16 +86,18 @@ class TestGetPrTitleFromGitea:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"} mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1) result = get_pr_title_from_gitea("owner/repo", 1)
assert result == "DEVX-1: Fix bug" assert result == "DEVX-1: Fix bug"
@patch("devx.ci.check_auto_merge_ready.GiteaClient") @patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None: def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
from devx.exceptions import APIError
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr.side_effect = Exception("API error") mock_client.get_pr.side_effect = APIError(500, "API error")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1) result = get_pr_title_from_gitea("owner/repo", 1)
assert result is None assert result is None
+17 -5
View File
@@ -3,6 +3,8 @@
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.check_test_coverage import ( from devx.tools.check_test_coverage import (
BUILTIN_RULES, BUILTIN_RULES,
DEFAULT_SKIP_EXTENSIONS, DEFAULT_SKIP_EXTENSIONS,
@@ -13,7 +15,7 @@ from devx.tools.check_test_coverage import (
_load_rules, _load_rules,
_resolve_test_path, _resolve_test_path,
_should_skip_file, _should_skip_file,
main, cli,
) )
@@ -201,7 +203,10 @@ class TestMain:
), ),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
): ):
assert main([]) == 0 runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "No changed files" in result.output
def test_all_have_tests(self, tmp_path: Path) -> None: def test_all_have_tests(self, tmp_path: Path) -> None:
(tmp_path / "scripts" / "tests").mkdir(parents=True) (tmp_path / "scripts" / "tests").mkdir(parents=True)
@@ -214,7 +219,10 @@ class TestMain:
), ),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
): ):
assert main([]) == 0 runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "have tests" in result.output
def test_missing_test_returns_1(self, tmp_path: Path) -> None: def test_missing_test_returns_1(self, tmp_path: Path) -> None:
with ( with (
@@ -225,7 +233,9 @@ class TestMain:
), ),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
): ):
assert main([]) == 1 runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
def test_warn_only_returns_0(self, tmp_path: Path) -> None: def test_warn_only_returns_0(self, tmp_path: Path) -> None:
with ( with (
@@ -236,4 +246,6 @@ class TestMain:
), ),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
): ):
assert main(["--warn-only"]) == 0 runner = CliRunner()
result = runner.invoke(cli, ["--warn-only"])
assert result.exit_code == 0
+2 -2
View File
@@ -248,8 +248,8 @@ class TestDevxI18n:
import devx.i18n import devx.i18n
importlib.reload(devx.i18n) importlib.reload(devx.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation # "ERROR: CI_GITEA_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: REPO_TOKEN is not set.") result = devx.i18n._("ERROR: CI_GITEA_TOKEN is not set.")
assert "FEHLER" in result assert "FEHLER" in result
# Restore # Restore
+13 -3
View File
@@ -264,6 +264,16 @@ class TestChangeClassifier:
assert fc.is_user_facing assert fc.is_user_facing
assert fc.matched_rule == "user_facing_overrides" assert fc.matched_rule == "user_facing_overrides"
def test_user_facing_override_glob_matches_nested(self) -> None:
"""User-facing overrides support glob patterns like infrastructure."""
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
user_facing_overrides=[".gitea/**"],
)
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert fc.is_user_facing
assert fc.matched_rule == "user_facing_overrides"
def test_user_facing_override_beats_infrastructure_override(self) -> None: def test_user_facing_override_beats_infrastructure_override(self) -> None:
"""User-facing overrides beat infrastructure overrides (safety first).""" """User-facing overrides beat infrastructure overrides (safety first)."""
classifier = self._make_classifier( classifier = self._make_classifier(
@@ -489,7 +499,7 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"] mock_changes.return_value = ["docs/index.md", "README.md"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [])
assert result.exit_code == 2 assert result.exit_code == 2
@@ -572,7 +582,7 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = [".gitea/workflows/ci.yml"] mock_changes.return_value = ["docs/index.md"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--quiet"]) result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -631,7 +641,7 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"] mock_changes.return_value = ["docs/index.md", "tests/test_foo.py"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"]) result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0 assert result.exit_code == 0
+31 -13
View File
@@ -32,9 +32,10 @@ class TestDefaultConfigs:
assert config["branch_name"] == "master" assert config["branch_name"] == "master"
assert config["enable_push"] is True assert config["enable_push"] is True
assert config["enable_push_whitelist"] is False assert config["enable_push_whitelist"] is False
assert config["required_approvals"] == 0 assert config["required_approvals"] == 1
assert isinstance(config["status_check_contexts"], list) assert isinstance(config["status_check_contexts"], list)
assert "CI / quality (pull_request)" in config["status_check_contexts"] assert "CI / quality (pull_request)" in config["status_check_contexts"]
assert config["block_admin_merge_override"] is True
def test_default_repo_settings_config(self) -> None: def test_default_repo_settings_config(self) -> None:
config = _default_repo_settings_config() config = _default_repo_settings_config()
@@ -47,7 +48,7 @@ class TestDefaultConfigs:
class TestConfigureRepo: 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") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -58,7 +59,7 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.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") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -69,10 +70,10 @@ class TestConfigureRepo:
configure_repo(token="tok", owner="owner", repo="repo") configure_repo(token="tok", owner="owner", repo="repo")
def test_configure_repo_no_token(self) -> None: 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") 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") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -102,7 +103,7 @@ class TestConfigureRepo:
class TestMain: 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") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None: def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -114,7 +115,7 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.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") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None: def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -125,7 +126,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once() 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") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None: def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -142,16 +143,17 @@ class TestMain:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"]) result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.REPO_NAME", "")
def test_main_no_repo(self) -> None: def test_main_no_repo(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Repository name not specified" in result.output 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") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None: def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -164,7 +166,7 @@ class TestMain:
args = mock_client.ensure_branch_protection.call_args args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop" assert args[0][0] == "develop"
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None: def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split.""" """DEVX_REPO_NAME with 'owner/repo' format should be split."""
@@ -181,7 +183,7 @@ class TestMain:
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"}, {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
clear=True, clear=True,
) )
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno") @patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
@@ -197,3 +199,19 @@ class TestMain:
call_args = mock_client_cls.call_args call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner assert call_args[0][2] == "oblachno" # owner
assert call_args[0][3] == "infra" # repo assert call_args[0][3] == "infra" # repo
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
"""When no env var is set, repo name should come from pyproject.toml."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno-oss" # owner
assert call_args[0][3] == "devx" # repo
+18 -19
View File
@@ -29,10 +29,22 @@ class TestGetRepoName:
def test_from_env(self) -> None: def test_from_env(self) -> None:
assert get_repo_name() == "infra" assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_env_overrides_pyproject(self) -> None:
assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {}, clear=True)
def test_from_pyproject(self) -> None:
assert get_repo_name() == "devx"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True) @patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None: def test_from_github(self) -> None:
assert get_repo_name() == "infra" assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None: def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"): with pytest.raises(click.ClickException, match="Repository name"):
@@ -44,7 +56,7 @@ class TestGetVikunjaTaskTitle:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None: def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}] mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"}
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("DEVX-42") == "Add feature" assert get_vikunja_task_title("DEVX-42") == "Add feature"
@@ -57,20 +69,7 @@ class TestGetVikunjaTaskTitle:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None: def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [] mock_client.find_task_by_identifier.return_value = None
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
@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 mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"): with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42") get_vikunja_task_title("DEVX-42")
@@ -95,7 +94,7 @@ class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @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("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"} mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
@@ -112,7 +111,7 @@ class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr") @patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: 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_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock() mock_client = MagicMock()
@@ -123,10 +122,10 @@ class TestCreatePr:
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None: def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"): with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo") create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None: def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"): with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo") create_pr("feature-branch", "master", "", "owner", "repo")
+250
View File
@@ -0,0 +1,250 @@
"""Unit tests for devx.ci.distribute_items."""
import pytest
from click.testing import CliRunner
from devx.ci.distribute_items import (
DEFAULT_WEIGHT,
distribute,
items_for_runner,
main,
parse_items,
parse_weighted_items,
)
class TestParseItems:
def test_string_array(self) -> None:
assert parse_items('["a", "b", "c"]') == ["a", "b", "c"]
def test_object_array(self) -> None:
raw = '[{"id": "a", "weight": 2}, {"id": "b"}]'
assert parse_items(raw) == ["a", "b"]
def test_empty_array(self) -> None:
assert parse_items("[]") == []
def test_not_an_array(self) -> None:
with pytest.raises(Exception, match="must be a JSON array"):
parse_items('{"key": "value"}')
def test_invalid_entry_type(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_items("[42]")
def test_object_without_id(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_items('[{"weight": 2}]')
class TestParseWeightedItems:
def test_string_array_default_weights(self) -> None:
items, weights = parse_weighted_items('["a", "b"]')
assert items == ["a", "b"]
assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT]
def test_object_array_with_weights(self) -> None:
items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]')
assert items == ["a", "b"]
assert weights == [5, 1]
def test_object_array_missing_weight(self) -> None:
items, weights = parse_weighted_items('[{"id": "a"}]')
assert items == ["a"]
assert weights == [DEFAULT_WEIGHT]
def test_not_an_array(self) -> None:
with pytest.raises(Exception, match="must be a JSON array"):
parse_weighted_items('"hello"')
def test_invalid_entry(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_weighted_items("[true]")
class TestDistribute:
def test_even_split(self) -> None:
items = [f"vm-{i}" for i in range(6)]
weights = [1] * 6
groups = distribute(items, weights, 3)
assert len(groups) == 3
assert all(len(g) == 2 for g in groups)
def test_uneven_split(self) -> None:
items = [f"vm-{i}" for i in range(5)]
weights = [1] * 5
groups = distribute(items, weights, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_items(self) -> None:
items = ["vm-a"]
weights = [1]
groups = distribute(items, weights, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_lpt_heavy_item_on_least_loaded(self) -> None:
items = ["heavy", "light1", "light2", "light3"]
weights = [10, 1, 1, 1]
groups = distribute(items, weights, 2)
# Heavy item goes to runner 0, lights go to runner 1 (least loaded)
assert "heavy" in groups[0]
# Runner 1 should have more items but less total weight
assert len(groups[1]) >= 2
def test_empty_items(self) -> None:
groups = distribute([], [], 3)
assert len(groups) == 3
assert all(len(g) == 0 for g in groups)
def test_single_runner(self) -> None:
items = ["a", "b", "c"]
weights = [1, 2, 3]
groups = distribute(items, weights, 1)
assert len(groups) == 1
assert len(groups[0]) == 3
class TestItemsForRunner:
def test_returns_assigned_subset(self) -> None:
items = ["a", "b", "c", "d", "e", "f"]
weights = [1] * 6
result = items_for_runner(items, weights, 0, 3)
assert len(result) == 2
assert all(item in items for item in result)
def test_out_of_range(self) -> None:
with pytest.raises(Exception, match="out of range"):
items_for_runner(["a"], [1], 5, 3)
def test_negative_index(self) -> None:
with pytest.raises(Exception, match="out of range"):
items_for_runner(["a"], [1], -1, 3)
class TestMain:
def test_stdin_string_array(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]')
assert result.exit_code == 0
# LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0
# All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b"
assert "a" in result.output
def test_stdin_object_array(self) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "2"],
input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]',
)
assert result.exit_code == 0
assert "a" in result.output
def test_items_file(self, tmp_path: object) -> None:
import pathlib
items_file = pathlib.Path(str(tmp_path)) / "items.json"
items_file.write_text('["x", "y", "z"]')
runner = CliRunner()
result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
assert "x" in result.output
def test_print_all_groups_no_runner_index(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]')
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
def test_empty_stdin(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="")
assert result.exit_code == 0
# Empty input → empty assigned items
assert result.output.strip() == ""
def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "2", "--github-env"],
input='["a", "b"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
assert "ASSIGNED_ITEMS=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"],
input='["a"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
assert "ASSIGNED_ITEMS=" in content
assert "SKIP=true" in content
def test_runner_index_zero(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "0"], input='["a"]')
assert result.exit_code != 0
assert "out of range" in result.output
def test_default_max_runners(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1"], input='["a"]')
assert result.exit_code == 0
assert "a" in result.output
def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--github-env"],
input='["a"]',
)
assert result.exit_code != 0
assert "GITHUB_ENV" in result.output
def test_invalid_json(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1"], input="not json")
assert result.exit_code != 0
def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
# Items with newlines in their IDs would trigger multiline syntax
# Normal items don't have newlines, but test the path anyway
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "1", "--github-env"],
input='["a\\nb"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
# Item "a\nb" contains a newline → heredoc syntax
assert "ASSIGNED_ITEMS<<" in content
+35 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest 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: class TestExtractIssueNumber:
@@ -359,3 +359,37 @@ class TestWhoami:
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="") mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
with patch("subprocess.run", return_value=mock_result): with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "testuser" 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
+17 -7
View File
@@ -8,21 +8,22 @@ import pytest
from click import ClickException from click import ClickException
import devx.tools.install_checkmake as install_checkmake import devx.tools.install_checkmake as install_checkmake
from devx.tools._shared import arch_string
class TestArch: class TestArchString:
def test_amd64(self) -> None: def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"): with patch.object(platform, "machine", return_value="x86_64"):
assert install_checkmake._arch() == "amd64" assert arch_string() == "amd64"
def test_arm64(self) -> None: def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"): with patch.object(platform, "machine", return_value="aarch64"):
assert install_checkmake._arch() == "arm64" assert arch_string() == "arm64"
def test_unsupported(self) -> None: def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"): with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException): with pytest.raises(ClickException):
install_checkmake._arch() arch_string()
class TestInstallWithGo: class TestInstallWithGo:
@@ -63,13 +64,19 @@ class TestDownloadBinary:
class TestMain: class TestMain:
def test_already_installed(self) -> None: def test_already_installed(self) -> None:
from click.testing import CliRunner
with patch("shutil.which", return_value="/usr/bin/checkmake"): with patch("shutil.which", return_value="/usr/bin/checkmake"):
install_checkmake.main() runner = CliRunner()
runner.invoke(install_checkmake.cli, [])
def test_install_with_go(self) -> None: def test_install_with_go(self) -> None:
from click.testing import CliRunner
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]): with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
install_checkmake.main() runner = CliRunner()
runner.invoke(install_checkmake.cli, [])
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
[ [
"/usr/bin/go", "/usr/bin/go",
@@ -80,6 +87,8 @@ class TestMain:
) )
def test_download_when_no_go(self, tmp_path: Path) -> None: def test_download_when_no_go(self, tmp_path: Path) -> None:
from click.testing import CliRunner
target = tmp_path / "checkmake" target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]: def _write_file(url: str, path: str) -> tuple[str, None]:
@@ -90,5 +99,6 @@ class TestMain:
with patch("shutil.which", side_effect=[None, None]): with patch("shutil.which", side_effect=[None, None]):
with patch.object(platform, "machine", return_value="x86_64"): with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve: with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake.main() runner = CliRunner()
runner.invoke(install_checkmake.cli, [])
mock_retrieve.assert_called_once() mock_retrieve.assert_called_once()
+24 -1
View File
@@ -222,6 +222,24 @@ class TestInstallTea:
assert (tmp_path / "tea").exists() 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: class TestListTools:
def test_list(self, tmp_path: Path) -> None: def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path): with patch.object(install_tools, "TARGET_DIR", tmp_path):
@@ -251,6 +269,11 @@ class TestInstallTool:
assert install_tools._install_tool("tea") is True assert install_tools._install_tool("tea") is True
mock.assert_called_once() 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: def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"): with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown") install_tools._install_tool("unknown")
@@ -269,7 +292,7 @@ class TestMain:
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install: with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, []) result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_install.call_count == 4 assert mock_install.call_count == 5
def test_install_specific_tool(self) -> None: def test_install_specific_tool(self) -> None:
runner = CliRunner() runner = CliRunner()
+4 -4
View File
@@ -101,7 +101,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -148,7 +148,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -193,7 +193,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -239,7 +239,7 @@ class TestCli:
assert "without cross-runner cancellation" in result.output assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None: 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 ( with (
patch.dict( patch.dict(
os.environ, os.environ,
+5 -5
View File
@@ -220,7 +220,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -288,7 +288,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -325,7 +325,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -371,7 +371,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -418,7 +418,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
+29 -29
View File
@@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.notify_failure import _configure_tea_login, main from devx.ci.notify_failure import main
from devx.gitea_cli import TeaCLIError from devx.gitea_cli import TeaCLIError, configure_tea_login
class TestNotifyFailure: class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None: def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
@@ -36,7 +36,7 @@ class TestNotifyFailure:
mock_tea.create_issue.assert_called_once() mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"]) 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") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None: def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
@@ -53,7 +53,7 @@ class TestNotifyFailure:
assert "issue #43" in result.output assert "issue #43" in result.output
mock_tea.add_label.assert_not_called() 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") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None: def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
"""When tea fails, the workflow fails — no fallback.""" """When tea fails, the workflow fails — no fallback."""
@@ -70,7 +70,7 @@ class TestNotifyFailure:
assert result.exit_code != 0 assert result.exit_code != 0
assert "tea" in result.output.lower() 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") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None: 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.""" """If listing labels fails via tea, issue is still created without labels."""
@@ -87,7 +87,7 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #50" in result.output 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") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None: 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.""" """If adding label fails via tea, issue is still reported as created."""
@@ -105,7 +105,7 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #51" in result.output 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: def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -113,10 +113,10 @@ class TestNotifyFailure:
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"], ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
) )
assert result.exit_code != 0 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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None) @patch("devx.gitea_cli.shutil.which", return_value=None)
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: 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.""" """--auto-login with tea not installed skips login and still creates issue."""
@@ -133,11 +133,11 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #60" in result.output assert "issue #60" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: 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 = MagicMock()
mock_tea_cls.return_value = mock_tea 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"], ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
class TestConfigureTeaLogin: class TestConfigureTeaLogin:
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None: def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no token prints skip message and returns.""" """configure_tea_login with no token prints skip message and returns."""
_configure_tea_login() configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None) @patch("devx.gitea_cli.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> 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 with no tea binary prints skip message and returns."""
_configure_tea_login() configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run") @patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_configures_tea( def test_auto_login_configures_tea(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
@@ -191,9 +191,9 @@ class TestConfigureTeaLogin:
# tea login add was called # tea login add was called
assert mock_subprocess.call_count >= 2 assert mock_subprocess.call_count >= 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run") @patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_skips_if_already_configured( def test_auto_login_skips_if_already_configured(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
+84
View File
@@ -0,0 +1,84 @@
"""Unit tests for devx.tools.pr_label."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.pr_label import cli
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_label.REPO_OWNER", "")
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_label.GiteaClient")
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
assert "Added label" in result.output
@patch("devx.tools.pr_label.GiteaClient")
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = ["ready-to-merge"]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_not_called()
assert "already" in result.output
@patch("devx.tools.pr_label.GiteaClient")
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = ["reviewed"]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge", "--label", "reviewed"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
assert "Added label" in result.output
assert "already" in result.output
@patch("devx.tools.pr_label.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_pr(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr_label_names.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
+312
View File
@@ -0,0 +1,312 @@
"""Unit tests for devx.tools.pr_logs."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.api_clients import APIError, GiteaClient
from devx.tools.pr_logs import (
_find_failed_jobs,
_find_job_by_name,
_find_latest_run_by_sha,
_get_pr_sha,
_print_failed_steps,
_print_job_summary,
_print_logs,
cli,
)
class TestGetPrSha:
def test_returns_sha(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {"sha": "abc123"}}
assert _get_pr_sha(client, 42) == "abc123"
def test_returns_empty_when_missing(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {}}
assert _get_pr_sha(client, 42) == ""
class TestFindLatestRunBySha:
def test_returns_matching_run(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {
"workflow_runs": [
{"id": 2, "head_sha": "def456"},
{"id": 1, "head_sha": "abc123def"},
],
}
result = _find_latest_run_by_sha(client, "abc123")
assert result is not None
assert result["id"] == 1
def test_returns_none_when_no_match(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "head_sha": "def456"}],
}
result = _find_latest_run_by_sha(client, "abc123")
assert result is None
def test_returns_none_when_empty(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {"workflow_runs": []}
result = _find_latest_run_by_sha(client, "abc123")
assert result is None
class TestFindFailedJobs:
def test_returns_failed(self) -> None:
jobs = [
{"id": 1, "name": "quality", "conclusion": "failure"},
{"id": 2, "name": "lint", "conclusion": "success"},
]
result = _find_failed_jobs(jobs)
assert len(result) == 1
assert result[0]["name"] == "quality"
def test_empty_when_none_failed(self) -> None:
jobs = [{"id": 1, "name": "quality", "conclusion": "success"}]
assert _find_failed_jobs(jobs) == []
class TestFindJobByName:
def test_case_insensitive_partial(self) -> None:
jobs = [{"id": 1, "name": "CI / quality (pull_request)"}]
result = _find_job_by_name(jobs, "QUALITY")
assert result is not None
assert result["id"] == 1
def test_returns_none_when_not_found(self) -> None:
jobs = [{"id": 1, "name": "quality"}]
assert _find_job_by_name(jobs, "molecule") is None
class TestPrintJobSummary:
def test_prints_all_jobs(self, capsys: pytest.CaptureFixture) -> None:
jobs = [
{"id": 1, "name": "quality", "conclusion": "failure", "status": "completed"},
{"id": 2, "name": "lint", "conclusion": "success", "status": "completed"},
]
_print_job_summary(jobs)
out = capsys.readouterr().out
assert "[FAIL]" in out
assert "[OK]" in out
assert "quality" in out
assert "lint" in out
class TestPrintFailedSteps:
def test_prints_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
job = {
"steps": [
{"name": "checkout", "number": 1, "conclusion": "success"},
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
]
}
result = _print_failed_steps(job)
assert result == [3]
out = capsys.readouterr().out
assert "FAILED step #3" in out
assert "Unit tests" in out
def test_no_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
job = {"steps": [{"name": "checkout", "number": 1, "conclusion": "success"}]}
result = _print_failed_steps(job)
assert result == []
def test_no_steps_key(self, capsys: pytest.CaptureFixture) -> None:
result = _print_failed_steps({})
assert result == []
class TestPrintLogs:
def test_prints_all_lines(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.return_value = "line 1\nline 2\nline 3"
_print_logs(client, 100, tail=0)
out = capsys.readouterr().out
assert "line 1" in out
assert "line 3" in out
def test_tail_truncates(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.return_value = "\n".join(f"line {i}" for i in range(100))
_print_logs(client, 100, tail=10)
out = capsys.readouterr().out
assert "line 99" in out
assert "line 0" not in out
assert "showing last 10" in out
def test_api_error_handled(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.side_effect = APIError(404, "not found")
_print_logs(client, 100, tail=0)
out = capsys.readouterr().out
assert "Could not fetch logs" in out
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_logs.REPO_OWNER", "")
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_logs.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_pr(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {"workflow_runs": []}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Fetching logs for PR #42" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_runs_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {"workflow_runs": []}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "No workflow runs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "No jobs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_failed_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "No failed jobs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_failed_job_logs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{
"id": 100,
"name": "quality",
"conclusion": "failure",
"status": "completed",
"steps": [
{"name": "checkout", "number": 1, "conclusion": "success"},
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
],
}
]
client.get_action_job_logs.return_value = "error: test failed"
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--tail", "0"])
assert result.exit_code == 0
assert "FAILED step #3" in result.output
assert "error: test failed" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_specific_job(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []},
{"id": 101, "name": "lint", "conclusion": "success", "status": "completed", "steps": []},
]
client.get_action_job_logs.return_value = "lint output here"
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--job", "lint", "--tail", "0"])
assert result.exit_code == 0
assert "lint output here" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_job_not_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--job", "nonexistent"])
assert result.exit_code != 0
assert "No job matching" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {}}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "SHA" in result.output
+182 -6
View File
@@ -683,7 +683,7 @@ class TestMain:
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None: def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult() mock_run.return_value = ReviewResult()
runner = CliRunner() 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 result.exit_code == 0
assert "[dry-run]" in result.output assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called() mock_client_class.return_value.create_review.assert_not_called()
@@ -694,7 +694,7 @@ class TestMain:
mock_run.return_value = ReviewResult() mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123} mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner() 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 result.exit_code == 0
assert "Review #123" in result.output assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once() mock_client_class.return_value.create_review.assert_called_once()
@@ -710,7 +710,7 @@ class TestMain:
{"id": 124}, {"id": 124},
] ]
runner = CliRunner() 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 result.exit_code == 0
assert "Review #124" in result.output assert "Review #124" in result.output
assert client.create_review.call_count == 2 assert client.create_review.call_count == 2
@@ -723,14 +723,190 @@ class TestMain:
client = mock_client_class.return_value client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error") client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner() 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 result.exit_code != 0
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
runner = CliRunner() 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 "CI_GITEA_TOKEN" in result.output
class TestManualReview:
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_success(self, mock_client_class: MagicMock) -> None:
mock_client_class.return_value.create_review.return_value = {"id": 200}
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"All 13 REVIEW_CHECKLIST.md categories verified. Architecture: clean. Security: no issues.",
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8,9,10,11,12,13",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "Review #200" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_without_checklist_confirmed_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "checklist-confirmed" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_too_few_categories_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "at least 8" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_short_body_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"LGTM",
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "50 characters" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_invalid_category_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,abc,4",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "Invalid" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_request_changes_success(self, mock_client_class: MagicMock) -> None:
mock_client_class.return_value.create_review.return_value = {"id": 201}
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"REQUEST_CHANGES",
"--body",
"Please fix the architecture issues in the CLI module before merging.",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "Review #201" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_dry_run(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60, "--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()
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_fallback(self, mock_client_class: MagicMock) -> None:
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 202},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "Review #202" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
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/devx", "--event", "COMMENT", "--body", "x" * 60],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
def test_main_module_block() -> None: def test_main_module_block() -> None:
+279
View File
@@ -0,0 +1,279 @@
"""Unit tests for devx.tools.pr_status."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.api_clients import GiteaClient
from devx.tools.pr_status import (
_get_pr_sha,
_get_symbol,
cli,
print_status,
wait_for_completion,
)
class TestGetSymbol:
def test_success(self) -> None:
assert _get_symbol("success") == "[OK]"
def test_failure(self) -> None:
assert _get_symbol("failure") == "[FAIL]"
def test_pending(self) -> None:
assert _get_symbol("pending") == "[..]"
def test_unknown(self) -> None:
assert _get_symbol("weird") == "[weird]"
class TestGetPrSha:
def test_returns_head_sha(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {"sha": "abc123"}}
assert _get_pr_sha(client, 42) == "abc123"
def test_returns_empty_when_missing(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {}}
assert _get_pr_sha(client, 42) == ""
class TestPrintStatus:
def test_no_statuses(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = []
result = print_status(client, "abc123")
assert result == "none"
def test_all_success(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / lint", "status": "success"},
]
result = print_status(client, "abc123")
assert result == "success"
def test_has_failure(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / lint", "status": "failure"},
]
result = print_status(client, "abc123")
assert result == "failure"
def test_pending(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "pending"},
]
result = print_status(client, "abc123")
assert result == "pending"
def test_skipped_still_success(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / molecule", "status": "skipped"},
]
result = print_status(client, "abc123")
assert result == "success"
class TestWaitForCompletion:
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
def test_success_after_pending(
self, mock_time: MagicMock, mock_sleep: MagicMock, capsys: pytest.CaptureFixture
) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "success"}],
]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "success"
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
def test_failure_after_pending(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "failure"}],
]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "failure"
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
def test_timeout(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "pending"
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_status.REPO_OWNER", "")
@patch("devx.tools.pr_status.get_repo_name", side_effect=Exception("should not reach"))
def test_no_owner_raises(self, mock_repo_name: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_status.GiteaClient")
def test_check_pr_status(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_check_sha_directly(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--sha", "abc123"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_failure_raises_exception(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "failure"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "failed" in result.output.lower()
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_branch(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "success"}]
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "PR #42" in result.output
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_no_pr_found(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = []
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "No open PR" in result.output
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_branch_error(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=1, stderr="git error\n")
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {}}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "SHA" in result.output
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
@patch("devx.tools.pr_status.GiteaClient")
def test_wait_success(
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "success"}],
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
@patch("devx.tools.pr_status.GiteaClient")
def test_wait_timeout(
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
assert result.exit_code != 0
assert "timeout" in result.output.lower()
+2 -33
View File
@@ -43,7 +43,7 @@ class TestTaskExists:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None: def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}] mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42"}
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True assert task_exists("DEVX-42") is True
@@ -51,7 +51,7 @@ class TestTaskExists:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None: def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}] mock_client.find_task_by_identifier.return_value = None
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False assert task_exists("DEVX-42") is False
@@ -59,37 +59,6 @@ class TestTaskExists:
def test_no_token(self) -> None: def test_no_token(self) -> None:
assert task_exists("DEVX-42") is False 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: class TestValidate:
def test_master_branch_skips(self) -> None: def test_master_branch_skips(self) -> None:
+51 -17
View File
@@ -165,7 +165,7 @@ class TestDefaultGiteaRegistryUrl:
class TestMain: 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -190,7 +190,7 @@ class TestMain:
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" "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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -213,7 +213,7 @@ class TestMain:
mock_gitea_publish.assert_called_once() mock_gitea_publish.assert_called_once()
mock_tea.create_release.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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -239,7 +239,7 @@ class TestMain:
@patch.dict( @patch.dict(
"os.environ", "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, clear=True,
) )
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -262,7 +262,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok") 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
@@ -284,14 +284,14 @@ class TestMain:
assert "PYPI_TOKEN not set" in result.output assert "PYPI_TOKEN not set" in result.output
mock_tea.create_release.assert_called_once() 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: def test_missing_repo_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -305,7 +305,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "build" in result.output 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -326,7 +326,7 @@ class TestMain:
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" "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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -343,7 +343,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "Release creation failed" in result.output 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
@@ -361,7 +361,7 @@ class TestMain:
mock_build.assert_not_called() mock_build.assert_not_called()
mock_tea.create_release.assert_called_once() mock_tea.create_release.assert_called_once()
@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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -379,7 +379,7 @@ class TestMain:
assert "already exists" in result.output assert "already exists" in result.output
mock_tea.create_release.assert_not_called() mock_tea.create_release.assert_not_called()
@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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -395,7 +395,7 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code == 0
@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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -419,7 +419,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "already exists" in result.output assert "already exists" 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.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -521,7 +521,7 @@ class TestFromTag:
@patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") @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: def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = [] mock_tea.list_releases.return_value = []
@@ -535,7 +535,7 @@ class TestFromTag:
@patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") @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: def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = [] mock_tea.list_releases.return_value = []
@@ -551,3 +551,37 @@ class TestFromTag:
result = runner.invoke(main, ["", "owner/repo", "--skip-build"]) result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Tag is required" in result.output 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()
+33 -7
View File
@@ -34,20 +34,20 @@ from devx.ci.release import (
class TestRunCmd: class TestRunCmd:
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None: def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
result = run_cmd(["echo", "hi"]) result = run_cmd(["echo", "hi"])
assert result.returncode == 0 assert result.returncode == 0
mock_run.assert_called_once() mock_run.assert_called_once()
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None: def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
with pytest.raises(click.ClickException): with pytest.raises(click.ClickException):
run_cmd(["false"]) run_cmd(["false"])
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_check_false_no_raise(self, mock_run: MagicMock) -> None: def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
result = run_cmd(["false"], check=False) result = run_cmd(["false"], check=False)
@@ -802,7 +802,7 @@ class TestCommitReleaseChanges:
assert result is True assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list] 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", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None: def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
@@ -811,7 +811,7 @@ class TestCommitReleaseChanges:
result = commit_release_changes("0.1.0") result = commit_release_changes("0.1.0")
assert result is False assert result is False
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0 [skip ci]"] not in calls
class TestCreateAndPushTag: class TestCreateAndPushTag:
@@ -1207,7 +1207,7 @@ class TestMain:
@patch("devx.ci.release.update_init_version") @patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog") @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_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.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_full_flow_tag_exists( def test_full_flow_tag_exists(
@@ -1232,7 +1232,33 @@ class TestMain:
result = runner.invoke(main, []) result = runner.invoke(main, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "already existed" in result.output 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.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.verify_tag_consistency", return_value=[])
+4 -4
View File
@@ -120,7 +120,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @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: 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="") mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="")
_configure_tea_login() _configure_tea_login()
@@ -130,7 +130,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @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: def test_login_add_success(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="") list_result = MagicMock(returncode=0, stdout="", stderr="")
add_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.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @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: def test_login_add_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="") list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=1, stdout="", stderr="auth failed") 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("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict( @patch.dict(
"os.environ", "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, clear=True,
) )
def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None: def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
+8 -10
View File
@@ -272,18 +272,16 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_start.assert_called_once_with(60) 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) @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None: def test_exports_github_env(self, mock_start: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available.""" """Should write DOCKER_HOST to GITHUB_ENV when available."""
mock_get.side_effect = lambda key, default="": ( env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default with patch.dict("os.environ", env, clear=True):
) with patch("builtins.open", mock_open()) as mock_file:
with patch("builtins.open", mock_open()) as mock_file: runner = CliRunner()
runner = CliRunner() result = runner.invoke(main, [])
result = runner.invoke(main, []) assert result.exit_code == 0
assert result.exit_code == 0 mock_file.assert_called_with("/tmp/github_env", "a", encoding="utf-8")
mock_file.assert_called_with("/tmp/github_env", "a")
@patch("devx.molecule.start_docker.os.environ.get", return_value="") @patch("devx.molecule.start_docker.os.environ.get", return_value="")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
+17 -15
View File
@@ -286,7 +286,7 @@ class TestVerifyWikiIntegrity:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.sync_wiki.MAPPING_FILE") @patch("devx.ci.sync_wiki.MAPPING_FILE")
@patch("devx.ci.sync_wiki.DOCS_DIR") @patch("devx.ci.sync_wiki.DOCS_DIR")
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
@@ -301,14 +301,16 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "dry-run" in result.output assert "dry-run" 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: def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"]) result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True) @patch.dict(
"os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True
)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None: def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
"""Test that repo is auto-detected from env vars when --repo is not passed.""" """Test that repo is auto-detected from env vars when --repo is not passed."""
@@ -322,7 +324,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_client_cls.assert_called_once() mock_client_cls.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None: def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
"""Test that missing mapping.json exits with error.""" """Test that missing mapping.json exits with error."""
@@ -333,7 +335,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "mapping.json" in result.output assert "mapping.json" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
"""Test that existing wiki pages are reported.""" """Test that existing wiki pages are reported."""
@@ -347,7 +349,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "existing wiki pages" in result.output assert "existing wiki pages" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None: def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files cause an error, not a warning.""" """Test that missing doc files cause an error, not a warning."""
@@ -361,7 +363,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "not found" in result.output assert "not found" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None: def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files cause an error, not a warning.""" """Test that empty doc files cause an error, not a warning."""
@@ -375,7 +377,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "empty" in result.output.lower() assert "empty" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_create_and_update(self, mock_client_cls: MagicMock) -> None: def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
"""Test that pages are created and updated correctly (non-dry-run).""" """Test that pages are created and updated correctly (non-dry-run)."""
@@ -393,7 +395,7 @@ class TestMain:
assert "Created: 1" in result.output assert "Created: 1" in result.output
assert "Updated: 1" in result.output assert "Updated: 1" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None: def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches.""" """Test that --verify passes when content matches."""
@@ -413,7 +415,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Verification passed" in result.output assert "Verification passed" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None: def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content.""" """Test that --verify fails when wiki pages have empty content."""
@@ -430,7 +432,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "FAIL" in result.output assert "FAIL" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run.""" """Test that --verify is skipped during dry-run."""
@@ -444,7 +446,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Verification" not in result.output assert "Verification" not 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None: def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds.""" """Test that --strict passes when integrity check succeeds."""
@@ -461,7 +463,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Integrity check passed" in result.output assert "Integrity check passed" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None: def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues.""" """Test that --strict fails when integrity check finds issues."""
@@ -483,7 +485,7 @@ class TestMain:
assert "Missing page: FAQ" in result.output assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output assert "Stale page: Old-Page" 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.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run.""" """Test that --strict verification is skipped during dry-run."""