Compare commits

...
230 Commits
Author SHA1 Message Date
jt 456af932a4 Merge pull request 'refactor: update merge conflict detection strategy (merge --no-commit to merge-tree --write-tree)' (#72) from refactor/merge-conflict-detections into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 38s
Reviewed-on: #72
2026-10-01 02:54:24 +00:00
jt eed5b606fb refactor: update merge conflict detection strategy (merge --no-commit to merge-tree --write-tree) 2026-09-30 23:53:18 -03:00
jt 14ec438fa1 Merge pull request 'feature: change exit code to 3 when no tag is needed, and skip build / publish in that case' (#71) from feature/no-tag-different-exit-code into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 43s
Reviewed-on: #71
2026-09-29 14:26:22 +00:00
Jonathan Teran 98d5c4ab3d feature: change exit code to 3 when no tag is needed, and skip build / publish in that case 2026-09-29 11:19:46 -03:00
jt 5cff0e4484 Merge pull request 'bugfix: only abort merge when is needed' (#70) from bugfix/abort-merge-when-necessary into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 22s
Reviewed-on: #70
2026-09-28 17:35:38 +00:00
jt 61712b0872 bugfix: only abort merge when is needed 2026-09-28 14:35:10 -03:00
jt 98f71d5481 Merge pull request 'bugfix: commit small improvements' (#69) from bugfix/commit-small-improvements into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 45s
Reviewed-on: #69
2026-09-28 17:27:53 +00:00
jt 76dd177421 refactor: inform instead of confirming current branch 2026-09-28 14:27:28 -03:00
jt e2e1c4b417 feature: change table border style 2026-09-28 14:23:49 -03:00
jt e13beae279 feature: show table with staged changes before comit 2026-09-28 14:22:49 -03:00
jt d54c740748 Merge pull request 'Bugfix/missing click module' (#68) from bugfix/missing-click-module into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 44s
Reviewed-on: #68
2026-09-28 13:04:28 +00:00
jt 86a2768c81 bugfix: remove import click and supress typer errors only 2026-09-28 10:03:39 -03:00
jt 9dee43705a clean: delete test csv 2026-09-28 09:57:30 -03:00
jt aacc5b90e2 bugfix: prevent commits on environments 2026-09-26 21:15:20 -03:00
jt 103eb1137a Merge pull request 'feature: add branch types, commit types, support for breaking changes' (#67) from feature/extend-branch-and-commit-types into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 16s
Reviewed-on: #67
2026-09-26 21:12:39 +00:00
jt e9d88b8113 feature: add branch types, commit types, support for breaking changes 2026-09-26 18:12:14 -03:00
jt 6e760f7866 Merge pull request 'feature: show commit lists in merge and rebase in a table' (#66) from feature/display-commits-in-table into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 18s
Reviewed-on: #66
2026-09-26 20:40:22 +00:00
jt 67eb6fb6d1 feature: show commit lists in merge and rebase in a table 2026-09-26 17:38:05 -03:00
jt 8ba5f8f5da Merge pull request 'feature: add verbose flag, improve command output, improve error display' (#64) from feature/improve-verbose-output into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 45s
Reviewed-on: #64
2026-09-26 20:19:41 +00:00
jt 839f71962f feature: add verbose flag, improve command output, improve error display 2026-09-26 17:18:34 -03:00
jt 26ac95e5cf Merge pull request 'docs: update pipeline examples, installation guide, add badges' (#63) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 23s
Reviewed-on: #63
2026-09-25 12:54:24 +00:00
Jonathan Teran 8a28ce3a7e docs: update pipeline examples, installation guide, add badges 2026-09-25 09:53:44 -03:00
jt 3d65356f7a Merge pull request 'feature: when merging environments, promote tag from previous env to next env' (#62) from feature/promote-tag-when-merging-environments into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 40s
Reviewed-on: #62
2026-09-24 15:07:02 +00:00
Jonathan Teran 44fa8bc1d6 feature: when merging environments, promote tag from previous env to next env 2026-09-24 12:06:19 -03:00
jt 631c507064 Merge pull request 'feature: add branch clean command' (#61) from feature/add-branch-clean-command into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 41s
Reviewed-on: #61
2026-09-23 00:41:19 +00:00
jt b20c7e0cb5 feature: include author email in report, generate report filename 2026-09-22 21:39:48 -03:00
jt 6a4f6c9804 feature: add branch clean subcommand 2026-09-22 01:32:10 -03:00
jt 5ece118c05 refactor: move branch command to branch ls subcommand 2026-09-22 01:20:33 -03:00
jt d383fa4003 Merge pull request 'docs: remove skip step logic, fail if no new tag' (#58) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 9s
Reviewed-on: #58
2026-09-21 19:27:21 +00:00
jt 3a1220f257 docs: remove skip step logic, fail if no new tag 2026-09-21 16:26:35 -03:00
jt 83dc348fff Merge pull request 'docs: cleanup pipeline steps' (#57) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 8s
Reviewed-on: #57
2026-09-21 19:24:23 +00:00
jt 5f65df64e4 docs: cleanup pipeline steps 2026-09-21 16:24:04 -03:00
jt e4a633e2e2 Merge pull request 'docs: update readme to reflect new tag command' (#56) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 22s
Reviewed-on: #56
2026-09-21 19:22:21 +00:00
jt ae81260e38 docs: update readme to reflect new tag command 2026-09-21 16:22:01 -03:00
jt 04c05ca8ce Merge pull request 'bugfix: check if merge commit already has a tag before tagging' (#55) from bugfix/check-merge-commit-tag into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 18s
Reviewed-on: #55
2026-09-21 18:12:13 +00:00
jt 0d1ce5305b bugfix: check if merge commit already has a tag before tagging 2026-09-21 15:11:47 -03:00
jt a17d9c308a Merge pull request 'bugfix: get target from tag options' (#54) from bugfix/clean-tag-command into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 19s
Reviewed-on: #54
2026-09-21 17:35:49 +00:00
jt 7121d40ed8 bugfix: get target from tag options 2026-09-21 14:35:27 -03:00
jt 41b7a9e602 Merge pull request 'bugfix: use full commit sha' (#53) from bugfix/clean-tag-command into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 18s
Reviewed-on: #53
2026-09-21 17:18:57 +00:00
jt f35dbc126a bugfix: use full commit sha 2026-09-21 14:17:43 -03:00
jt 410c5f9356 Merge pull request 'bugfix: remove debug log' (#52) from bugfix/clean-tag-command into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 19s
Reviewed-on: #52
2026-09-21 16:54:35 +00:00
jt 7a24ccb24c bugfix: remove debug log 2026-09-21 13:49:50 -03:00
gitea-actions dc35b635a1 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-21 16:46:23 +00:00
jt 5171100c76 Merge pull request 'feature: add fetch depth' (#51) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 19s
Reviewed-on: #51
2026-09-21 16:46:06 +00:00
jt 68cec91506 feature: add fetch depth 2026-09-21 13:45:50 -03:00
jt 02c081f68f Merge pull request 'feature: add debug info' (#50) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 16s
Reviewed-on: #50
2026-09-21 16:42:18 +00:00
jt a83d693584 feature: add debug info 2026-09-21 13:41:59 -03:00
jt b51544ddab Merge pull request 'feature: show errors on ci' (#49) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 16s
Reviewed-on: #49
2026-09-21 16:40:30 +00:00
jt d5a88d6c8d feature: show errors on ci 2026-09-21 13:40:11 -03:00
jt e144048429 Merge pull request 'feature: remove file parameter on status report' (#48) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 16s
Reviewed-on: #48
2026-09-21 16:37:23 +00:00
jt d7f2e569da feature: remove file parameter on status report 2026-09-21 13:37:05 -03:00
jt 45fe22aea7 Merge pull request 'feature: add debug info' (#47) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 17s
Reviewed-on: #47
2026-09-21 16:30:53 +00:00
jt c194b555c3 feature: add debug info 2026-09-21 13:29:58 -03:00
jt 60c29b6aa5 Merge pull request 'feature: install uv before bump, run using uv' (#46) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 25s
Reviewed-on: #46
2026-09-21 16:26:26 +00:00
jt 488e9172cb feature: remove condition from install uv step 2026-09-21 13:23:31 -03:00
jt c23a890f8e Merge pull request 'feature: install uv before bump, run using uv' (#45) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 16s
Reviewed-on: #45
2026-09-21 16:22:33 +00:00
jt dd79b478e2 feature: install uv before bump, run using uv 2026-09-21 12:28:38 -03:00
jt 0a143909f5 Merge pull request 'feature: add uv install step' (#44) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 24s
Reviewed-on: #44
2026-09-21 15:26:28 +00:00
jt 6a44b20556 feature: add uv install step 2026-09-21 12:26:11 -03:00
jt cc816b4244 Merge pull request 'feature: use command status to skip next job' (#43) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 9s
Reviewed-on: #43
2026-09-21 15:24:10 +00:00
jt 8ff91e9c45 feature: use command status to skip next job 2026-09-21 12:23:49 -03:00
jt 865c39e4f2 Merge pull request 'bugfix: show error when no next tag availbale' (#41) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 9s
Reviewed-on: #41
2026-09-21 15:13:42 +00:00
jt f14c77e144 bugfix: show error when no next tag availbale 2026-09-21 12:13:18 -03:00
jt 01631cfddd Merge pull request 'feature: dont update changelog by default' (#40) from feature/prevent-changelog-update into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 9s
Reviewed-on: #40
2026-09-21 14:29:46 +00:00
jt ad76e13673 feature: dont update changelog by default 2026-09-21 11:28:28 -03:00
jt d054881f3e Merge pull request 'docs: add output to version step' (#38) from chore/update-workflow into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 8s
Reviewed-on: #38
2026-09-21 14:08:37 +00:00
jt ab3023acb7 docs: add output to version step 2026-09-21 11:08:22 -03:00
jt aa63815cc6 Merge pull request 'docs: fix workflow syntax' (#37) from chore/update-workflow into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 8s
Reviewed-on: #37
2026-09-21 14:06:56 +00:00
jt 9ef3100871 docs: fix workflow syntax 2026-09-21 11:06:38 -03:00
jt 201e2b54a6 Merge pull request 'docs: add condition checking before build and publish' (#36) from chore/update-workflow into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 8s
Reviewed-on: #36
2026-09-21 14:05:48 +00:00
jt a5e50b8b16 docs: add condition checking before build and publish 2026-09-21 11:05:28 -03:00
jt df09810806 Merge pull request 'docs: update workflow' (#35) from chore/update-workflow into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 7s
Reviewed-on: #35
2026-09-21 14:00:31 +00:00
Jonathan Teran Carballo e44cd98696 docs: update workflow 2026-09-21 11:00:14 -03:00
gitea-actions 57c6ff7a4b chore: bump version and update CHANGELOG.md [skip ci] 2026-09-21 13:57:25 +00:00
jt 4a39fe95d4 Merge pull request 'bugfix: tag locally for workflow to run' (#33) from bugfix/return-empty-on-remote-tag-error into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 19s
Reviewed-on: #33
2026-09-21 13:57:08 +00:00
Jonathan Teran Carballo 24a40c99bc bugfix: tag locally for workflow to run 2026-09-21 10:56:42 -03:00
gitea-actions bd03cf218b chore: bump version and update CHANGELOG.md [skip ci] 2026-09-21 13:54:46 +00:00
jt e9e7d2ad6e Merge pull request 'bugfix: fix workflow pipeline' (#32) from bugfix/return-empty-on-remote-tag-error into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 20s
Reviewed-on: #32
2026-09-21 13:54:33 +00:00
Jonathan Teran Carballo 82c78c727a bugfix: fix workflow pipeline 2026-09-21 10:54:16 -03:00
jt 6d65462dbf Merge pull request 'bugfix: return empty on remote tag error' (#28) from bugfix/return-empty-on-remote-tag-error into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 8s
Reviewed-on: #28
2026-09-21 13:45:48 +00:00
Jonathan Teran Carballo 6df7206eec bugfix: print errors in stderr 2026-09-21 10:45:16 -03:00
Jonathan Teran Carballo e793feaa1e bugfix: only return tag on remote tag command, empty if error 2026-09-21 10:44:48 -03:00
jt d03cf964e1 Merge pull request 'docs: fix workflow yaml' (#27) from chore/update-pipeline into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 12s
Reviewed-on: #27
2026-09-21 12:53:52 +00:00
Jonathan Teran Carballo 9d17cf0320 docs: fix workflow yaml 2026-09-21 09:53:28 -03:00
jt 8c95835207 Merge pull request 'docs: update pipeline workflow' (#26) from chore/update-pipeline into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 0s
Reviewed-on: #26
2026-09-21 12:49:27 +00:00
Jonathan Teran Carballo 7c69936360 docs: update pipeline workflow 2026-09-21 09:48:01 -03:00
gitea-actions 4cf6321b80 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-21 12:45:33 +00:00
jt ae61fa4fa4 Merge pull request 'refactor: update remote tag fetching behaviour so it uses api instead of shallow clone' (#25) from refactor/remote-tag-fetching-behaviour into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 37s
Reviewed-on: #25
2026-09-21 12:45:04 +00:00
Jonathan Teran Carballo 22ba8f5e5f refactor: update remote tag fetching behaviour so it uses api instead of shallow clone 2026-09-21 09:15:48 -03:00
gitea-actions eabef5eb33 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-21 00:12:35 +00:00
jt ff61238bfe Merge pull request 'feature: tag remote fetch commits from api' (#24) from feature/tag-remote-fetch-commits-from-api into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 41s
Reviewed-on: #24
2026-09-21 00:11:58 +00:00
Jonathan Teran Carballo c50688bb1a bugfix: fix parameters for changelog generate_content 2026-09-20 21:08:12 -03:00
Jonathan Teran Carballo 26ab81b175 feature: tag from ci fetches commits from remote api 2026-09-20 21:05:21 -03:00
gitea-actions 889109bf77 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-18 14:27:33 +00:00
jt 164eded8f8 Merge pull request 'bugfix: muevo titulo de error al panel, agrego repositorio a gitlab error' (#23) from bugfix/add-debug-error-info-2 into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 18s
Reviewed-on: #23
2026-09-18 14:27:19 +00:00
Jonathan Teran ce1ab99ddc bugfix: muevo titulo de error al panel, agrego repositorio a gitlab error 2026-09-18 11:26:55 -03:00
gitea-actions 8f96cb21f6 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-18 13:27:25 +00:00
jt 9fa7fa41bd Merge pull request 'bugfix: agrego informacion de traceback para errores de GitFlow' (#22) from bugfix/add-debug-error-info into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 38s
Reviewed-on: #22
2026-09-18 13:26:51 +00:00
Jonathan Teran 8a4cf6e667 bugfix: agrego informacion de traceback para errores de GitFlow 2026-09-18 10:25:52 -03:00
gitea-actions a502541e35 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-17 14:52:53 +00:00
jt b6370648f9 Merge pull request 'bugfix: apply release prefix and ticket detection to single-commit PR titles' (#21) from bugfix/pr-title-single-commit into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 19s
Reviewed-on: #21
2026-09-17 14:52:37 +00:00
Jonathan Teran e0200e3695 bugfix: apply release prefix and ticket detection to single-commit PR titles 2026-09-17 11:36:36 -03:00
gitea-actions 40f3e6ba49 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-17 13:58:06 +00:00
jt 77d9a224fa Merge pull request 'feature: downgrade python version for better compatibility' (#18) from feature/downgrade-python-version into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 1m2s
Reviewed-on: #18
2026-09-17 13:57:07 +00:00
Jonathan Teran 55172bda74 feature: downgrade python version for better compatibility 2026-09-17 10:56:34 -03:00
gitea-actions b8fff9e77a chore: bump version and update CHANGELOG.md [skip ci] 2026-09-15 19:53:13 +00:00
jt f6a7f1a987 Merge pull request 'bugfix: include group path in repo for gitlab support' (#17) from bugfix/gitlab-include-group-path into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 44s
Reviewed-on: #17
2026-09-15 19:52:35 +00:00
Jonathan Teran 3e448544f0 bugfix: include group path in repo for gitlab support 2026-09-15 16:50:45 -03:00
jt 75a77201d4 Merge pull request 'docs: actualizar README con secciones por remoto y ejemplos de pipeline' (#16) from worktree-kind-watching-hearth into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 29s
Reviewed-on: #16
2026-09-14 04:03:06 +00:00
jtandClaude Sonnet 5 e8c0c0b777 docs: actualizar README con secciones por remoto y ejemplos de pipeline
Documenta como generar tokens de acceso para Github, Gitlab, Bitbucket y
Gitea, y agrega ejemplos de pipeline para taggeo automatico en cada uno.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:18:47 -03:00
gitea-actions d4dc694db6 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-14 00:23:39 +00:00
jt 9120698900 Merge pull request 'refactor: remove release prompts' (#15) from refactor/remove-release-prompts into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 17s
Reviewed-on: #15
2026-09-14 00:23:28 +00:00
jt c035a8867e refactor: cambiar incremento de commits refactor y perf 2026-09-13 21:22:41 -03:00
jt 0c31c747ff refactor: eliminar preguntas innecesarias de release y merge 2026-09-13 21:22:14 -03:00
gitea-actions 12ce68d954 chore: bump version and update CHANGELOG.md [skip ci] 2026-09-13 23:56:52 +00:00
jt 29eacfdc4d Merge pull request 'feature: add gitlab remote support' (#14) from feature/gitlab-remote-support into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 2m40s
Reviewed-on: #14
2026-09-13 23:54:15 +00:00
jt 748d4201cc feature: add gitlab remote support 2026-09-13 20:48:49 -03:00
gitea-actions 94a219ed82 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-31 15:25:28 +00:00
jt 3f3650360a Merge pull request 'Feature/parse git remote url with library' (#12) from feature/parse-git-remote-url-with-library into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 55s
Reviewed-on: #12
2026-07-31 15:24:35 +00:00
Jonathan Teran 11a48a922c style: format with ruff 2026-07-31 12:22:39 -03:00
Jonathan Teran f4adb431db feature: parse git remote url with giturlparse 2026-07-31 12:22:14 -03:00
gitea-actions 8f1a60cc06 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-29 14:56:49 +00:00
jt fcc8114c8e Merge pull request 'bugfix: change checkout version to v7' (#11) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Successful in 16s
Reviewed-on: #11
2026-07-29 14:56:38 +00:00
Jonathan Teran Carballo c58c4cea96 bugfix: change checkout version to v7 2026-07-29 11:55:54 -03:00
jt 7fbf499278 Merge pull request 'bugfix: move git fetch to separate line' (#10) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 8s
Reviewed-on: #10
2026-07-29 14:49:33 +00:00
Jonathan Teran Carballo 64fc890cb7 bugfix: move git fetch to separate line 2026-07-29 11:49:25 -03:00
gitea-actions 7199249f75 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-29 14:47:56 +00:00
jt 7efaf367e5 Merge pull request 'bugfix: change actions/checkout to v4 and add step to fetch tags' (#9) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 17s
Reviewed-on: #9
2026-07-29 14:47:38 +00:00
Jonathan Teran Carballo dc79ffa399 bugfix: change actions/checkout to v4 and add step to fetch tags 2026-07-29 11:47:00 -03:00
jt 31513a3e3a Merge pull request 'bugfix: change actions/checkout version' (#8) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 19s
Reviewed-on: #8
2026-07-29 14:45:20 +00:00
Jonathan Teran Carballo c45f065e77 bugfix: change actions/checkout version 2026-07-29 11:45:12 -03:00
jt 7f7dd7b538 Merge pull request 'bugfix: add fetch-tags: true' (#7) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 14s
Reviewed-on: #7
2026-07-29 14:41:06 +00:00
Jonathan Teran Carballo adcb53156a bugfix: add fetch-tags: true 2026-07-29 11:40:54 -03:00
jt 5ca817c796 Merge pull request 'bugfix: remove checkout parameters' (#6) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 14s
Reviewed-on: #6
2026-07-29 14:35:04 +00:00
Jonathan Teran Carballo 2be9b50ac4 bugfix: remove checkout parameters 2026-07-29 11:34:43 -03:00
gitea-actions 347b8b9556 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-29 13:57:37 +00:00
jt 9b547bd6a9 Merge pull request 'feature: change flow settings in local config' (#5) from feature/change-flow-settings-in-local-config into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 29s
Reviewed-on: #5
2026-07-29 13:57:06 +00:00
Jonathan Teran Carballo f05582b50f feature: show commit count and list tags on workflow 2026-07-29 10:53:56 -03:00
gitea-actions a1a9a7acac chore: bump version and update CHANGELOG.md [skip ci] 2026-07-18 03:16:04 +00:00
jt 190b4b8408 Merge pull request 'bugfix: add fetch tags to checkout step' (#4) from bugfix/fetch-tags-on-checkout into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 11s
Reviewed-on: #4
2026-07-18 03:15:50 +00:00
jt f9fb5a62d1 bugfix: add fetch tags to checkout step 2026-07-18 00:15:19 -03:00
gitea-actions 09d0de2860 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-18 01:35:18 +00:00
jt f233adfd1d Merge pull request 'feature: print current tag command output' (#3) from feature/log-current-tag-command into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 10s
Reviewed-on: #3
2026-07-18 01:35:03 +00:00
jt 0cdc01291c feature: print current tag command output 2026-07-17 22:34:39 -03:00
gitea-actions 098a4724ca chore: bump version and update CHANGELOG.md [skip ci] 2026-07-18 01:13:08 +00:00
jt 9167390098 Merge pull request 'feature: update I/O methods to use questionary where applicable' (#2) from feature/refactor-use-questionary-package into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 43s
Reviewed-on: #2
2026-07-18 01:12:22 +00:00
jt a08666cac3 style: organize imports 2026-07-17 22:07:37 -03:00
jt 726df217ec style: format and organize imports 2026-07-17 22:04:56 -03:00
jt 07d4ff960e refactor: use base.io_ methods 2026-07-17 22:01:42 -03:00
jt 919e680e75 refactor: use base.io_ methods 2026-07-16 22:26:48 -03:00
jt add4cd2747 refactor: use base.io_ methods 2026-07-16 22:25:20 -03:00
jt e4833c9ed9 style: run black formatter on merge.py 2026-07-16 22:12:30 -03:00
jt 7315681b23 style: run black formatter on commit.py 2026-07-16 22:11:35 -03:00
jt ba1c9bd68d style: run black formatter on new.py 2026-07-16 22:11:07 -03:00
jt 1a7ae338ff style: run black formatter 2026-07-16 22:09:28 -03:00
jt 0120faac9e refactor: use new base.io_ methods 2026-07-16 22:08:46 -03:00
jt febd18c3b3 refactor: use unsafe_ask in favor of global error catch 2026-07-16 22:02:47 -03:00
jt 069c33bc17 style: run black formatter 2026-07-16 21:48:28 -03:00
jt 1d186e117c refactor: reword current branch check, move if around so it's better placed 2026-07-16 21:47:27 -03:00
jt c0f7642687 feature: change unicode status icons 2026-07-16 21:35:41 -03:00
jt a884c28094 Merge pull request 'refactor: use questionary package' (#1) from refactor/use-questionary-package into main
Publish to PyPI / Publish-to-PyPI (push) Failing after 12s
Reviewed-on: #1
2026-07-14 03:32:09 +00:00
jt 0782cfb041 refactor: migrar comando merge para que utilice questionary 2026-07-14 00:28:20 -03:00
jt 320d40a211 refactor: migrar comando commit para que utilice questionary 2026-07-14 00:23:36 -03:00
jt a5a753f429 refactor: migrar comando new para que utilice metodos usando questionary 2026-07-14 00:22:22 -03:00
gitea-actions 4968667dad chore: bump version and update CHANGELOG.md [skip ci] 2026-07-09 21:15:32 +00:00
jt 79ae88b012 Merge pull request 'bugfix: actualizo versiones de checkout, setup-python, clonar depth completo' (#10) from bugfix/publish-pypi-workflow into main
Reviewed-on: #10
2026-07-09 21:15:00 +00:00
jt 4666503f32 bugfix: actualizo versiones de checkout, setup-python, clonar depth completo 2026-07-09 18:14:38 -03:00
gitea-actions 4c089eb8da chore: bump version and update CHANGELOG.md [skip ci] 2026-07-09 21:05:19 +00:00
jt dc858d026f Merge pull request 'feature: add publish pypi package workflow' (#9) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #9
2026-07-09 21:05:03 +00:00
jt 5989699f08 bugfix: usar https por defecto como esquema si se detecta ssh 2026-07-09 18:04:40 -03:00
jt ecfe058f8d feature: usar esquema de remoto para host de api gitea 2026-07-09 18:00:39 -03:00
gitea-actions ffe813bf06 chore: bump version and update CHANGELOG.md [skip ci] 2026-07-09 01:21:44 +00:00
jt 6cd6771241 Merge pull request 'bugfix: agregar setup previo a ejecucion de git tag' (#8) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #8
2026-07-09 01:21:35 +00:00
jt 1cc2de0b75 bugfix: agregar setup previo a ejecucion de git tag 2026-07-08 22:21:17 -03:00
jt 0e669019f4 Merge pull request 'bugfix: arreglo acceso a secretos' (#7) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #7
2026-07-09 01:11:06 +00:00
jt d456565b2f bugfix: arreglo acceso a secretos 2026-07-08 22:10:35 -03:00
jt 74a78d30a1 Merge pull request 'bugfix: agrego fetch-depth a checkout' (#6) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #6
2026-07-09 01:08:20 +00:00
jt ad8984cb28 bugfix: agrego fetch-depth a checkout 2026-07-08 22:08:02 -03:00
jt 53b02f1f8f Merge pull request 'bugfix: agrego log de git luego del checkout' (#5) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #5
2026-07-09 01:06:41 +00:00
jt bf6a0e8443 bugfix: agrego log de git luego del checkout 2026-07-08 22:06:24 -03:00
jt 12351e64ba Merge pull request 'bugfix: cambio version de action setup-uv' (#4) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #4
2026-07-09 00:59:09 +00:00
jt cb742425c1 bugfix: cambio version de action setup-uv 2026-07-08 21:58:52 -03:00
jt bd47b40aff Merge pull request 'feature: agrego workflow para subir paquete a pypi' (#3) from feature/add-publish-pypi-package-workflow into main
Reviewed-on: #3
2026-07-09 00:55:53 +00:00
jt e94ee2ccc7 feature: agrego workflow para subir paquete a pypi 2026-07-08 21:55:16 -03:00
jt 069c62b697 Merge pull request 'feature: agrego implementacion de github remote api' (#2) from feature/add-github-remote-api into main
Reviewed-on: #2
2026-07-09 00:28:52 +00:00
jt c18ca454c0 feature: agrego implementacion de github remote api 2026-07-08 21:27:42 -03:00
jt 0fcf53bcb5 Merge pull request 'feature: add gitea remote api' (#1) from feature/add-gitea-remote-api into main
Reviewed-on: #1
2026-07-09 00:22:34 +00:00
jt f9dddbc4d6 bugfix: agregar schema a host de gitea api 2026-07-08 21:20:43 -03:00
jt c7888c74f1 bugfix: corregir headers de gitea 2026-07-08 21:19:24 -03:00
jt 87c5ba81c4 feature: agrego soporte para apis gitea 2026-07-08 21:12:06 -03:00
jt a10e9fe4fe Add .gitea/workflows/demo.yaml 2026-07-08 02:56:08 +00:00
bitbucket-pipelines c93833769b chore: bump version and update CHANGELOG.md [skip ci] 2026-06-03 00:40:26 +00:00
JonathanGitFlowandjt c536deb54d Merged in bugfix/allow-commit-messages-non-conventional (pull request #77)
bugfix: allow commits not compliant with conventional commits in changelog generation

Approved-by: Jonathan Teran
2026-06-03 00:39:43 +00:00
jt 3ff9f76a6c bugfix: allow commits not compliant with conventional commits in changelog generation 2026-06-02 21:38:18 -03:00
bitbucket-pipelines 1c319beff4 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-24 05:42:13 +00:00
JonathanGitFlowandjt f885c27e08 Merged in bugfix/pr-title-with-ticket (pull request #76)
bugfix: use rest of branch name instead of ticket for pr title generation

Approved-by: Jonathan Teran
2026-05-24 05:41:05 +00:00
jt 094bc1a698 bugfix: use rest of branch name instead of ticket for pr title generation 2026-05-24 02:40:33 -03:00
bitbucket-pipelines 9b0d7d6166 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-22 19:20:02 +00:00
JonathanGitFlowandjt f52edf03e0 Merged in bugfix/prevent-close-branch-on-environment-merges (pull request #75)
bugfix: dont close branches on merges between environments

Approved-by: Jonathan Teran
2026-05-22 19:19:30 +00:00
Jonathan Teran 8ee01290ef bugfix: dont close branches on merges between environments 2026-05-22 16:18:56 -03:00
bitbucket-pipelines 9c89f4d638 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-22 19:14:59 +00:00
JonathanGitFlowandjt 8d35610c00 Merged in bugfix/customize-pr-title-on-environment-merges (pull request #74)
bugfix: change pr title on merges between environments

Approved-by: Jonathan Teran
2026-05-22 19:14:19 +00:00
Jonathan Teran de124baf1a bugfix: change pr title on merges between environments 2026-05-22 16:13:39 -03:00
bitbucket-pipelines 2676f41def chore: bump version and update CHANGELOG.md [skip ci] 2026-05-20 20:14:50 +00:00
JonathanGitFlowandjt 22fd68c5b6 Merged in bugfix/dont-push-environment-branches-on-merge (pull request #73)
bugfix: dont push environment branches since they are already created on remote

Approved-by: Jonathan Teran
2026-05-20 20:14:08 +00:00
jt 04c9a4b449 bugfix: dont push environment branches since they are already created on remote 2026-05-20 17:13:34 -03:00
JonathanGitFlowandjt 417021b36e Merged in refactor/unify-flow-environments-usage (pull request #72)
refactor: unify flow branches usage

Approved-by: Jonathan Teran
2026-05-20 20:12:26 +00:00
jt be245114d5 refactor: unify flow branches usage 2026-05-20 17:10:58 -03:00
bitbucket-pipelines 184b135886 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-20 20:01:41 +00:00
JonathanGitFlowandjt acc0e8666a Merged in feature/allow-environment-merges (pull request #71)
feature: allow merges between environments

Approved-by: Jonathan Teran
2026-05-20 20:00:57 +00:00
jt 5806aeab27 feature: allow merges between environments 2026-05-20 17:00:20 -03:00
bitbucket-pipelines 23eb9df482 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-20 12:20:37 +00:00
JonathanGitFlowandjt 538c9130dc Merged in bugfix/pr-title-getter (pull request #70)
bugfix: arreglo split de branch para armar correctamente el titulo del PR

Approved-by: Jonathan Teran
2026-05-20 12:19:58 +00:00
Jonathan Teran Carballo a0d3028904 bugfix: arreglo split de branch para armar correctamente el titulo del PR 2026-05-20 09:19:24 -03:00
bitbucket-pipelines 59b9e1c419 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-20 02:30:52 +00:00
JonathanGitFlowandjt 8793248dbe Merged in feature/add-ticket-support (pull request #69)
feature: agrego soporte para especificar tickets en nombre e rama

Approved-by: Jonathan Teran
2026-05-20 02:30:18 +00:00
jt f389118515 feature: agrego soporte para especificar tickets en nombre e rama 2026-05-19 23:29:22 -03:00
JonathanGitFlowandjt 5a7ed03801 Merged in refactor/move-constants-to-git_flow (pull request #68)
refactor: move constants to git_flow module

Approved-by: Jonathan Teran
2026-05-17 14:48:46 +00:00
Jonathan Teran Carballo 5f7db6cf5d refactor: move constants to git_flow module 2026-05-17 11:48:02 -03:00
bitbucket-pipelines 8929438fbf chore: bump version and update CHANGELOG.md [skip ci] 2026-05-17 14:38:22 +00:00
JonathanGitFlowandjt d35dab9046 Merged in feature/include-revlist-count-in-version (pull request #67)
feature: include revlist count in version tag when skipping bump

Approved-by: Jonathan Teran
2026-05-17 14:37:38 +00:00
jt d275ac08f3 feature: include revlist count in version tag when skipping bump 2026-05-17 11:37:04 -03:00
JonathanGitFlowandjt 9b5584bbfe Merged in refactor/migrate-from-class-to-scripts (pull request #66)
refactor: migrate from class to scripts

Approved-by: Jonathan Teran
2026-05-17 14:28:48 +00:00
jt 9ccffd502d refactor: remove unused constants, move io functions to module 2026-05-17 11:27:17 -03:00
jt 0ac04aee84 refactor: migrate from class commands to scripts commands 2026-05-17 11:19:30 -03:00
JonathanGitFlowandjt 9ffefafad0 Merged in refactor/migrate-to-typer-framework (pull request #65)
refactor: replace global command class to typer functions

Approved-by: Jonathan Teran
2026-05-16 22:01:32 +00:00
jt fec30cd1e8 refactor: replace global command class to typer functions 2026-05-16 18:59:45 -03:00
bitbucket-pipelines e87571af8b chore: bump version and update CHANGELOG.md [skip ci] 2026-02-11 10:47:35 +00:00
JonathanGitFlowandjt e504887f2f Merged in feature/update-project-name-in-lock (pull request #64)
feature: update project name in lock

Approved-by: Jonathan Teran
2026-02-11 10:47:04 +00:00
Jonathan Teran Carballo 9cd0b2514b feature: actualizar nombre de proyecto en uv.lock 2026-02-11 07:46:38 -03:00
29 changed files with 2418 additions and 1003 deletions
+2
View File
@@ -2,3 +2,5 @@
remote = origin remote = origin
branches = main branches = main
initialized = true initialized = true
version = 2
remote-type = gitea
+43
View File
@@ -0,0 +1,43 @@
name: Publish to PyPI
on:
push:
branches:
- main
jobs:
Bump-Build-Publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 10
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install git-flow and bump version
id: version
run: |
pip install git-flow-envs=="${{ vars.GITFLOWENVS_VERSION }}"
# El código 3 indica que no hace falta un nuevo tag: se saltea la publicación
status=0
git-flow tag --token="${{ secrets.GITEA_TOKEN }}" || status=$?
if [ "$status" -eq 3 ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
elif [ "$status" -ne 0 ]; then
exit "$status"
fi
- name: Install uv
if: steps.version.outputs.skip != 'true'
uses: astral-sh/setup-uv@v8.3.2
- name: Build and publish
if: steps.version.outputs.skip != 'true'
run: |
uv build
uv publish --token="${{ secrets.PYPI_TOKEN }}"
+3
View File
@@ -9,3 +9,6 @@ dist/
build/ build/
**/__pycache__/ **/__pycache__/
**/*.egg-info/ **/*.egg-info/
# MacOS
.DS_Store
+262
View File
@@ -1,3 +1,265 @@
## v1.23.0 - 2026-09-21
- **feature**: add fetch depth [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (68cec91)
---
## v1.22.3 - 2026-09-21
- **bugfix**: tag locally for workflow to run [[jteran](mailto:jteran@renatre.org.ar)] (24a40c9)
---
## v1.22.2 - 2026-09-21
- **bugfix**: fix workflow pipeline [[jteran](mailto:jteran@renatre.org.ar)] (82c78c7)
---
## v1.22.1 - 2026-09-21
- **refactor**: update remote tag fetching behaviour so it uses api instead of shallow clone [[jteran](mailto:jteran@renatre.org.ar)] (22ba8f5)
---
## v1.22.0 - 2026-09-21
- **feature**: tag from ci fetches commits from remote api [[jteran](mailto:jteran@renatre.org.ar)] (26ab81b)
- **bugfix**: fix parameters for changelog generate_content [[jteran](mailto:jteran@renatre.org.ar)] (c50688b)
---
## v1.21.3 - 2026-09-18
- **bugfix**: muevo titulo de error al panel, agrego repositorio a gitlab error [[jteran](mailto:jteran@renatre.org.ar)] (ce1ab99)
---
## v1.21.2 - 2026-09-18
- **bugfix**: agrego informacion de traceback para errores de GitFlow [[jteran](mailto:jteran@renatre.org.ar)] (8a4cf6e)
---
## v1.21.1 - 2026-09-17
- **bugfix**: apply release prefix and ticket detection to single-commit PR titles [[jteran](mailto:jteran@renatre.org.ar)] (e0200e3)
---
## v1.21.0 - 2026-09-17
- **feature**: downgrade python version for better compatibility [[jteran](mailto:jteran@renatre.org.ar)] (55172bd)
---
## v1.20.2 - 2026-09-15
- **bugfix**: include group path in repo for gitlab support [[jteran](mailto:jteran@renatre.org.ar)] (3e44854)
---
## v1.20.1 - 2026-09-14
- **refactor**: cambiar incremento de commits refactor y perf [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c035a88)
- **refactor**: eliminar preguntas innecesarias de release y merge [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0c31c74)
---
## v1.20.0 - 2026-09-13
- **feature**: add gitlab remote support [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (748d420)
---
## v1.19.0 - 2026-07-31
- **style**: format with ruff [[jteran](mailto:jteran@renatre.org.ar)] (11a48a9)
- **feature**: parse git remote url with giturlparse [[jteran](mailto:jteran@renatre.org.ar)] (f4adb43)
---
## v1.18.0 - 2026-07-29
- **bugfix**: change checkout version to v7 [[jteran](mailto:jteran@renatre.org.ar)] (c58c4ce)
- **bugfix**: move git fetch to separate line [[jteran](mailto:jteran@renatre.org.ar)] (64fc890)
- **bugfix**: change actions/checkout to v4 and add step to fetch tags [[jteran](mailto:jteran@renatre.org.ar)] (dc79ffa)
- **bugfix**: change actions/checkout version [[jteran](mailto:jteran@renatre.org.ar)] (c45f065)
- **bugfix**: add fetch-tags: true [[jteran](mailto:jteran@renatre.org.ar)] (adcb531)
- **bugfix**: remove checkout parameters [[jteran](mailto:jteran@renatre.org.ar)] (2be9b50)
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.1.0-main - 2026-07-29
- **bugfix**: change actions/checkout to v4 and add step to fetch tags [[jteran](mailto:jteran@renatre.org.ar)] (dc79ffa)
- **bugfix**: change actions/checkout version [[jteran](mailto:jteran@renatre.org.ar)] (c45f065)
- **bugfix**: add fetch-tags: true [[jteran](mailto:jteran@renatre.org.ar)] (adcb531)
- **bugfix**: remove checkout parameters [[jteran](mailto:jteran@renatre.org.ar)] (2be9b50)
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.1.0-main - 2026-07-29
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.0.1-main - 2026-07-18
- **bugfix**: add fetch tags to checkout step [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f9fb5a6)
---
## v0.1.0-main - 2026-07-18
- **feature**: print current tag command output [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0cdc012)
---
## v0.1.0-main - 2026-07-18
- **style**: organize imports [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (a08666c)
- **style**: format and organize imports [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (726df21)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (07d4ff9)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (919e680)
- **refactor**: use base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (add4cd2)
- **style**: run black formatter on merge.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e4833c9)
- **style**: run black formatter on commit.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7315681)
- **style**: run black formatter on new.py [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ba1c9bd)
- **style**: run black formatter [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1a7ae33)
- **refactor**: use new base.io_ methods [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0120faa)
- **refactor**: use unsafe_ask in favor of global error catch [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (febd18c)
- **style**: run black formatter [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (069c33b)
- **refactor**: reword current branch check, move if around so it's better placed [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1d186e1)
- **feature**: change unicode status icons [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c0f7642)
---
## v1.17.6 - 2026-07-09
- **bugfix**: actualizo versiones de checkout, setup-python, clonar depth completo [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (4666503)
---
## v0.1.0-main - 2026-07-09
- **bugfix**: usar https por defecto como esquema si se detecta ssh [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (5989699)
- **feature**: usar esquema de remoto para host de api gitea [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ecfe058)
- **bugfix**: agregar setup previo a ejecucion de git tag [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1cc2de0)
- **bugfix**: arreglo acceso a secretos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d456565)
- **bugfix**: agrego fetch-depth a checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ad8984c)
- **bugfix**: agrego log de git luego del checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf6a0e8)
- **bugfix**: cambio version de action setup-uv [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (cb74242)
- **feature**: agrego workflow para subir paquete a pypi [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e94ee2c)
---
## v0.1.0-main - 2026-07-09
- **bugfix**: agregar setup previo a ejecucion de git tag [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1cc2de0)
- **bugfix**: arreglo acceso a secretos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d456565)
- **bugfix**: agrego fetch-depth a checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ad8984c)
- **bugfix**: agrego log de git luego del checkout [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf6a0e8)
- **bugfix**: cambio version de action setup-uv [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (cb74242)
- **feature**: agrego workflow para subir paquete a pypi [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (e94ee2c)
---
## v1.17.5 - 2026-06-03
- **bugfix**: allow commits not compliant with conventional commits in changelog generation [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (3ff9f76)
---
## v1.17.4 - 2026-05-24
- **bugfix**: use rest of branch name instead of ticket for pr title generation [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (094bc1a)
---
## v1.17.3 - 2026-05-22
- **bugfix**: dont close branches on merges between environments [[jteran](mailto:jteran@renatre.org.ar)] (8ee0129)
---
## v1.17.2 - 2026-05-22
- **bugfix**: change pr title on merges between environments [[jteran](mailto:jteran@renatre.org.ar)] (de124ba)
---
## v1.17.1 - 2026-05-20
- **bugfix**: dont push environment branches since they are already created on remote [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (04c9a4b)
---
## v1.17.0 - 2026-05-20
- **feature**: allow merges between environments [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (5806aea)
---
## v1.16.1 - 2026-05-20
- **bugfix**: arreglo split de branch para armar correctamente el titulo del PR [[jteran](mailto:jteran@renatre.org.ar)] (a0d3028)
---
## v1.16.0 - 2026-05-20
- **feature**: agrego soporte para especificar tickets en nombre e rama [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f389118)
---
## v1.15.0 - 2026-05-17
- **feature**: include revlist count in version tag when skipping bump [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d275ac0)
---
## v1.14.0 - 2026-02-11
- **feature**: actualizar nombre de proyecto en uv.lock [[jteran](mailto:jteran@renatre.org.ar)] (9cd0b25)
---
## v1.13.1 - 2026-02-11 ## v1.13.1 - 2026-02-11
- **bugfix**: test pipeline change [[jteran](mailto:jteran@renatre.org.ar)] (f2eb5af) - **bugfix**: test pipeline change [[jteran](mailto:jteran@renatre.org.ar)] (f2eb5af)
+211 -34
View File
@@ -1,5 +1,8 @@
# Git Flow # Git Flow
[![PyPI Version](https://img.shields.io/pypi/v/git-flow-envs)](https://pypi.org/project/git-flow-envs/)
![Python Version](https://img.shields.io/python/required-version-toml?tomlFilePath=https://git.jonathanteran.dev/jt/git-flow/raw/branch/main/pyproject.toml)
Herramienta para establecer un workflow siguiendo [Conventional Commits Herramienta para establecer un workflow siguiendo [Conventional Commits
(CC)](https://www.conventionalcommits.org), [Conventional Branch (CC)](https://www.conventionalcommits.org), [Conventional Branch
(CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/). (CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/).
@@ -9,27 +12,12 @@ derivar versiones automaticamente de los merges sobre ramas principales.
## Instalación ## Instalación
Para instalar Git Flow, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la Se puede instalar con `pip` ejecutando el siguiente comando: `pip install git-flow-envs`. Sin
documentación para instalarlo). Una vez instalado `pipx`, simplemente clonamos el repositorio en una embargo, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la documentación para
carpeta local, y lo instalamos: instalarlo), ya que aisla las dependencias de la herramienta en un `venv`. Una vez instalado,
simplemente ejecutamos `pipx install git-flow-envs`
```sh Luego podemos verificar que se haya instalado correctamente ejecutando: `git-flow --help`
cd ~/Downloads
git clone git@bitbucket.org:sushi2717/git-flow.git
cd git-flow
pipx install .
```
Luego podemos verificar que se haya instalado correctamente ejecutando: `git-flow -h`
Para actualizarlo, hacemos un pull de los cambios recientes del repositorio, y hacemos el upgrade
mediante `pipx`:
```sh
cd ~/Downloads/git-flow
git pull
pipx upgrade git-flow
```
## Configuración ## Configuración
@@ -47,25 +35,35 @@ El comando solicitará 2 opciones:
automáticamente. Por el momento, se soportan los siguientes remotos: automáticamente. Por el momento, se soportan los siguientes remotos:
- [x] Bitbucket - [x] Bitbucket
- [ ] Github: planeado - [x] Github
- [x] Gitea
- [x] Gitlab
La configuración del remoto requiere tener un Access Token para poder crear los PRs automáticamente. La configuración del remoto requiere tener un Access Token para poder crear los PRs y tags
El mismo se puede crear desde el repositorio al que se quiere dar acceso: *"Repository settings" > automáticamente. Ver la sección [Remotos soportados](#remotos-soportados) para las instrucciones
"Access Tokens" > "Create access token"*. El token necesita permiso para *Write* en *Pull Requests*. de cómo generar el token en cada proveedor.
Luego se debe guardar el token dentro del repositorio a configurar, en el archivo Una vez generado, el token se debe guardar dentro del repositorio a configurar, en el archivo
`.repository-token`. De no especificarse, los merges y tags se harán de forma local. `.repository-token`. De no especificarse, los merges y tags se harán de forma local.
## Utilización ## Utilización
Git Flow cuenta con 5 comandos principales para guiar el workflow (ver `git-flow <command> -h` para Git Flow cuenta con los siguientes comandos para guiar el workflow (ver `git-flow <command> --help` para
más información): más información):
- `init`: Inicializa el repositorio para utilizar git-flow (ramas principales y remoto).
- `new`: Crea una nueva rama siguiendo CB. - `new`: Crea una nueva rama siguiendo CB.
- `commit`: Crea un commit siguiendo CC. - `commit`: Crea un commit siguiendo CC. No permite commitear directamente sobre un entorno.
- `merge`: Mergea la rama actual a una de las ramas principales. - `merge`: Mergea la rama actual a uno de los entornos.
- `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer. - `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer (ver [Tag](#tag)).
- `release`: Prepara la rama actual para pasarse al siguiente entorno configurado - `release`: Prepara la rama actual para pasarse al siguiente entorno configurado.
- `branch ls`: Lista las ramas del repositorio, agrupadas por entorno objetivo.
- `branch clean`: Elimina las ramas alcanzables desde HEAD (locales por defecto, o remotas con
`--remote`), dejando un reporte CSV (`branch-clean-report.csv`) con nombre, commit, autor y
fecha de cada rama eliminada.
Por defecto solo se muestra cada paso que se ejecuta. Con `git-flow --verbose <command>` (o `-v`) se
muestra además la salida de cada comando git y el traceback completo de los errores.
El workflow para el cual se penso la herramienta es el siguiente: El workflow para el cual se penso la herramienta es el siguiente:
@@ -84,9 +82,188 @@ El workflow para el cual se penso la herramienta es el siguiente:
`feature/my-new-feature`, se crea la rama `release/test/feature/my-new-feature` sobre `test` que `feature/my-new-feature`, se crea la rama `release/test/feature/my-new-feature` sobre `test` que
tiene los cambios de la rama original. tiene los cambios de la rama original.
En cualquier momento se puede usar `git-flow branch ls` para ver en qué entorno está cada rama, y cuáles
están todavía en progreso (`wip/`) o descartadas (`trash/`).
### Tag
`git-flow tag [--target=<RAMA>] [--token=<TOKEN>] [--update-changelog]`
- `--target`: rama principal sobre la que se taggea. Por defecto, la rama actual. Debe ser una de las
ramas principales configuradas.
- `--token`: Access Token del remoto. Si se pasa, se asume que corre en un pipeline: los commits y el
último tag se obtienen por API del remoto y el tag se crea en el remoto (y también localmente). Si
no, se usa `.repository-token` y todo se resuelve localmente.
- `--update-changelog`: genera la entrada correspondiente en `CHANGELOG.md` y la commitea antes de
taggear (en un pipeline, además hace push del commit).
Comportamiento:
- El tag se crea sobre el último merge (first-parent) de la rama. Si ese merge ya tiene un tag, no se
hace nada.
- La versión se calcula a partir de los tipos de commit (CC) del último merge. Si ningún commit
implica un salto de versión, se avisa y el comando termina con código de salida 1. Se toma el
mayor incremento entre los commits:
| Tipo de commit | Incremento |
| -------------------------------------------------------------- | ---------- |
| cualquier tipo marcado como breaking (`feature!: ...`) | major |
| `feature` (o `feat`) | minor |
| `bugfix` (o `fix`), `hotfix`, `refactor`, `perf`, `revert` | patch |
| `docs`, `style`, `test`, `build`, `ci`, `chore`, `wip` | ninguno |
| personalizado (opción `otro` en `git-flow commit`) | ninguno |
`git-flow commit` pregunta si el commit es un breaking change (salvo en commits `wip`).
- Con más de un entorno configurado, cada rama versiona por separado con el nombre del entorno como
sufijo (por ejemplo, `v1.2.0-prod`). Con un solo entorno no se agrega sufijo.
- Si el merge viene del entorno anterior (por ejemplo, `dev` a `test`), no se calcula una nueva
versión: se promueve la del entorno anterior cambiando el sufijo (si `dev` está en `v1.2.0-dev` y
`test` en `v1.0.0-test`, el nuevo tag es `v1.2.0-test`), sin importar los tipos de commit. Si ese
tag ya existe en el destino, el comando falla. En un pipeline, el merge se detecta por la rama
origen del PR; localmente, porque el merge trae tags del entorno anterior que el destino no tenía.
## Remotos soportados
Cada remoto necesita un Access Token con permisos para crear Pull/Merge Requests y tags. El token se
guarda en el archivo `.repository-token` en la raíz del repositorio (una única línea con el valor del
token), o se pasa por parámetro con `--token=<TOKEN>` al comando `git-flow tag` (útil para no
commitear el token y usarlo solo desde un pipeline mediante una variable secreta).
### Github
1. Ir a *Settings* del repositorio (o de la organización) > *Developer settings* > *Personal access
tokens* > *Fine-grained tokens* > *Generate new token*.
2. Seleccionar el repositorio sobre el que se va a usar `git-flow`.
3. En *Repository permissions*, otorgar:
- **Contents**: Read and write (necesario para crear tags).
- **Pull requests**: Read and write (necesario para crear PRs).
4. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (GitHub Actions) para taggear automáticamente en cada push a una rama principal:
```yaml
# .github/workflows/tag.yml
name: Tag and publish
on:
push:
branches: [main]
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }}
```
### Gitlab
1. Ir a *Settings* > *Access Tokens* del proyecto (o *Group access tokens* si se quiere usar a nivel
grupo).
2. Crear un token con rol **Developer** o superior, y los scopes:
- **api** (o al menos `write_repository` para tags y `api` para Merge Requests).
3. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (GitLab CI) para taggear automáticamente en cada push a una rama principal:
```yaml
# .gitlab-ci.yml
tag:
image: python:3.14
variables:
GIT_DEPTH: 10
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- pip install uv
# GitLab hace checkout en detached HEAD, por lo que la rama se indica explícitamente
- uv run git-flow tag --target=$CI_COMMIT_BRANCH --token=$GIT_FLOW_TOKEN
- uv build
- uv publish --token=$PYPI_TOKEN
```
El token debe guardarse como variable protegida y enmascarada (`GIT_FLOW_TOKEN`) en *Settings* >
*CI/CD* > *Variables*.
### Bitbucket
1. Ir a *Repository settings* > *Access Tokens* > *Create access token* (o *Workspace settings* >
*Access Tokens* para un token a nivel workspace).
2. Otorgar permiso de **Write** en *Pull requests* y *Repositories* (necesario para crear tags).
3. Generar el token y guardarlo en `.repository-token`.
Este mismo repositorio usa Git Flow para el taggeo automático usando Bitbucket Pipelines, se puede
ver el archivo `bitbucket-pipelines.yml` como ejemplo:
```yaml
# bitbucket-pipelines.yml
image: python:3.14
clone:
depth: 10
pipelines:
branches:
'main':
- step:
name: "Tag and publish package"
caches:
- pip
script:
- pip install uv
- uv run git-flow tag --token=$BEARER
- uv build
- uv publish --token=$PYPI_TOKEN
```
El token se configura como variable segura del repositorio (`BEARER`) en *Repository settings* >
*Repository variables*.
### Gitea
1. Ir a *Settings* del usuario (o de la organización) > *Applications* > *Manage Access Tokens* >
*Generate New Token*.
2. Otorgar permiso de escritura sobre:
- **repository** (necesario para crear tags).
- **issue** (necesario para crear PRs, ya que Gitea expone los PRs bajo el mismo scope).
3. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (Gitea Actions) para taggear automáticamente en cada push a una rama principal:
```yaml
# .gitea/workflows/tag.yml
name: Tag and publish
on:
push:
branches: [main]
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }}
```
## Taggeo automático por pipeline ## Taggeo automático por pipeline
Éste repositorio utiliza Git Flow para el taggeo automático usando pipelines, se puede ver el En todos los casos, al pasar `--token=<TOKEN>` `git-flow tag` asume que se está ejecutando desde un
archivo `bitbucket-pipelines.yml` como ejemplo. El comando `git-flow tag` soporta el pasaje del pipeline: obtiene los commits del último PR / MR y el último tag por API del remoto, y usa el token
token mediante la opción `--token=<TOKEN>`. Ver la sección [Configuración](#configuracion) para las tanto para crear el tag en el remoto como para pushear el commit del `CHANGELOG.md` generado (con
instrucciones de como generar este token. El mismo necesita scope `write:repository:bitbucket`. `--update-changelog`), en vez de resolver todo en forma local.
-16
View File
@@ -1,16 +0,0 @@
image: python:3.10
pipelines:
branches:
'main':
- step:
name: "Tag and publish package"
caches:
- pip
script:
- pip install uv
- uv run git-flow tag --token=$BEARER
- git status
- git log --oneline -5
- uv build
- uv publish --token=$PYPI_TOKEN
+1 -1
View File
@@ -8,7 +8,7 @@ description = "Git workflow automation to follow best practices when working wit
dynamic = ["version"] dynamic = ["version"]
requires-python = ">= 3.10" requires-python = ">= 3.10"
dependencies = [ dependencies = [
"requests" "requests", "typer", "rich", "questionary", "giturlparse"
] ]
authors = [ authors = [
{name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"}, {name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"},
+37 -25
View File
@@ -1,15 +1,6 @@
GitFlowError = ValueError GitFlowError = ValueError
BRANCH_TYPES = [ BRANCH_TYPES = [
"feature",
"refactor",
"bugfix",
"hotfix",
"chore",
"docs",
]
COMMIT_TYPES = [
"feature", "feature",
"bugfix", "bugfix",
"hotfix", "hotfix",
@@ -18,6 +9,24 @@ COMMIT_TYPES = [
"docs", "docs",
"style", "style",
"test", "test",
"build",
"ci",
"chore",
]
COMMIT_TYPES = [
"feature",
"bugfix",
"hotfix",
"refactor",
"perf",
"revert",
"docs",
"style",
"test",
"build",
"ci",
"chore",
"wip", "wip",
] ]
@@ -32,32 +41,35 @@ SEMVER_PATCH = 1
# Commits con estos tipos no alteran la versión # Commits con estos tipos no alteran la versión
SEMVER_SKIP = 0 SEMVER_SKIP = 0
# Un commit marcado como breaking change (<tipo>!: <mensaje>) incrementa SEMVER_MAJOR sin importar
# el tipo. Los tipos que no figuran aquí no alteran la versión.
COMMIT_TYPE_INCREMENT = { COMMIT_TYPE_INCREMENT = {
"feature": SEMVER_MINOR, "feature": SEMVER_MINOR,
"bugfix": SEMVER_PATCH, "bugfix": SEMVER_PATCH,
"hotfix": SEMVER_PATCH, "hotfix": SEMVER_PATCH,
"refactor": SEMVER_SKIP, "refactor": SEMVER_PATCH,
"perf": SEMVER_SKIP, "perf": SEMVER_PATCH,
"revert": SEMVER_PATCH,
"docs": SEMVER_SKIP, "docs": SEMVER_SKIP,
"style": SEMVER_SKIP, "style": SEMVER_SKIP,
"test": SEMVER_SKIP, "test": SEMVER_SKIP,
"build": SEMVER_SKIP,
"ci": SEMVER_SKIP,
"chore": SEMVER_SKIP,
"wip": SEMVER_SKIP,
# Alias de Conventional Commits, para commits creados fuera de git-flow
"feat": SEMVER_MINOR,
"fix": SEMVER_PATCH,
} }
SUPPORTED_REMOTE_APIS = [ SUPPORTED_REMOTE_URL_SCHEMAS = [
"bitbucket.org", "ssh",
"github.com", "http",
"https"
] ]
REPOSITORY_TOKEN_FILENAME = ".repository-token" REPOSITORY_TOKEN_FILENAME = ".repository-token"
COLOR_BLACK = "\033[30m" FLOWCONFIG_FILENAME = ".flowconfig"
COLOR_RED = "\033[31m"
COLOR_GREEN = "\033[32m" FLOWCONFIG_VERSION = 2
COLOR_YELLOW = "\033[33m"
COLOR_BLUE = "\033[34m"
COLOR_5 = "\033[35m"
COLOR_6 = "\033[36m"
COLOR_7 = "\033[37m"
COLOR_BLACK_BOLD = "\033[1;30m"
COLOR_WHITE_BOLD = "\033[1;37m"
COLOR_RESET = "\033[0m"
+14 -13
View File
@@ -1,15 +1,15 @@
from datetime import datetime from datetime import datetime
from os.path import isfile from os.path import isfile
from git_flow.git import Git from git_flow.git import Commit
class Changelog: class Changelog:
FILENAME = "CHANGELOG.md" FILENAME = "CHANGELOG.md"
COMMIT_MESSAGE = "chore: bump version and update CHANGELOG.md [skip ci]" COMMIT_MESSAGE = "chore: bump version and update CHANGELOG.md [skip ci]"
def update(self, title: str, base: str, branch: str): def update(self, title: str, commits: list[Commit]):
self._prepend(self.generate_entry(title, base, branch)) self._prepend(self.generate_entry(title, commits))
def generate_header(self, title): def generate_header(self, title):
now = datetime.now() now = datetime.now()
@@ -18,21 +18,22 @@ class Changelog:
""" """
def generate_content(self, base: str, branch: str): def generate_content(self, commits: list[Commit]):
commits = Git(
"log", base + ".." + branch, first_parent=True, format="%h", merges=False
).lines()
content = "" content = ""
for commit in commits: for commit in commits:
message = Git("show", commit, patch=False, format="%s").firstline() message = commit.subject
email = Git("show", commit, patch=False, format="%ae").firstline() email = commit.email
username = email[: email.index("@")] username = email[: email.index("@")] if "@" in email else email
description = message
if ":" in message:
index = message.index(":") index = message.index(":")
commit_type = message[:index] commit_type = message[:index]
commit_message = message[index + 1 :] commit_message = message[index + 1 :]
description = f"**{commit_type}**: {commit_message}"
content += f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})\n" content += f"- {description} [[{username}](mailto:{email})] ({commit.sha})\n"
return content return content
@@ -43,10 +44,10 @@ class Changelog:
""" """
def generate_entry(self, title: str, base: str, branch: str): def generate_entry(self, title: str, commits: list[Commit]):
return ( return (
self.generate_header(title) self.generate_header(title)
+ self.generate_content(base, branch) + self.generate_content(commits)
+ self.generate_footer() + self.generate_footer()
) )
+236 -172
View File
@@ -1,127 +1,38 @@
from argparse import Namespace, ArgumentParser import re
from abc import ABC, abstractmethod import time
from os.path import isfile from os.path import isfile
import questionary
import rich
import rich.panel
from rich import box
from rich.table import Table
from git_flow import ( from git_flow import (
BRANCH_TYPES, BRANCH_TYPES,
COLOR_BLUE, COMMIT_TYPE_INCREMENT,
COLOR_GREEN, COMMIT_TYPES,
COLOR_RED, FLOWCONFIG_FILENAME,
COLOR_RESET, FLOWCONFIG_VERSION,
COLOR_YELLOW,
REPOSITORY_TOKEN_FILENAME, REPOSITORY_TOKEN_FILENAME,
GitFlowError, GitFlowError,
) )
from git_flow.git import FLOWCONFIG_FILE, Git from git_flow.git import Git
from git_flow.io import *
from git_flow.remote.base import RemoteAPI from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.gitea import GiteaRemoteAPI
from git_flow.remote.github import GithubRemoteAPI from git_flow.remote.github import GithubRemoteAPI
from git_flow.remote.gitlab import GitlabRemoteAPI
TYPE_SUCCESS = 0 flowconfig = Git.get_config(FLOWCONFIG_FILENAME) if isfile(FLOWCONFIG_FILENAME) else {}
TYPE_WARNING = 1 environments = (
TYPE_ERROR = 2 flowconfig["flow.branches"].split(",") if "flow.branches" in flowconfig else []
TYPE_INFO = 3
class Command(ABC):
flowconfig: dict[str, str]
@abstractmethod
def name(self) -> str:
pass
@abstractmethod
def description(self) -> str:
pass
@abstractmethod
def run(self, args: Namespace = Namespace()):
pass
def init(self):
self.flowconfig = (
Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {}
) )
def success(self, msg: str):
self._print(TYPE_SUCCESS, msg)
def warning(self, msg: str): def ensure_initialized():
self._print(TYPE_WARNING, msg) initialized = flowconfig["flow.initialized"] if flowconfig else None
def error(self, msg: str):
self._print(TYPE_ERROR, msg)
def info(self, msg: str):
self._print(TYPE_INFO, msg)
def prompt(
self,
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
if default:
prompt += f" [{default}]"
prompt += ": "
result = input(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
self.error("Debe ingresar un valor no vacío.")
else:
return result
def confirm(self, question: str, default: bool = True):
suffix = " [Y/n]: " if default else " [y/N]: "
answer = input(question + suffix)
return default if len(answer) == 0 else answer.startswith("y")
def choice(self, prompt: str, options: list[str]):
print(prompt)
for i, option in enumerate(options):
print(f"\t{i+1}. {option}")
selection = None
while selection is None:
answer = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
if answer.isdigit():
answer = int(answer)
if 1 <= answer and answer <= len(options):
selection = options[answer - 1]
if not self.confirm(
f"Seleccionó la opción {answer} ({selection}), ¿es correcto?"
):
selection = None
else:
self.error(f"La opción {answer} está fuera del rango permitido.")
elif answer in options:
selection = answer
else:
self.error(f"La opción '{answer}' es inválida.")
return selection
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
return parser
def ensure_initialized(self):
initialized = self.flowconfig["flow.initialized"] if self.flowconfig else None
if not initialized: if not initialized:
raise GitFlowError( raise GitFlowError(
@@ -132,45 +43,27 @@ class Command(ABC):
f"El valor de `flow.initialized` ({initialized}) es inválido." f"El valor de `flow.initialized` ({initialized}) es inválido."
) )
def ensure_right_branch(self): version = int(flowconfig.get("flow.version", "1"))
if version != FLOWCONFIG_VERSION:
raise GitFlowError(
f"La versión del flowconfig ({version}) no es compatible con esta versión de git-flow ({FLOWCONFIG_VERSION}). "
f"Debe reinicializar el repositorio."
)
def ensure_on_branch():
branch = Git.get_current_branch() branch = Git.get_current_branch()
if branch == "HEAD": if branch == "HEAD":
raise GitFlowError("No se encuentra parado sobre una rama.") raise GitFlowError("No se encuentra parado sobre una rama.")
elif self.confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
io_info(f"Rama actual: {branch}")
return branch return branch
else:
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
with open(REPOSITORY_TOKEN_FILENAME) as f: def get_branch_env_and_type(branch: str) -> tuple[str, str]:
return f.readline().strip()
def ensure_clean_worktree(self, has_remote: bool):
status = Git.status()
if not status:
return
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
if not has_remote:
self.warning("Existen cambios en tu entorno de trabajo sin commitear.")
else:
raise GitFlowError("No se puede continuar con cambios pendientes.")
if not self.confirm("¿Desea continuar?", False):
raise GitFlowError("Ejecución abortada")
def get_branch_env_and_type(self, branch: str) -> tuple[str, str]:
components = branch.split("/") components = branch.split("/")
target_branches = self.flowconfig["flow.branches"].split(",")
if len(components) == 2: if len(components) == 2:
if components[0] not in BRANCH_TYPES: if components[0] not in BRANCH_TYPES:
@@ -178,13 +71,13 @@ class Command(ABC):
f"Branch inválida, el tipo '{components[0]}' no es válido." f"Branch inválida, el tipo '{components[0]}' no es válido."
) )
return (target_branches[0], components[0]) return (environments[0], components[0])
elif len(components) == 4: elif len(components) == 4:
target_branches = target_branches[1:] valid_environments = environments[1:]
if ( if (
components[0] != "release" components[0] != "release"
or components[1] not in target_branches or components[1] not in valid_environments
or components[2] not in BRANCH_TYPES or components[2] not in BRANCH_TYPES
): ):
raise GitFlowError( raise GitFlowError(
@@ -196,41 +89,212 @@ class Command(ABC):
else: else:
raise GitFlowError("Branch inválido: " + branch) raise GitFlowError("Branch inválido: " + branch)
def get_remote_api(self, token: str) -> RemoteAPI:
if "flow.remote" not in self.flowconfig: def ensure_repository_token():
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
with open(REPOSITORY_TOKEN_FILENAME) as f:
return f.readline().strip()
def ensure_clean_worktree(has_remote: bool):
status = Git.status()
if not status:
return
not_empty = any(s.index != " " or s.worktree != " " for s in status)
if not_empty:
if not has_remote:
io_warning("Existen cambios en tu entorno de trabajo sin commitear.")
if not io_confirm("¿Desea continuar?", False):
raise GitFlowError("Ejecución abortada")
else:
raise GitFlowError("No se puede continuar con cambios pendientes.")
def get_remote_api(token: str) -> RemoteAPI:
if "flow.remote" not in flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.") raise GitFlowError("El repositorio no tiene configurado un remoto.")
[host, repository] = RemoteAPI.parse(self.flowconfig["flow.remote"]) remote_type = flowconfig.get("flow.remote-type", "").lower()
[schema, host, repository] = RemoteAPI.parse(flowconfig["flow.remote"])
if host == "bitbucket.org": if remote_type == "gitea":
remote_schema = "https://" if schema == "ssh://" else schema
return GiteaRemoteAPI(remote_schema + host, repository, token)
elif remote_type == "gitlab":
remote_schema = "https://" if schema == "ssh://" else schema
return GitlabRemoteAPI(remote_schema + host, repository, token)
elif remote_type == "bitbucket":
return BitbucketRemoteAPI(repository, token) return BitbucketRemoteAPI(repository, token)
elif host == "github.com": elif remote_type == "github":
return GithubRemoteAPI(repository, token) return GithubRemoteAPI(repository, token)
else: else:
raise GitFlowError("El host del repositorio remoto es inválido") raise GitFlowError("El tipo del remoto es inválido")
def _print(self, type: int, msg: str):
print(self._get_color(type) + self._get_tag(type) + " " + msg + COLOR_RESET)
def _get_color(self, type: int) -> str: def is_valid_ticket(ticket: str) -> bool:
if type == TYPE_SUCCESS: """Check if the specified string is a valid ticket number (ABC-123)"""
return COLOR_GREEN components = ticket.split("-")
elif type == TYPE_WARNING:
return COLOR_YELLOW
elif type == TYPE_ERROR:
return COLOR_RED
elif type == TYPE_INFO:
return COLOR_BLUE
return ""
def _get_tag(self, type: int) -> str: if len(components) != 2:
if type == TYPE_SUCCESS: return False
return "[success]"
elif type == TYPE_WARNING:
return "[warning]"
elif type == TYPE_ERROR:
return "[error]"
elif type == TYPE_INFO:
return "[info]"
return "" ticket_project = components[0]
ticket_number = components[1]
return (
ticket_project.isalpha()
and ticket_project.isupper()
and ticket_number.isnumeric()
)
CUSTOM_COMMIT_TYPE_OPTION = "otro"
def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
commit_type = io_choice("Tipo de commit", commit_types + [CUSTOM_COMMIT_TYPE_OPTION])
if commit_type == CUSTOM_COMMIT_TYPE_OPTION:
commit_type = io_prompt(
"Tipo de commit personalizado",
strip=True,
validator=lambda s: re.fullmatch(r"\w+", s.strip()) is not None,
instruction="letras, números o _",
)
if commit_type not in COMMIT_TYPE_INCREMENT:
io_warning(
f"El tipo '{commit_type}' no produce un salto de versión, "
"salvo que se marque como breaking change."
)
# Los commits WIP se squashean en uno final, que es el que puede marcarse como breaking
if commit_type != "wip" and io_confirm("¿Es un breaking change?", False):
commit_type += "!"
commit_message = io_prompt(
"Mensaje de commit",
validator=lambda s: 0 < len(s) and len(s) < 100,
instruction="100 caracteres máximo",
)
return commit_type + ": " + commit_message
# <tipo>[(alcance)][!]: <mensaje>, para no confundir "Merge pull request 'feature: ...'" con un tipo
COMMIT_SUBJECT_RE = re.compile(r"^(\w+)(?:\([^)]*\))?(!)?:\s*(.*)$")
def show_commits(title: str, revision_range: str):
"""Muestra en una tabla los commits del rango (fecha relativa, tipo y mensaje).
Si no hay commits aborta, ya que no hay nada para mergear."""
# fecha de autor: la de commit cambia al hacer rebase (por ejemplo, en release)
lines = Git("log", revision_range, format="%at%x09%s").lines()
if not lines:
raise GitFlowError("No hay cambios a mergear.")
table = Table(title=title, title_justify="left", title_style="bold", box=box.SQUARE)
table.add_column("Fecha", style="dim", no_wrap=True)
table.add_column("Tipo", style="cyan", no_wrap=True)
table.add_column("Mensaje")
now = time.time()
for line in lines:
timestamp, subject = line.split("\t", 1)
match = COMMIT_SUBJECT_RE.match(subject)
commit_type, breaking, message = match.groups() if match else ("-", None, subject)
commit_type += breaking or ""
table.add_row(format_time_ago(now - int(timestamp)), commit_type, message)
console.print(table)
def format_time_ago(seconds: float) -> str:
units = [
(365 * 24 * 3600, "año", "años"),
(30 * 24 * 3600, "mes", "meses"),
(7 * 24 * 3600, "semana", "semanas"),
(24 * 3600, "día", "días"),
(3600, "hora", "horas"),
(60, "minuto", "minutos"),
]
for size, singular, plural in units:
amount = int(seconds // size)
if amount >= 1:
return f"hace {amount} {singular if amount == 1 else plural}"
return "hace un momento"
def io_error(message: str, title: str | None = None):
_io_status(message, "✖ Error: ", "red", title)
def io_warning(message: str, title: str | None = None):
_io_status(message, "⚠ Warning: ", "yellow", title)
def io_info(message: str, title: str | None = None):
_io_status(message, "ℹ Info: ", "blue", title)
def io_success(message: str, title: str | None = None):
_io_status(message, "✔ Success: ", "green", title)
def _io_status(message: str, prefix: str, style: str, title: str | None = None):
if title:
rich.print(
rich.panel.Panel(
message,
title=prefix + title,
style=style,
expand=False,
title_align="left",
)
)
else:
rich.print(f"[{style}]{prefix}{message}[/{style}]")
def io_confirm(question: str, default: bool = True) -> bool:
return questionary.confirm(question, default=default, auto_enter=False).unsafe_ask()
def io_choice(prompt: str, options: list[str]) -> str:
return questionary.select(
prompt,
options,
use_search_filter=True,
use_jk_keys=False,
instruction="Tipear o usar flechas",
).unsafe_ask()
def io_prompt(
message: str,
default: str = "",
persistent: bool = False,
strip: bool = False,
validator=None,
instruction: str | None = None,
) -> str:
if persistent and not validator:
validator = lambda s: len(s.strip()) > 0
value = questionary.text(
message, default, validate=validator, instruction=instruction
).unsafe_ask()
return value.strip() if strip else value
-117
View File
@@ -1,117 +0,0 @@
from argparse import ArgumentParser, Namespace
from git_flow import COLOR_WHITE_BOLD, COLOR_GREEN, COLOR_RED, COLOR_RESET, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)"
class BranchCommand(Command):
def name(self) -> str:
return "branch"
def description(self) -> str:
return """Lista ramas del repositorio, agrupandolas por entorno objetivo"""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument(
"environment",
nargs="?",
help="Entorno de las ramas a listar. Por defecto, es el entorno actual.",
)
parser.add_argument(
"--trash",
action="store_true",
help=f"Listar ramas en la 'papelera de reciclaje' (ramas que empiezan con '{TRASH_BRANCH_PREFIX}')"
)
parser.add_argument(
"--wip",
action="store_true",
help=f"Listar ramas 'en progreso' (ramas que empiezan con '{WIP_BRANCH_PREFIX}')"
)
parser.add_argument(
"--all",
action="store_true",
help=f"Listar ramas de todos los entornos"
)
return parser
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
self.current_branch = Git.get_current_branch()
self.environments = self.flowconfig["flow.branches"].split(",")
if self.current_branch in self.environments:
current_environment = self.current_branch
else:
(current_environment, _) = self.get_branch_env_and_type(self.current_branch)
if args.trash:
self.show_branches(TRASH_BRANCH_PREFIX)
elif args.wip:
self.show_branches(WIP_BRANCH_PREFIX)
elif args.all:
self.show_envs()
self.show_all_branches()
elif args.environment is None:
self.show_envs()
self.show_env_branches(current_environment)
elif args.environment in self.environments:
self.show_envs()
self.show_env_branches(args.environment)
else:
raise GitFlowError(f"'{args.environment}' no es un entorno válido.")
def show_branches(self, prefix: str):
branches = self.get_branches(prefix)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
prefix = (COLOR_RED + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
def get_branches(self, prefix: str):
return Git("for-each-ref", "refs/heads/" + prefix, format=BRANCH_FORMAT).lines()
def show_all_branches(self):
for environment in self.environments:
self.show_env_branches(environment, len(self.environments) > 1)
trash = len(self.get_branches(TRASH_BRANCH_PREFIX))
if trash:
print()
print(COLOR_WHITE_BOLD + "Papelera: " + COLOR_RESET + str(trash) + " rama" + ("" if trash == 1 else "s"))
def show_envs(self):
print(COLOR_WHITE_BOLD + "Entornos" + COLOR_RESET)
for environment in self.environments:
prefix = (COLOR_GREEN + "*") if environment == self.current_branch else " "
print(prefix + " " + environment + COLOR_RESET)
print()
def show_env_branches(self, environment: str, show_env: bool = False):
is_first_env = environment == self.environments[0]
pattern = "refs/heads/" if is_first_env else "refs/heads/release/" + environment + "/"
pattern += "*/*"
exclude = ["refs/heads/" + TRASH_BRANCH_PREFIX, "refs/heads/release/"] if is_first_env else []
branches = Git("for-each-ref", pattern, exclude=exclude, format=BRANCH_FORMAT).lines()
print(COLOR_WHITE_BOLD + (environment if show_env else "Ramas") + COLOR_RESET)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
prefix = (COLOR_GREEN + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
+7
View File
@@ -0,0 +1,7 @@
import typer
from git_flow.command.branch import clean, ls
app = typer.Typer(name="branch")
app.add_typer(ls.app)
app.add_typer(clean.app)
+124
View File
@@ -0,0 +1,124 @@
import csv
import datetime
from dataclasses import dataclass
from typing import Annotated
import typer
from git_flow import GitFlowError
from git_flow.command import base
from git_flow.git import Git
REPORT_FORMAT = "%(refname:short)\t%(objectname)\t%(author)\t%(authordate:iso-strict)"
REPORT_FILENAME = "branch-clean-report.csv"
app = typer.Typer()
@dataclass
class BranchInfo:
name: str
sha: str
author: str
date: str
@app.command()
def clean(
remote: Annotated[
bool,
typer.Option(
help="Elimina las ramas remotas alcanzables desde HEAD, en vez de las locales"
),
] = False,
):
"""Elimina las ramas alcanzables desde HEAD, dejando un reporte CSV de las ramas eliminadas"""
base.ensure_initialized()
branches = get_remote_branches() if remote else get_local_branches()
if not branches:
base.io_info("No hay ramas alcanzables desde HEAD para eliminar.")
return
base.io_info(
"\n".join(f"- {branch.name}" for branch in branches),
title=f"Ramas a eliminar ({'remoto' if remote else 'local'})",
)
if not base.io_confirm(f"¿Eliminar {len(branches)} rama(s)?", False):
raise GitFlowError("Ejecución abortada")
report = write_report(branches)
for branch in branches:
delete_branch(branch, remote)
base.io_success(f"Se eliminaron {len(branches)} rama(s). Reporte generado en {report}")
def get_local_branches() -> list[BranchInfo]:
current_branch = Git.get_current_branch()
branches = get_merged_branches("refs/heads/")
return [b for b in branches if b.name != current_branch and b.name not in base.environments]
def get_remote_branches() -> list[BranchInfo]:
if "flow.remote" not in base.flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.")
remote = base.flowconfig["flow.remote"]
current_tracking = Git.get_tracking_branch(Git.get_current_branch())
branches = get_merged_branches(f"refs/remotes/{remote}/")
result = []
for branch in branches:
if branch.name == current_tracking or branch.name == f"{remote}/HEAD":
continue
name = branch.name.removeprefix(f"{remote}/")
if name in base.environments:
continue
result.append(BranchInfo(name, branch.sha, branch.author, branch.date))
return result
def get_merged_branches(pattern: str) -> list[BranchInfo]:
lines = Git("for-each-ref", pattern, merged="HEAD", format=REPORT_FORMAT).lines()
return [parse_branch(line) for line in lines]
def parse_branch(line: str) -> BranchInfo:
name, sha, author, date = line.split("\t")
return BranchInfo(name, sha, author, date)
def write_report(branches: list[BranchInfo]) -> str:
now = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
report = "branch-clean-report-" + now + ".csv"
with open(report, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["name", "commit", "author", "date"])
for branch in branches:
writer.writerow([branch.name, branch.sha, branch.author, branch.date])
return report
def delete_branch(branch: BranchInfo, remote: bool):
if remote:
remote_name = base.flowconfig["flow.remote"]
Git("push", remote_name, branch.name, delete=True).exec(
print=f"Eliminando rama remota '{branch.name}'"
)
else:
Git("branch", branch.name, d=True).exec(
print=f"Eliminando rama local '{branch.name}'"
)
+146
View File
@@ -0,0 +1,146 @@
from typing import Annotated
import typer
from rich.console import Console
from git_flow import (
TRASH_BRANCH_PREFIX,
WIP_BRANCH_PREFIX,
GitFlowError,
)
from git_flow.command import base
from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)"
app = typer.Typer()
console = Console()
@app.command()
def ls(
environment: Annotated[
str | None,
typer.Argument(
help="Entorno de las ramas a listar. Por defecto, es el entorno actual."
),
] = None,
trash: Annotated[
bool,
typer.Option(
help=f"Listar ramas en la 'papelera de reciclaje' (ramas que empiezan con '{TRASH_BRANCH_PREFIX}')"
),
] = False,
wip: Annotated[
bool,
typer.Option(
help=f"Listar ramas 'en progreso' (ramas que empiezan con '{WIP_BRANCH_PREFIX}')"
),
] = False,
all: Annotated[
bool, typer.Option(help="Listar ramas de todos los entornos")
] = False,
):
"""Lista ramas del repositorio, agrupandolas por entorno objetivo"""
base.ensure_initialized()
current_branch = Git.get_current_branch()
if current_branch in base.environments:
current_environment = current_branch
else:
current_environment, _ = base.get_branch_env_and_type(current_branch)
if trash:
show_branches(TRASH_BRANCH_PREFIX, current_branch)
elif wip:
show_branches(WIP_BRANCH_PREFIX, current_branch)
elif all:
show_envs(base.environments, current_branch)
show_all_branches(base.environments, current_branch)
elif environment is None:
show_envs(base.environments, current_branch)
show_env_branches(current_environment, base.environments, current_branch)
elif environment in base.environments:
show_envs(base.environments, current_branch)
show_env_branches(environment, base.environments, current_branch)
else:
raise GitFlowError(f"'{environment}' no es un entorno válido.")
def show_branches(branch_prefix: str, current_branch: str):
branches = get_branches(branch_prefix)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
def get_branches(branch_prefix: str):
return Git(
"for-each-ref", "refs/heads/" + branch_prefix, format=BRANCH_FORMAT
).lines()
def show_all_branches(environments: list[str], current_branch: str):
for environment in environments:
show_env_branches(
environment, environments, current_branch, len(environments) > 1
)
trash = len(get_branches(TRASH_BRANCH_PREFIX))
if trash:
print()
console.print(
"[bold]Papelera:[/bold] "
+ str(trash)
+ " rama"
+ ("" if trash == 1 else "s")
)
def show_envs(environments: list[str], current_branch: str):
console.print("Entornos", style="bold")
for environment in environments:
is_current = environment == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + environment, style="green" if is_current else None)
print()
def show_env_branches(
environment: str,
environments: list[str],
current_branch: str,
show_env: bool = False,
):
is_first_env = environment == environments[0]
pattern = (
"refs/heads/" if is_first_env else "refs/heads/release/" + environment + "/"
)
pattern += "*/*"
exclude = (
["refs/heads/" + TRASH_BRANCH_PREFIX, "refs/heads/release/"]
if is_first_env
else []
)
branches = Git(
"for-each-ref", pattern, exclude=exclude, format=BRANCH_FORMAT
).lines()
console.print(environment if show_env else "Ramas", style="bold")
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
+85 -31
View File
@@ -1,35 +1,46 @@
from argparse import Namespace
from datetime import datetime from datetime import datetime
from git_flow import COMMIT_TYPES, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command import typer
from rich import box
from rich.table import Table
from git_flow import TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command import base
from git_flow.git import Git from git_flow.git import Git
from git_flow.io import console
app = typer.Typer()
class CommitCommand(Command): @app.command()
def name(self) -> str: def commit():
return "commit" """Crea un commit siguiendo el formato de Conventional Commits"""
base.ensure_initialized()
branch = base.ensure_on_branch()
def description(self) -> str: # Una rama WIP termina squasheando en su rama original, que tampoco puede ser un entorno
return """Crea un commit siguiendo el formato de Conventional Commits""" target_branch = branch.removeprefix(WIP_BRANCH_PREFIX)
def run(self, args: Namespace = Namespace()): if target_branch in base.environments:
self.ensure_initialized() raise GitFlowError(
branch = self.ensure_right_branch() f"No se puede commitear directamente sobre el entorno '{target_branch}'. "
"Debe crear una rama de trabajo con el comando `new`."
)
if not self._has_files_staged(): if not _has_files_staged():
self.warning("No hay cambios en el indice para commitear.") base.io_warning("No hay cambios en el indice para commitear.")
if self.confirm("¿Desea agregar la carpeta actual?"): if base.io_confirm("¿Desea agregar la carpeta actual?"):
Git("add", ".").exec(print="Agregando cambios") Git("add", ".").exec(print="Agregando cambios")
else: else:
raise GitFlowError( raise GitFlowError("Debe agregar algún cambio al indice para continuar.")
"Debe agregar algún cambio al indice para continuar."
)
commit_type = self.choice("Tipo de commit", COMMIT_TYPES) _show_index()
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True if not base.io_confirm("¿Desea commitear estos cambios?"):
) raise GitFlowError("Ejecución cancelada.")
message = commit_type + ": " + commit_message
message = base.get_commit_message()
commit_type = message[: message.index(":")]
if branch.startswith(WIP_BRANCH_PREFIX): if branch.startswith(WIP_BRANCH_PREFIX):
original_branch = branch.removeprefix(WIP_BRANCH_PREFIX) original_branch = branch.removeprefix(WIP_BRANCH_PREFIX)
@@ -38,25 +49,68 @@ class CommitCommand(Command):
if commit_type != "wip": if commit_type != "wip":
suffix = datetime.now().strftime("%Y%m%dT%H%M") suffix = datetime.now().strftime("%Y%m%dT%H%M")
Git("switch", original_branch).exec(print="Volviendo a rama original") Git("switch", original_branch).exec(print="Volviendo a rama original")
Git("merge", branch, squash=True).exec(print="Squasheando commits WIP en uno solo") Git("merge", branch, squash=True).exec(
print="Squasheando commits WIP en uno solo"
)
Git("commit", m=message).exec(print="Creando commit final de rama WIP") Git("commit", m=message).exec(print="Creando commit final de rama WIP")
Git("branch", branch, TRASH_BRANCH_PREFIX + branch + "/" + suffix, move=True).exec(print="Backup de rama WIP") Git(
"branch", branch, TRASH_BRANCH_PREFIX + branch + "/" + suffix, move=True
).exec(print="Backup de rama WIP")
else: else:
if commit_type == "wip": if commit_type == "wip":
Git("switch", WIP_BRANCH_PREFIX + branch, create=True).exec(print="Creando nueva rama WIP") Git("switch", WIP_BRANCH_PREFIX + branch, create=True).exec(
print="Creando nueva rama WIP"
)
Git("commit", m=message).exec(print="Creando commit") Git("commit", m=message).exec(print="Creando commit")
def _has_files_staged(self):
def _has_files_staged():
status = Git.status() status = Git.status()
if not status: if not status:
raise GitFlowError("No hay cambios para commitear.") raise GitFlowError("No hay cambios para commitear.")
files_in_index = [] return any(_is_staged(s) for s in status)
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
return len(files_in_index) > 0 def _is_staged(s: Git.Status) -> bool:
return s.index != "?" and s.index != " "
INDEX_STATES = {
"M": "modificado",
"T": "cambio de tipo",
"A": "agregado",
"D": "eliminado",
"R": "renombrado",
"C": "copiado",
"U": "en conflicto",
}
def _show_index():
"""Muestra en una tabla los archivos del indice (los que entran en el commit), e informa
cuántos archivos quedan con cambios fuera del indice."""
status = Git.status()
staged = [s for s in status if _is_staged(s)]
not_staged = [s for s in status if s.worktree != " "]
table = Table(
title="Cambios a commitear",
title_justify="left",
title_style="bold",
box=box.SQUARE,
)
table.add_column("Estado", style="cyan", no_wrap=True)
table.add_column("Archivo")
for s in staged:
table.add_row(INDEX_STATES.get(s.index, s.index), s.file)
console.print(table)
if not_staged:
base.io_info(
f"{len(not_staged)} archivo(s) con cambios fuera del indice no se incluirán en el commit."
)
+60 -47
View File
@@ -1,65 +1,68 @@
from argparse import Namespace import typer
from git_flow import GitFlowError
from git_flow.command.base import Command import git_flow.command.base as base
from git_flow.git import FLOWCONFIG_FILE, Git from git_flow import FLOWCONFIG_FILENAME, FLOWCONFIG_VERSION, GitFlowError
from git_flow.git import Git
app = typer.Typer()
class InitCommand(Command): @app.command()
def name(self) -> str: def init():
return "init" """Inicializa el repositorio para utilizar git-flow"""
def description(self) -> str: _ensure_is_repository()
return """Inicializa el repositorio para utilizar git-flow""" _ensure_not_already_initialized()
def run(self, args: Namespace = Namespace()): branches = _setup_flow_branches()
self._ensure_is_repository() remote, remote_type = _setup_flow_remote()
self._ensure_not_already_initialized()
branches = self._setup_flow_branches()
remote = self._setup_flow_remote()
flowconfig = { flowconfig = {
"flow.version": str(FLOWCONFIG_VERSION),
"flow.initialized": "true", "flow.initialized": "true",
"flow.branches": ",".join(branches), "flow.branches": ",".join(branches),
} }
if remote: if remote and remote_type:
flowconfig["flow.remote"] = remote flowconfig["flow.remote"] = remote
flowconfig["flow.remote-type"] = remote_type
Git.set_config(flowconfig, FLOWCONFIG_FILE) Git.set_config(flowconfig, FLOWCONFIG_FILENAME)
Git("add", FLOWCONFIG_FILE).exec() Git("add", FLOWCONFIG_FILENAME).exec()
Git("commit", message="feature: initialize git-flow").exec() Git("commit", message="feature: initialize git-flow").exec()
self._ensure_all_flow_branches_exist(branches, remote) _ensure_all_flow_branches_exist(branches, remote)
def _ensure_is_repository(self):
def _ensure_is_repository():
if not Git.is_repository(): if not Git.is_repository():
self.warning("El directorio actual no es un repositorio.") base.io_warning("El directorio actual no es un repositorio.")
if not self.confirm("¿Desea inicializarlo?"): if not base.io_confirm("¿Desea inicializarlo?"):
raise GitFlowError( raise GitFlowError("No se puede continuar sin inicializar el repositorio")
"No se puede continuar sin inicializar el repositorio"
)
Git("init").exec() Git("init").exec()
def _ensure_not_already_initialized(self):
if not self.flowconfig: def _ensure_not_already_initialized():
if not base.flowconfig:
return return
elif self.flowconfig.get("flow.initialized") == "true": elif base.flowconfig.get("flow.initialized") == "true":
raise GitFlowError("El repositorio ya fue inicializado para usar git-flow") raise GitFlowError("El repositorio ya fue inicializado para usar git-flow")
else: else:
raise GitFlowError("Valor de 'flow.initialized' es inválido") raise GitFlowError("Valor de 'flow.initialized' es inválido")
def _setup_flow_branches(self):
self.info( def _setup_flow_branches():
"""Ingrese las ramas que representan los entornos de deploy del proyecto en base.io_info(
orden creciente de cercanía al entorno productivo, y separados por coma. """Ingrese las ramas que representan los entornos de deploy del proyecto
Por ejemplo: "dev,test,prod".""" en orden creciente de cercanía al entorno productivo, y separados por
coma. Por ejemplo: "dev, test, prod".""",
title="Configuración de entornos",
) )
branches = list( branches = list(
map(lambda b: b.strip(), self.prompt("Ramas", "main").split(",")) map(lambda b: b.strip(), base.io_prompt("Ramas", "main").split(","))
) )
if not all(branches): if not all(branches):
@@ -67,20 +70,25 @@ class InitCommand(Command):
return branches return branches
def _setup_flow_remote(self):
self.info( def _setup_flow_remote():
"""Puede elegir o crear un repositorio remoto para generar automáticamente PRs""" base.io_info(
"""Configurar un repositorio remoto le permite generar PRs automáticamente para el
entorno correspondiente. Deberá seleccionar uno de los remotos actuales, o crear
uno nuevo.""",
title="Configuración de remoto",
) )
remote = None remote = None
remote_type = None
if self.confirm("¿Configurar repositorio remoto?"): if base.io_confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines(print="Listando remotos disponibles") remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes: if not remotes:
self.info("No tiene ningún repositorio remoto, se creará uno.") base.io_info("No tiene ningún repositorio remoto, se creará uno.")
url = self.prompt( url = base.io_prompt(
"Ingrese la URL del repositorio (e.g. https://github.com/user/repo.git)", "Ingrese la URL del repositorio (e.g. https://github.com/user/repo.git)",
persistent=True, persistent=True,
) )
@@ -89,19 +97,24 @@ class InitCommand(Command):
elif len(remotes) == 1: elif len(remotes) == 1:
remote = remotes[0] remote = remotes[0]
else: else:
self.info("Tiene más de un remoto, seleccione el que va a utilizar.") base.io_info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = self.choice("Remoto", remotes) remote = base.io_choice("Remoto", remotes)
return remote remote_type = base.io_choice(
"Tipo de remoto:", ["bitbucket", "github", "gitea", "gitlab"]
)
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str | None): return (remote, remote_type)
def _ensure_all_flow_branches_exist(branches: list[str], remote: str | None):
existing_branches = Git.get_branches() existing_branches = Git.get_branches()
if not all(map(lambda b: b in existing_branches, branches)): if not all(map(lambda b: b in existing_branches, branches)):
self.warning("Algunas de las ramas de entornos expecificadas no existen.") base.io_warning("Algunas de las ramas de entornos expecificadas no existen.")
self.info("Debe indicar sobre que rama se crearán las ramas de entorno.") base.io_info("Debe indicar sobre que rama se crearán las ramas de entorno.")
target_branch = self.choice("Rama", existing_branches) target_branch = base.io_choice("Rama", existing_branches)
for branch in branches: for branch in branches:
if branch not in existing_branches: if branch not in existing_branches:
+107 -65
View File
@@ -1,111 +1,153 @@
from argparse import Namespace from typing import Optional
import typer
import git_flow.command.base as base
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.changelog import Changelog from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git from git_flow.git import Git
app = typer.Typer()
class MergeCommand(Command):
def name(self) -> str:
return "merge"
def description(self) -> str: @app.command()
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado""" def merge():
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
base.ensure_initialized()
def run(self, args: Namespace = Namespace()): branch = base.ensure_on_branch()
self.ensure_initialized()
branch = self.ensure_right_branch() if branch in base.environments:
(target, _) = self.get_branch_env_and_type(branch) if branch == base.environments[-1]:
raise GitFlowError("No se puede hacer un merge del último entorno.")
if "flow.remote" in self.flowconfig: base.io_info(
remote = self.flowconfig["flow.remote"] """Está por hacer un merge de 2 entornos que puede implicar muchos cambios en el proyecto.""",
token = self.ensure_repository_token() "Merge de entornos",
self.run_remote(remote, token, branch, target) )
if not base.io_confirm("¿Desea continuar?"):
raise GitFlowError("Ejecución abortada")
target = base.environments[base.environments.index(branch) + 1]
else: else:
self.run_local(branch, target) target, _ = base.get_branch_env_and_type(branch)
def run_remote(self, remote: str, token: str, branch: str, target: str): if "flow.remote" in base.flowconfig:
self.ensure_clean_worktree(True) remote = base.flowconfig["flow.remote"]
token = base.ensure_repository_token()
run_remote(remote, token, branch, target)
else:
run_local(branch, target)
def run_remote(remote: str, token: str, branch: str, target: str):
base.ensure_clean_worktree(True)
Git("switch", target).exec(print="Cambiando a rama destino") Git("switch", target).exec(print="Cambiando a rama destino")
Git("pull").exec(print="Obteniendo ultimos cambios del remoto") Git("pull").exec(print="Obteniendo ultimos cambios del remoto")
Git("switch", "-").exec(print="Volviendo a rama a mergear") Git("switch", "-").exec(print="Volviendo a rama a mergear")
self.check_merge_conflicts(target) check_merge_conflicts(target)
self.show_commits_to_merge(target) show_commits_to_merge(target)
if self.confirm(f"¿Crear PR de '{branch}' a '{target}'?"): if base.io_confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
if branch not in base.environments:
Git("push", remote, branch, set_upstream=True).exec( Git("push", remote, branch, set_upstream=True).exec(
print="Subiendo rama al remoto para crear PR" print="Subiendo rama al remoto para crear PR"
) )
self.create_pull_request(token, branch, target) create_pull_request(token, branch, target)
def run_local(self, branch: str, target: str):
self.ensure_clean_worktree(False)
self.check_merge_conflicts(target)
self.show_commits_to_merge(target)
if self.confirm(f"¿Mergear rama '{branch}' a '{target}'?"): def run_local(branch: str, target: str):
base.ensure_clean_worktree(False)
check_merge_conflicts(target)
show_commits_to_merge(target)
if base.io_confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
Git("switch", target).exec(print="Cambiando a rama destino") Git("switch", target).exec(print="Cambiando a rama destino")
Git("merge", branch, ff=False).exec(print="Mergeando") Git("merge", branch, ff=False).exec(print="Mergeando")
def show_commits_to_merge(self, target):
commits = Git("log", target + "..", format="%s").lines()
if not commits: def show_commits_to_merge(target):
raise GitFlowError("No hay cambios a mergear.") base.show_commits("Cambios a mergear", target + "..")
print("Cambios a mergear:")
for commit in commits: def check_merge_conflicts(target: str):
print("- " + commit) merge_conflicts = Git.get_merge_conflicts(target, print="Verificando conflictos de merge")
def check_merge_conflicts(self, target: str):
merge_conflicts = Git("merge", target, ff=False, commit=False).code(
print="Realizando merge de prueba para verificar conflictos"
)
Git("merge", abort=True).exec(print="Abortando merge de prueba", check=False)
if merge_conflicts: if merge_conflicts:
self.error(f"La rama actual tiene conflictos con {target}") files = "\n".join(" - " + file for file in merge_conflicts)
self.info( base.io_error(
f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando""" f"La rama actual tiene conflictos con {target}:\n{files}\n\n"
f"Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente,\n"
"y ejecutar nuevamente este comando",
title="Conflictos de merge",
) )
raise GitFlowError("Ejecución abortada.") raise GitFlowError("Ejecución abortada.")
else: else:
self.success("No se detectaron merge conflicts.") base.io_success("No se detectaron merge conflicts.")
def create_pull_request(self, token: str, branch: str, target: str):
def create_pull_request(token: str, branch: str, target: str):
changelog = Changelog() changelog = Changelog()
commits = Git("log", target + "..", format="%s").lines() commits = Git("log", target + "..", format="%s").lines()
title = _get_pr_title(branch, commits)
if len(commits) == 1: base.panel("Título del PR", title)
title = commits[0]
else:
title = branch.replace("/", ": ").replace("-", " ")
if title.startswith("release: "): if base.io_confirm("¿Desea cambiar el título del PR?", False):
title = title[title.index(' ', title.index(' ') + 1) + 1:] title = base.io_prompt("Título del PR", persistent=True)
self.info("Título del PR por defecto: " + title) commits = Git.get_commits(target, branch)
message = base.get_remote_api(token).create_pull_request(
opt_title = self.prompt("[Opcional] Ingrese otro titulo para el PR")
if opt_title:
title = opt_title
message = self.get_remote_api(token).create_pull_request(
branch, branch,
target, target,
title, title,
changelog.generate_content(target, branch), changelog.generate_content(commits),
branch not in base.environments,
) )
self.success(message) base.io_success(message, "Rama creada")
if self.confirm("¿Desea cambiar a la rama objetivo y bajar los cambios?"):
Git("switch", target).exec(print="Cambiando a rama objetivo") def _get_pr_title(branch: str, commits: list[str]) -> str:
Git("pull").exec(print="Obteniendo cambios") if branch in base.environments:
target = base.environments[base.environments.index(branch) + 1]
return f"Sincronización de entorno {branch} a {target}"
components = branch.split("/") # <type>/<desc> or release/<env>/<type>/<desc>
release_prefix = f"[Pasaje a {components[1]}]" if len(components) == 4 else None
branch_type = components[-2]
branch_ticket, branch_desc = _extract_ticket_from_branch(components[-1])
# when there's a single commit, use it as the PR description instead of
# deriving one from the branch name, but keep the release prefix / ticket
if len(commits) == 1:
return " ".join(filter(None, [release_prefix, branch_ticket, commits[0]]))
return " ".join(
filter(
None,
[
release_prefix,
branch_type + ":",
branch_ticket,
branch_desc.replace("-", " "),
],
)
)
def _extract_ticket_from_branch(branch_desc: str) -> tuple[Optional[str], str]:
"""detect if branch description has ticket as prefix: <desc> = ABC-123-my-branch-name"""
first_dash = branch_desc.find("-")
second_dash = branch_desc.find("-", first_dash + 1)
if first_dash >= 0 and second_dash >= 0:
maybe_ticket = branch_desc[:second_dash]
if base.is_valid_ticket(maybe_ticket):
return maybe_ticket, branch_desc[second_dash + 1 :]
return None, branch_desc
+41 -28
View File
@@ -1,54 +1,67 @@
from argparse import Namespace from typing import Optional
import typer
import git_flow.command.base as base
from git_flow import BRANCH_TYPES from git_flow import BRANCH_TYPES
from git_flow.command.base import Command
from git_flow.git import Git from git_flow.git import Git
app = typer.Typer()
class NewCommand(Command):
def name(self) -> str:
return "new"
def description(self) -> str: @app.command()
return """Crea una nueva rama siguiendo conventional branches""" def new():
"""Crea una nueva rama siguiendo Conventional Branches"""
def run(self, args: Namespace = Namespace()): base.ensure_initialized()
self.ensure_initialized() branch = base.ensure_on_branch()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
if branch not in envs: if branch not in base.environments:
self.warning("La rama actual no corresponde a un entorno configurado.") base.io_warning("La rama actual no corresponde a un entorno configurado.")
self.info("Se creará una nueva rama de trabajo sobre la rama actual.") base.io_info(
self.info("Debe seleccionar el tipo de cambio a realizar.") "Se creará una nueva rama de trabajo sobre la rama actual.\n"
"Debe seleccionar el tipo de cambio a realizar.",
title="Tipo de cambio",
)
branch_type = self.choice("Tipos de cambio", BRANCH_TYPES) branch_type = base.io_choice("Tipos de cambio", BRANCH_TYPES)
new_branch = self._get_unique_branch_name(branch_type) ticket = _get_optional_ticket()
new_branch = _get_unique_branch_name(branch_type, ticket)
if "flow.remote" in self.flowconfig and Git.get_tracking_branch(branch): if "flow.remote" in base.flowconfig and Git.get_tracking_branch(branch):
Git("pull").exec(print="Actualizando rama actual") Git("pull").exec(print="Actualizando rama actual")
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"): if base.io_confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("switch", new_branch, create=True).exec(print="Creando nueva rama") Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
def _get_unique_branch_name(self, branch_type: str) -> str:
self.info( def _get_optional_ticket() -> Optional[str]:
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'." return base.io_prompt(
"Ticket que respalda el cambio (en formato ABC-123)",
validator=lambda x: len(x.strip()) == 0 or base.is_valid_ticket(x),
)
def _get_unique_branch_name(branch_type: str, ticket: Optional[str]) -> str:
base.io_info(
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'.\n"
"Las mismas se utilizaran para generar el nombre de la rama.",
title="Nombre de rama",
) )
branch = None branch = None
branches = Git.get_branches() branches = Git.get_branches()
while branch is None: while branch is None:
keywords = self.prompt( keywords = base.io_prompt("Palabras clave", persistent=True)
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True,
)
keywords = filter(lambda k: len(k) > 0, keywords.split(" ")) keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
branch = branch_type + "/" + "-".join(keywords) branch = (
branch_type + "/" + (ticket + "-" if ticket else "") + "-".join(keywords)
)
if branch in branches: if branch in branches:
self.error( base.io_error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra" f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
) )
branch = None branch = None
+21 -76
View File
@@ -1,105 +1,50 @@
from argparse import ArgumentParser, Namespace import typer
from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.command.base import Command import git_flow.command.base as base
from git_flow import GitFlowError
from git_flow.git import Git from git_flow.git import Git
app = typer.Typer()
class ReleaseCommand(Command):
def name(self) -> str:
return "release"
def description(self) -> str: @app.command()
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado""" def release():
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
base.ensure_initialized()
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser: branch = base.ensure_on_branch()
parser.add_argument( env, _ = base.get_branch_env_and_type(branch)
"--group",
nargs="?",
help="Agrupar release",
)
return parser if env == base.environments[-1]:
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
(env, _) = self.get_branch_env_and_type(branch)
if env == envs[-1]:
raise GitFlowError( raise GitFlowError(
"No se puede hacer release de una rama en el ultimo entorno." "No se puede hacer release de una rama en el ultimo entorno."
) )
has_remote = "flow.remote" in self.flowconfig has_remote = "flow.remote" in base.flowconfig
self.ensure_clean_worktree(has_remote) base.ensure_clean_worktree(has_remote)
next_env = envs[envs.index(env) + 1] next_env = base.environments[base.environments.index(env) + 1]
next_branch = ( next_branch = (
branch.replace(f"/{env}/", f"/{next_env}/") branch.replace(f"/{env}/", f"/{next_env}/")
if branch.startswith("release/") if branch.startswith("release/")
else f"release/{next_env}/{branch}" else f"release/{next_env}/{branch}"
) )
base = Git.get_first_fork_point(branch, env) fork_point = Git.get_first_fork_point(branch, env)
commits = Git("log", base + ".." , format="%s").lines() base.show_commits("Cambios a pasar al próximo entorno", fork_point + "..")
if not commits: if has_remote and Git.get_tracking_branch(next_env):
raise GitFlowError("No hay cambios a mergear.") Git("switch", next_env).exec(print="Cambiando a la rama objetivo")
print("Cambios a pasar al proximo entorno:")
for commit in commits:
print("- " + commit)
group = args.group
grouping = group is not None
if not grouping:
if self.confirm("¿Desea agrupar este release con otra rama?", False):
grouping = True
group = self.choice("Grupo release: ", Git.get_branches("release/" + next_env + "/"))
else:
group = next_env
if has_remote and Git.get_tracking_branch(group):
Git("switch", group).exec(print="Cambiando a la rama objetivo")
Git("pull").exec(print="Sincronizando cambios la rama objetivo") Git("pull").exec(print="Sincronizando cambios la rama objetivo")
Git("switch", "-").exec(print="Volviendo a la rama original") Git("switch", "-").exec(print="Volviendo a la rama original")
if len(commits) > 1 and self.confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?",
False
):
self.info("Debe ingresar el mensaje del commit a crear.")
commit_type = self.choice("Tipo de commit", COMMIT_TYPES[:-1])
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
Git("switch", next_branch, base, create=True).exec(
print="Creando rama release en base"
)
Git("merge", branch, squash=True).exec(
print="Squasheando commits en uno solo"
)
Git("commit", m=message).exec(print="Creando commit único")
else:
Git("switch", next_branch, create=True).exec(print="Creando rama release") Git("switch", next_branch, create=True).exec(print="Creando rama release")
status = Git("rebase", base, next_branch, onto=group).code( status = Git("rebase", fork_point, next_branch, onto=next_env).code(
print="Moviendo cambios hacia el siguiente ambiente" print="Moviendo cambios hacia el siguiente ambiente"
) )
if status: if status:
Git("rebase", abort=True).exec( Git("rebase", abort=True).exec(print="Deshaciendo cambios por conflictos")
print="Deshaciendo cambios por conflictos"
)
raise GitFlowError("Error al realizar pasaje de cambios a rama objetivo") raise GitFlowError("Error al realizar pasaje de cambios a rama objetivo")
elif grouping:
Git("switch", group).exec(print="Cambiando a rama objetivo")
Git("merge", next_branch, ff_only=True).exec(print="Mergeando cambios de la nueva rama")
+110 -58
View File
@@ -1,4 +1,5 @@
from argparse import ArgumentParser, Namespace import typer
from git_flow import ( from git_flow import (
COMMIT_TYPE_INCREMENT, COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR, SEMVER_MAJOR,
@@ -8,91 +9,142 @@ from git_flow import (
GitFlowError, GitFlowError,
) )
from git_flow.changelog import Changelog from git_flow.changelog import Changelog
from git_flow.command.base import Command from git_flow.command import base
from git_flow.git import Git from git_flow.git import Git
app = typer.Typer()
class TagCommand(Command): # Código de salida cuando no hace falta crear un tag, para que el CI pueda saltear la publicación
def name(self) -> str: EXIT_NO_TAG_NEEDED = 3
return "tag"
def description(self) -> str:
return """Crea un nuevo tag para el último merge."""
def run(self, args: Namespace = Namespace()): @app.command()
self.ensure_initialized() def tag(target: str | None = None, token: str | None = None, update_changelog: bool = False):
"""Crea un nuevo tag para el último merge."""
base.ensure_initialized()
envs = self.flowconfig["flow.branches"].split(",") target = target or Git.get_current_branch()
target = Git.get_current_branch()
if target not in envs: if target not in base.environments:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.") raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
branch = Git("show", self.get_last_merge_commit() + "^2", patch=False, format="%h").firstline() merge_commit = Git(
base = Git.get_first_fork_point(branch, target) "log", first_parent=True, merges=True, max_count=1, format="%H"
commits = Git("log", base + ".." + branch, format="%s").lines() ).firstline()
next_tag = self.get_next_tag_from_commits(commits, target)
if not merge_commit:
raise GitFlowError("No hay merges a taggear.")
merge_commit_tag = Git("tag", "--points-at", merge_commit).firstline()
if merge_commit_tag:
base.io_info(f"El último merge ya tiene un tag: {merge_commit_tag}")
raise typer.Exit(code=EXIT_NO_TAG_NEEDED)
token, ci = get_token_and_ci(token)
if ci and not token:
raise GitFlowError("No hay token disponible")
# Con varios entornos cada uno versiona por separado (v1.0.0-prod), con uno solo no hace falta
suffix = f"-{target}" if len(base.environments) > 1 else ""
# Un merge desde el entorno anterior (por ejemplo, dev a test) promueve su versión tal cual
target_index = base.environments.index(target)
source_env = base.environments[target_index - 1] if target_index > 0 else None
source_suffix = f"-{source_env}"
promoted_tag = None
api = base.get_remote_api(token) if ci else None
if api:
# Commits se obtienen por API del ultimo PR / MR mergeado
commits = api.get_merge_commits(merge_commit)
commits = [c for c in commits if not c.is_merge]
# Último tag se obtiene por API filtrando por sufijo
current_tag = api.get_latest_tag(suffix)
if source_env and api.get_merge_source_branch(merge_commit) == source_env:
promoted_tag = api.get_latest_tag(source_suffix)
else:
branch = Git("show", merge_commit + "^2", patch=False, format="%H").firstline()
promoted_tag = Git.get_promoted_tag(merge_commit, source_suffix) if source_env else None
if promoted_tag:
# Todos los cambios que el entorno anterior trae al destino
commits = Git.get_commits(merge_commit + "^1", branch, first_parent=False)
else:
# Commits se obtienen del último merge, listando hasta el primer forkpoint
fork_point = Git.get_first_fork_point(branch, target)
commits = Git.get_commits(fork_point, branch)
# Último tag se obtiene localmente
current_tag = Git.get_current_tag(suffix)
if promoted_tag:
next_tag = promoted_tag.removesuffix(source_suffix) + suffix
base.io_info(
f"Merge desde {source_env}: se promueve la versión {promoted_tag} a {target}."
)
# Puede existir si el destino ya llegó a esa versión por otro lado (por ejemplo, un release)
existing = api.list_tags(suffix) if api else Git("tag", next_tag, list=True).lines()
if next_tag in existing:
raise GitFlowError(f"El tag {next_tag} ya existe en {target}.")
else:
next_tag = get_next_tag_from_commits(
[c.subject for c in commits], suffix, current_tag
)
if not next_tag: if not next_tag:
self.info( base.io_warning(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior." "Los cambios realizados no implican un salto de versión, se mantiene la anterior."
) )
return
(token, ci) = self.get_token_and_ci(args) raise typer.Exit(code=EXIT_NO_TAG_NEEDED)
if update_changelog:
changelog = Changelog() changelog = Changelog()
changelog.update(next_tag, commits)
changelog.update(next_tag, base, branch)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md") Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit") Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline()
if ci: if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo") Git("push").exec()
self.success(self.get_remote_api(token).create_tag(next_tag, tag_commit))
tag_commit = Git("show", "HEAD", patch=False, format="%H").firstline()
if ci:
base.io_success(base.get_remote_api(token).create_tag(next_tag, tag_commit))
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente") Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(self, args: Namespace):
try:
return (self.ensure_repository_token(), False)
except GitFlowError:
return (args.token, True)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser: def get_token_and_ci(token: str | None):
parser.add_argument("--token") if token:
return (token, True)
else:
return (base.ensure_repository_token(), False)
return parser
def get_last_merge_commit(self): def get_next_tag_from_commits(
commit = Git( commits: list[str], suffix: str, current_tag: str | None
"log", first_parent=True, merges=True, max_count=1, format="%h" ) -> str | None:
).firstline()
if not commit:
raise GitFlowError("No hay merges a taggear.")
return commit
def check_not_tagged(self, commit: str):
tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if tag:
raise GitFlowError("El commit a taggear ya tiene tag: " + tag)
def get_next_tag_from_commits(self, commits: list[str], branch: str) -> str | None:
increment = SEMVER_SKIP increment = SEMVER_SKIP
for commit in commits: for commit in commits:
commit_type = commit[: commit.index(":")] match = base.COMMIT_SUBJECT_RE.match(commit)
increment = max(
increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP)
)
tag = Git.get_current_tag() or f"v0.0.0-{branch}" if not match:
continue
until_dash = tag.index("-") if "-" in tag else None commit_type, breaking, _ = match.groups()
suffix = tag[until_dash:] if until_dash is not None else "" commit_increment = SEMVER_MAJOR if breaking else COMMIT_TYPE_INCREMENT.get(commit_type)
[major, minor, patch] = map(int, tag[1:until_dash].split(".")) increment = max(increment, commit_increment or SEMVER_SKIP)
if increment == SEMVER_SKIP:
return None
version = (current_tag or "v0.0.0" + suffix).removeprefix("v").removesuffix(suffix)
[major, minor, patch] = map(int, version.split("."))
if increment == SEMVER_MAJOR: if increment == SEMVER_MAJOR:
major += 1 major += 1
@@ -104,4 +156,4 @@ class TagCommand(Command):
elif increment == SEMVER_PATCH: elif increment == SEMVER_PATCH:
patch += 1 patch += 1
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}" return f"v{major}.{minor}.{patch}{suffix}"
+107 -34
View File
@@ -1,14 +1,22 @@
import subprocess import subprocess
from dataclasses import dataclass
from git_flow import GitFlowError from rich.markup import escape
from rich.panel import Panel
from rich.text import Text
from git_flow import FLOWCONFIG_FILENAME
from git_flow.io import * from git_flow.io import *
FLOWCONFIG_FILE = ".flowconfig" @dataclass
class Commit:
"""Datos de un commit necesarios para versionar y generar el changelog."""
CHANGELOG_FILE = "CHANGELOG.md" sha: str
subject: str
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]" email: str
is_merge: bool = False
class Git: class Git:
@@ -18,8 +26,9 @@ class Git:
file: str file: str
def __init__(self, line: str) -> None: def __init__(self, line: str) -> None:
self.worktree = line[0] # Formato porcelain "XY <file>": X es el estado en el indice, Y en el worktree
self.index = line[1] self.index = line[0]
self.worktree = line[1]
self.file = line[3:].strip("\n") self.file = line[3:].strip("\n")
command: list[str] command: list[str]
@@ -44,7 +53,7 @@ class Git:
@staticmethod @staticmethod
def flow_config(*args, **kwargs): def flow_config(*args, **kwargs):
return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs) return Git("config", *args, file=FLOWCONFIG_FILENAME, **kwargs)
@staticmethod @staticmethod
def get_current_branch(): def get_current_branch():
@@ -54,13 +63,50 @@ class Git:
def get_branches(prefix: str = ""): def get_branches(prefix: str = ""):
return Git._get_references("heads/" + prefix) return Git._get_references("heads/" + prefix)
@staticmethod
def get_merge_conflicts(target: str, print: str | None = None) -> list[str]:
"""Archivos con conflictos al mergear target en HEAD. El merge se resuelve en memoria con
merge-tree, sin tocar el índice ni el worktree."""
process = Git(
"merge-tree", "HEAD", target, write_tree=True, name_only=True, messages=False
)._get(print, check=False)
# 0: sin conflictos, 1: con conflictos, otro: error (por ejemplo, historias sin relación)
if process.returncode not in (0, 1):
process.check_returncode()
# Primera línea: OID del árbol resultante, luego los archivos en conflicto
return [l for l in process.stdout.splitlines()[1:] if l]
@staticmethod @staticmethod
def status(): def status():
return list(map(Git.Status, Git("status", porcelain=True).lines(False))) return list(map(Git.Status, Git("status", porcelain=True).lines(False)))
@staticmethod @staticmethod
def get_current_tag(): def get_current_tag(suffix: str = ""):
return Git("describe", abbrev="0", tags=True).firstline(check=False) """Último tag SemVer alcanzable desde HEAD, con el sufijo exacto indicado."""
return Git(
"describe",
abbrev="0",
tags=True,
match="v[0-9]*.[0-9]*.[0-9]*" + suffix,
exclude=None if suffix else "*-*",
).firstline(check=False, print="Obteniendo tag actual")
@staticmethod
def get_promoted_tag(merge_commit: str, suffix: str) -> str | None:
"""Si el merge trae tags con el sufijo indicado que la rama destino no tenía (por ejemplo,
un merge de dev a test trae tags *-dev nuevos), devuelve el más alto de ellos."""
tags = Git(
"tag",
"v[0-9]*.[0-9]*.[0-9]*" + suffix,
list=True,
merged=merge_commit + "^2",
no_merged=merge_commit + "^1",
sort="-v:refname",
).lines()
return tags[0] if tags else None
@staticmethod @staticmethod
def get_first_fork_point(branch: str, env: str): def get_first_fork_point(branch: str, env: str):
@@ -72,6 +118,24 @@ class Git:
return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline() return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline()
@staticmethod
def get_commits(base: str, branch: str, first_parent: bool = True) -> list[Commit]:
"""Commits propios de la rama (sin merges), del más nuevo al más viejo."""
lines = Git(
"log",
base + ".." + branch,
first_parent=first_parent or None,
merges=False,
format="%h%x09%ae%x09%s",
).lines()
commits = []
for line in lines:
[sha, email, subject] = line.split("\t", 2)
commits.append(Commit(sha, subject, email))
return commits
@staticmethod @staticmethod
def _get_references(kind: str): def _get_references(kind: str):
prefix = "refs/" + kind prefix = "refs/" + kind
@@ -88,7 +152,9 @@ class Git:
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return None return None
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None) -> None: def __init__(
self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None
) -> None:
self.command = ["git", subcommand] self.command = ["git", subcommand]
for option, value in kwargs.items(): for option, value in kwargs.items():
@@ -120,9 +186,9 @@ class Git:
process = self._get(**kwargs) process = self._get(**kwargs)
lines = process.stdout.splitlines() lines = process.stdout.splitlines()
return lines if not strip else list(map(lambda l: l.strip(), lines)) return lines if not strip else [l.strip() for l in lines]
def firstline(self, **kwargs: bool): def firstline(self, **kwargs):
lines = self.lines(**kwargs) lines = self.lines(**kwargs)
return lines[0] if lines else "" return lines[0] if lines else ""
@@ -134,29 +200,23 @@ class Git:
self._run(**kwargs) self._run(**kwargs)
def _get(self, print: str | None = None, check: bool = True): def _get(self, print: str | None = None, check: bool = True):
process = subprocess.run(self.command, capture_output=True, text=True) process = subprocess.run(self.command, capture_output=True, text=True, check=False)
if print is not None: # Sin --verbose solo se muestra el paso; si falla, la salida la muestra main()
self._print_process(process.stdout, process.stderr, print) if print is not None and settings.verbose:
self._print_process(process, print)
elif print is not None:
console.print(Text.assemble(("› ", "cyan"), print))
if check: if check:
process.check_returncode() process.check_returncode()
return process return process
def _run(self, print: str | None = None, check: bool = True): def _run(self, print: str | None = None, check: bool = True):
process = subprocess.run(self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return self._get(print, check)
if print is not None: def _print_process(self, process: subprocess.CompletedProcess, title: str | None):
self._print_process("", "", title=print)
if check:
process.check_returncode()
return process
def _print_process(self, stdout: str, stderr: str, title: str):
command = [] command = []
for arg in self.command: for arg in self.command:
@@ -168,12 +228,25 @@ class Git:
command.append(arg) command.append(arg)
title = title or "Ejecutando" ok = process.returncode == 0
print(COLOR_YELLOW + "| " + title + COLOR_RESET) # git escribe mensajes informativos en stderr, así que solo se resalta si falló
print(COLOR_YELLOW + "| $ " + " ".join(command) + COLOR_RESET) stderr_style = "dim" if ok else "red"
text = Text.assemble(("$ ", "dim"), (" ".join(command), "bold yellow"))
for line in stdout.splitlines(): for line in process.stdout.splitlines():
print(COLOR_YELLOW + "| " + COLOR_RESET + "[out] " + line) text.append("\n" + line)
for line in stderr.splitlines(): for line in process.stderr.splitlines():
print(COLOR_YELLOW + "| " + COLOR_RED + "[err] " + line + COLOR_RESET) text.append("\n" + line, stderr_style)
console.print(
Panel(
text,
title=f"[bold]{escape(title or 'Ejecutando')}[/bold]",
title_align="left",
subtitle=None if ok else f"código de salida {process.returncode}",
subtitle_align="right",
border_style="bright_black" if ok else "red",
expand=False,
)
)
+50 -42
View File
@@ -1,56 +1,64 @@
import sys from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
from git_flow import COLOR_BLACK_BOLD, COLOR_BLUE, COLOR_GREEN, COLOR_RED, COLOR_RESET, COLOR_YELLOW console = Console(highlight=False)
def confirm(prompt, default: bool = True):
user_input = input(prompt + (" [Y/n]: " if default else " [y/N]: "))
return default if len(user_input) == 0 else user_input.startswith("y")
def choose(prompt: str, options: list[str]): class Settings:
print(prompt) """Opciones globales de ejecución, configuradas desde la línea de comandos."""
for i, option in enumerate(options): verbose: bool = False
print(f"\t{i+1}. {option}")
selection = None
while selection is None: settings = Settings()
user_input = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
if user_input.isdigit():
user_input = int(user_input)
if 1 <= user_input and user_input <= len(options): def success(msg: str):
selection = options[user_input - 1] console.print(f":white_check_mark: [green]{msg}[/green]")
if not confirm(
f"Seleccionó la opción {user_input} ({selection}), ¿es correcto?" def warning(msg: str):
): console.print(f":warning: [yellow]{msg}[/yellow]")
selection = None
def error(msg: str):
console.print(f":x: [red]{msg}[/red]")
def info(msg: str):
console.print(f":information: [blue]{msg}[/blue]")
def panel(title: str, text: str):
console.print(Panel(text, title=title, expand=False, title_align="left"))
def prompt(
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
result = Prompt.ask(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
error("Debe ingresar un valor no vacío.")
else: else:
print_error(f"La opción {user_input} está fuera del rango permitido.") return result
elif user_input in options:
selection = user_input
else:
print_error(f"La opción '{user_input}' es inválida.")
return selection
def print_error(message: str): def confirm(question: str, default: bool = True):
print(f"{COLOR_RED}[err] {message}{COLOR_RESET}", file=sys.stderr) return Confirm.ask(question, default=default)
def print_warning(message: str): def choice(prompt: str, options: list[str]):
print(f"{COLOR_YELLOW}[wrn] {message}{COLOR_RESET}") return Prompt.ask(prompt, choices=options)
def print_success(message: str):
print(f"{COLOR_GREEN}[ok!] {message}{COLOR_RESET}")
def print_info(message: str):
print(f"{COLOR_BLUE}[inf] {message}{COLOR_RESET}")
def print_debug(message: str):
print(f"{COLOR_BLACK_BOLD}[dbg] {message}{COLOR_RESET}")
+61 -72
View File
@@ -1,90 +1,79 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import locale from subprocess import CalledProcessError, SubprocessError
from argparse import Namespace, ArgumentParser from typing import Annotated
import typer
from rich.markup import escape
from rich.traceback import Traceback
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.command.base import Command from git_flow.command import base, branch, commit, init, merge, new, release, tag
from git_flow.command.branch import BranchCommand from git_flow.io import console, settings
from git_flow.command.commit import CommitCommand
from git_flow.command.init import InitCommand
from git_flow.command.merge import MergeCommand
from git_flow.command.new import NewCommand
from git_flow.command.release import ReleaseCommand
from git_flow.command.tag import TagCommand
from git_flow.io import *
REPOSITORY_TOKEN_PATH = ".repository-token" REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8" LOCALE = "es_AR.UTF-8"
class GitFlowCommand(Command): app = typer.Typer()
commands: tuple[Command, ...] app.add_typer(init.app)
app.add_typer(new.app)
app.add_typer(commit.app)
app.add_typer(merge.app)
app.add_typer(tag.app)
app.add_typer(release.app)
app.add_typer(branch.app)
def __init__(self, *args: Command) -> None:
self.commands = args
def name(self) -> str: @app.callback()
return "git-flow" def options(
verbose: Annotated[
def description(self) -> str: bool,
return """Git flow es una herramienta para automatizar un workflow siguiendo conventional commits, conventional branch y semver.""" typer.Option(
"--verbose",
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser: "-v",
subparsers = parser.add_subparsers( help="Muestra la salida de los comandos git y el traceback de los errores",
title="comandos", dest="command", required=True ),
) ] = False,
):
for command in self.commands: settings.verbose = verbose
subparser = subparsers.add_parser(
command.name(), description=command.description()
)
command.setup_parser(subparser)
return parser
def get_parser(self) -> ArgumentParser:
parser = ArgumentParser(self.name(), description=self.description())
return self.setup_parser(parser)
def run(self, args: Namespace = Namespace()):
args = self.get_parser().parse_args()
for command in self.commands:
if command.name() == args.command:
command.init()
return command.run(args)
def main(): def main():
try: try:
locale.setlocale(locale.LC_ALL, LOCALE) app()
except locale.Error as e:
print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}")
command = GitFlowCommand(
InitCommand(),
NewCommand(),
CommitCommand(),
MergeCommand(),
TagCommand(),
ReleaseCommand(),
BranchCommand()
)
try:
command.run()
except GitFlowError as e:
command.error(str(e))
except Exception as e:
command.error("Ocurrió un error inesperado: " + str(e))
except KeyboardInterrupt as e:
print() print()
command.error("Ejecución abortada") except GitFlowError as e:
_print_error(escape(str(e)), "Git Flow", e)
return except CalledProcessError as e:
_print_error(_format_process_error(e), "Comando", e)
except SubprocessError as e:
_print_error(escape(str(e)), "Comando", e)
except KeyboardInterrupt:
base.io_error("Ejecución abortada")
if __name__ == "__main__": def _format_process_error(e: CalledProcessError) -> str:
main() command = " ".join(e.cmd) if isinstance(e.cmd, list) else str(e.cmd)
message = f"[bold]$ {escape(command)}[/bold]\nTerminó con código de salida {e.returncode}"
for output in (e.stdout, e.stderr):
if isinstance(output, str) and output.strip():
message += "\n\n" + escape(output.strip())
return message
def _print_error(message: str, title: str, e: BaseException):
if settings.verbose:
base.io_error(message, title=title)
console.print(
Traceback.from_exception(
type(e), e, e.__traceback__, suppress=[typer]
)
)
else:
base.io_error(
message + "\n\n[dim]Ejecutar con `git-flow --verbose <comando>` para ver el detalle[/dim]",
title=title,
)
+97 -33
View File
@@ -1,7 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from git_flow import SUPPORTED_REMOTE_APIS, GitFlowError import re
from git_flow.git import Git
import giturlparse
import requests
from git_flow import SUPPORTED_REMOTE_URL_SCHEMAS, GitFlowError
from git_flow.git import Commit, Git
class RemoteAPI(ABC): class RemoteAPI(ABC):
@@ -11,43 +16,42 @@ class RemoteAPI(ABC):
@staticmethod @staticmethod
def parse(remote: str): def parse(remote: str):
"""Soportamos 3 tipos de URL: http, https y ssh. Si no se especifica el esquema, asumimos ssh:
Ejemplos:
- http://gitea/user/repo
- https://gitea.com/user/repo.git
- git@bitbucket.org:user/repo
- git.mygiteainstance.com:user/repo.git
- ssh://git@git.mygiteainstance.com:1234/user/repo.git
"""
url = Git("remote", "get-url", remote).firstline() url = Git("remote", "get-url", remote).firstline()
invalid_url_error = GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
url_type = None
if url.startswith("git@"): parsed_url = giturlparse.parse(url) # Validamos que la URL sea válida
url_type = "ssh"
elif url.startswith("http://") or url.startswith("https://"):
url_type = "https"
else:
raise invalid_url_error
if url_type == "ssh": if not parsed_url.valid:
# git@host:user/repo[.git] raise GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
at_pos = url.index("@")
colon_pos = url.index(":")
host = url[at_pos+1:colon_pos]
repository = url[colon_pos+1:]
else:
# https://host/user/repo[.git]
host_start = url.index("://") + 3
uri_start = url.index("/", host_start)
host = url[host_start:uri_start]
repository = url[uri_start+1:]
# Esto permite configurar hosts "imaginarios" para permitir el uso de multiples claves ssh if not parsed_url.protocol in SUPPORTED_REMOTE_URL_SCHEMAS:
if host.endswith(".bitbucket.org"): raise GitFlowError(
host = "bitbucket.org" f"La URL del remoto {remote} no tiene un esquema soportado: {url}"
elif host.endswith(".github.com"): )
host = "github.com"
if host not in SUPPORTED_REMOTE_APIS: # Cuando el host es seteado por pipeline puede ser "http://gitea/OWNER/REPO", en ese caso
raise GitFlowError("El host del repositorio remoto es inválido") # la libreria parsea OWNER="" y REPO="OWNER/REPO"
# En GitLab, los proyectos pueden estar dentro de grupos y subgrupos (ej:
repository.removesuffix(".git") # owner/grupo/subgrupo/repo), que la libreria expone por separado en groups_path
repository = (
return [host, repository] "/".join(
filter(
None,
[parsed_url.owner, parsed_url.groups_path, parsed_url.repo],
)
)
.removeprefix("/")
.removesuffix(".git")
)
return [parsed_url.protocol + "://", parsed_url.host, repository]
@abstractmethod @abstractmethod
def create_pull_request( def create_pull_request(
@@ -56,6 +60,7 @@ class RemoteAPI(ABC):
destination: str, destination: str,
title: str | None = None, title: str | None = None,
description: str | None = None, description: str | None = None,
close_source_branch: bool = True,
) -> str: ) -> str:
pass pass
@@ -63,6 +68,23 @@ class RemoteAPI(ABC):
def create_tag(self, tag: str, commit: str) -> str: def create_tag(self, tag: str, commit: str) -> str:
pass pass
@abstractmethod
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
"""Busca el PR que generó el merge commit y devuelve sus commits (del más nuevo al más
viejo), sin depender del historial local."""
pass
@abstractmethod
def get_merge_source_branch(self, merge_commit: str) -> str:
"""Nombre de la rama origen del PR que generó el merge commit."""
pass
@abstractmethod
def list_tags(self, suffix: str = "") -> list[str]:
"""Nombres de los tags del repositorio, sin orden garantizado. `suffix` es solo una pista
para que la API filtre; el filtrado definitivo se hace en `get_latest_tag`."""
pass
@abstractmethod @abstractmethod
def get_endpoint(self, resource: str) -> str: def get_endpoint(self, resource: str) -> str:
pass pass
@@ -73,3 +95,45 @@ class RemoteAPI(ABC):
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": "Bearer " + self.token, "Authorization": "Bearer " + self.token,
} }
def get_json(self, url: str, params: dict | None = None):
response = requests.get(url, headers=self.get_headers(), params=params)
if not response.ok:
raise GitFlowError(f"Ocurrió un error al consultar {url}: {response.text}")
return response
def get_latest_tag(self, suffix: str = "") -> str | None:
"""Tag SemVer más alto (vX.Y.Z + suffix exacto), o None si no hay ninguno."""
pattern = re.compile(rf"^v(\d+)\.(\d+)\.(\d+){re.escape(suffix)}$")
versions = {}
for name in self.list_tags(suffix):
match = pattern.match(name)
if match:
versions[tuple(map(int, match.groups()))] = name
return versions[max(versions)] if versions else None
def get_all_pages(
self,
url: str,
page_size: int = 50,
params: dict | None = None,
size_param: str = "per_page",
) -> list:
"""Recorre un endpoint paginado por número de página (page / per_page)."""
items: list = []
page = 1
while True:
query = {**(params or {}), "page": page, size_param: page_size}
batch = self.get_json(url, query).json()
items += batch
if len(batch) < page_size:
return items
page += 1
+58 -1
View File
@@ -1,5 +1,8 @@
import re
import requests import requests
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI from git_flow.remote.base import RemoteAPI
@@ -12,6 +15,7 @@ class BitbucketRemoteAPI(RemoteAPI):
destination: str, destination: str,
title: str | None = None, title: str | None = None,
description: str | None = None, description: str | None = None,
close_source_branch: bool = True
): ):
request = requests.post( request = requests.post(
self.get_endpoint("/pullrequests"), self.get_endpoint("/pullrequests"),
@@ -21,7 +25,7 @@ class BitbucketRemoteAPI(RemoteAPI):
"description": description, "description": description,
"source": {"branch": {"name": source}}, "source": {"branch": {"name": source}},
"destination": {"branch": {"name": destination}}, "destination": {"branch": {"name": destination}},
"close_source_branch": True, "close_source_branch": close_source_branch,
}, },
) )
@@ -43,5 +47,58 @@ class BitbucketRemoteAPI(RemoteAPI):
else: else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
commits: list[Commit] = []
url: str | None = self.get_endpoint(f"pullrequests/{pr['id']}/commits")
# Bitbucket pagina siguiendo el link "next", y devuelve del más nuevo al más viejo
while url:
page = self.get_json(url).json()
for c in page["values"]:
raw = c["author"].get("raw", "")
match = re.search(r"<(.+?)>", raw)
commits.append(
Commit(
c["hash"][:7],
c["message"].splitlines()[0],
match.group(1) if match else raw,
len(c["parents"]) > 1,
)
)
url = page.get("next")
return commits
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["source"]["branch"]["name"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
prs = self.get_json(self.get_endpoint(f"commit/{merge_commit}/pullrequests")).json()
pr = next((p for p in prs["values"] if p["state"] == "MERGED"), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
return pr
def list_tags(self, suffix: str = "") -> list[str]:
names: list[str] = []
url: str | None = self.get_endpoint("refs/tags")
params: dict[str, int|str] | None = {"pagelen": 100}
if suffix:
params["q"] = f'name ~ "{suffix}"'
while url:
page = self.get_json(url, params).json()
names += [t["name"] for t in page["values"]]
url = page.get("next")
params = None # "next" ya incluye la query
return names
def get_endpoint(self, resource: str) -> str: def get_endpoint(self, resource: str) -> str:
return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}" return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}"
+96
View File
@@ -0,0 +1,96 @@
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
class GiteaRemoteAPI(RemoteAPI):
def __init__(self, base_url: str, repository: str, token: str) -> None:
super().__init__(repository, token)
self.base_url = base_url
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
response = requests.post(
self.get_endpoint("/pulls"),
headers=self.get_headers(),
json={
"title": title,
"body": description,
"head": source,
"base": destination,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["html_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
def create_tag(self, tag: str, commit: str) -> str:
response = requests.post(
self.get_endpoint("/tags"),
headers=self.get_headers(),
json={"tag_name": tag, "target": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
url = self.get_endpoint(f"/pulls/{pr['number']}/commits")
items: list = []
page = 1
# Gitea pagina con page / limit
while True:
batch = self.get_json(
url, {"page": page, "limit": 50, "files": "false", "verification": "false"}
).json()
items += batch
if len(batch) < 50:
break
page += 1
# La API los devuelve del más viejo al más nuevo
return [
Commit(
c["sha"][:7],
c["commit"]["message"].splitlines()[0],
c["commit"]["author"]["email"],
len(c["parents"]) > 1,
)
for c in reversed(items)
]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["head"]["ref"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
return self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pull")).json()
def list_tags(self, suffix: str = "") -> list[str]:
tags = self.get_all_pages(self.get_endpoint("/tags"), 50, size_param="limit")
return [t["name"] for t in tags]
def get_endpoint(self, resource: str) -> str:
return f"{self.base_url}/api/v1/repos/{self.repository}{resource}"
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "token " + self.token,
}
+60 -4
View File
@@ -1,9 +1,11 @@
import requests
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI from git_flow.remote.base import RemoteAPI
class GithubRemoteAPI(RemoteAPI): class GithubRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://github.com" API_ENDPOINT = "https://api.github.com/repos"
def create_pull_request( def create_pull_request(
self, self,
@@ -11,11 +13,65 @@ class GithubRemoteAPI(RemoteAPI):
destination: str, destination: str,
title: str | None = None, title: str | None = None,
description: str | None = None, description: str | None = None,
close_source_branch: bool = True
) -> str: ) -> str:
raise GitFlowError("not implemented yet!") response = requests.post(
self.get_endpoint("/pulls"),
headers=self.get_headers(),
json={
"title": title,
"body": description,
"head": source,
"base": destination,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["html_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
def create_tag(self, tag: str, commit: str) -> str: def create_tag(self, tag: str, commit: str) -> str:
raise GitFlowError("not implemented yet!") response = requests.post(
self.get_endpoint("/git/refs"),
headers=self.get_headers(),
json={"ref": f"refs/tags/{tag}", "sha": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
items = self.get_all_pages(self.get_endpoint(f"/pulls/{pr['number']}/commits"), 100)
# La API los devuelve del más viejo al más nuevo
return [
Commit(
c["sha"][:7],
c["commit"]["message"].splitlines()[0],
c["commit"]["author"]["email"],
len(c["parents"]) > 1,
)
for c in reversed(items)
]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["head"]["ref"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
prs = self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pulls")).json()
pr = next((p for p in prs if p.get("merge_commit_sha") == merge_commit), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
return pr
def list_tags(self, suffix: str = "") -> list[str]:
return [t["name"] for t in self.get_all_pages(self.get_endpoint("/tags"), 100)]
def get_endpoint(self, resource: str) -> str: def get_endpoint(self, resource: str) -> str:
return GithubRemoteAPI.API_ENDPOINT return f"{GithubRemoteAPI.API_ENDPOINT}/{self.repository}{resource}"
+96
View File
@@ -0,0 +1,96 @@
from urllib.parse import quote
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
class GitlabRemoteAPI(RemoteAPI):
def __init__(self, base_url: str, repository: str, token: str) -> None:
super().__init__(repository, token)
self.base_url = base_url
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
response = requests.post(
self.get_endpoint("/merge_requests"),
headers=self.get_headers(),
json={
"title": title,
"description": description,
"source_branch": source,
"target_branch": destination,
"remove_source_branch": close_source_branch,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["web_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
def create_tag(self, tag: str, commit: str) -> str:
response = requests.post(
self.get_endpoint("/repository/tags"),
headers=self.get_headers(),
json={"tag_name": tag, "ref": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
mr = self._get_merge_request(merge_commit)
items = self.get_all_pages(self.get_endpoint(f"/merge_requests/{mr['iid']}/commits"), 100)
# GitLab ya los devuelve del más nuevo al más viejo
return [
Commit(
c["short_id"][:7],
c["title"],
c["author_email"],
len(c["parent_ids"]) > 1,
)
for c in items
]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_request(merge_commit)["source_branch"]
def _get_merge_request(self, merge_commit: str) -> dict:
mrs = self.get_json(
self.get_endpoint(f"/repository/commits/{merge_commit}/merge_requests")
).json()
mr = next((m for m in mrs if m.get("merge_commit_sha") == merge_commit), None)
if not mr:
raise GitFlowError(f"No se encontró un MR para el commit {merge_commit}")
return mr
def list_tags(self, suffix: str = "") -> list[str]:
# "search" soporta "$" para anclar al final del nombre
params = {"search": suffix + "$"} if suffix else None
tags = self.get_all_pages(self.get_endpoint("/repository/tags"), 100, params)
return [t["name"] for t in tags]
def get_endpoint(self, resource: str) -> str:
project_id = quote(self.repository, safe="")
return f"{self.base_url}/api/v4/projects/{project_id}{resource}"
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"PRIVATE-TOKEN": self.token,
}
Generated
+151 -2
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@@ -101,14 +110,54 @@ wheels = [
] ]
[[package]] [[package]]
name = "git-flow" name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "git-flow-envs"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "giturlparse" },
{ name = "questionary" },
{ name = "requests" }, { name = "requests" },
{ name = "rich" },
{ name = "typer" },
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "requests" }] requires-dist = [
{ name = "giturlparse" },
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
{ name = "typer" },
]
[[package]]
name = "giturlparse"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8a/b70b84cc78f9059d627f09560c81a11e8f046570d220a24e169489cad6c9/giturlparse-0.15.0.tar.gz", hash = "sha256:9af3f1fd5c4a0cac94ddb283593635005646393ee0debbe330d1bdff8866bf2c", size = 16138, upload-time = "2026-06-16T07:28:56.021Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/96/147a2771ab655b9353781fb2f95c94eaf1d8576dccc991c1a61d0d355067/giturlparse-0.15.0-py2.py3-none-any.whl", hash = "sha256:76d2e6983b037356ab99b30683e533ac3db96409b68e2163a20fc3aff6446f10", size = 16683, upload-time = "2026-06-16T07:28:55.184Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
@@ -119,6 +168,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@@ -134,6 +237,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
] ]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"
@@ -142,3 +282,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
] ]
[[package]]
name = "wcwidth"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
]