From 44fa8bc1d6e68fa21b8cd4348f1ead02ba6ea6db Mon Sep 17 00:00:00 2001 From: Jonathan Teran Date: Thu, 24 Sep 2026 12:06:19 -0300 Subject: [PATCH] feature: when merging environments, promote tag from previous env to next env --- README.md | 5 ++++ src/git_flow/command/tag.py | 50 ++++++++++++++++++++++++-------- src/git_flow/git.py | 23 +++++++++++++-- src/git_flow/remote/base.py | 5 ++++ src/git_flow/remote/bitbucket.py | 19 ++++++++---- src/git_flow/remote/gitea.py | 8 ++++- src/git_flow/remote/github.py | 19 ++++++++---- src/git_flow/remote/gitlab.py | 23 ++++++++++----- 8 files changed, 117 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 74dce2f..66856cc 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ Comportamiento: implica un salto de versión, se avisa y el comando termina con código de salida 1. - 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 diff --git a/src/git_flow/command/tag.py b/src/git_flow/command/tag.py index 13259bd..6abb53b 100644 --- a/src/git_flow/command/tag.py +++ b/src/git_flow/command/tag.py @@ -37,9 +37,6 @@ def tag(target: str | None = None, token: str | None = None, update_changelog: b base.io_info(f"El último merge ya tiene un tag: {merge_commit_tag}") return - # 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 "" - token, ci = get_token_and_ci(token) if ci and not token: @@ -48,23 +45,52 @@ def tag(target: str | None = None, token: str | None = None, update_changelog: b # 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 "" - if ci: + # 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 = base.get_remote_api(token).get_merge_commits(merge_commit) + 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 = base.get_remote_api(token).get_latest_tag(suffix) + 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: - # Commits se obtienen del último merge, listando hasta el primer forkpoint branch = Git("show", merge_commit + "^2", patch=False, format="%H").firstline() - fork_point = Git.get_first_fork_point(branch, target) - commits = Git.get_commits(fork_point, branch) + 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) - next_tag = get_next_tag_from_commits( - [c.subject for c in commits], suffix, current_tag - ) + 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: base.io_warning( diff --git a/src/git_flow/git.py b/src/git_flow/git.py index 0c263f8..723bde8 100644 --- a/src/git_flow/git.py +++ b/src/git_flow/git.py @@ -73,6 +73,21 @@ class Git: 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 def get_first_fork_point(branch: str, env: str): # Lista de commits alcanzables por el entorno (por ejemplo, dev), @@ -84,10 +99,14 @@ class Git: return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline() @staticmethod - def get_commits(base: str, branch: str) -> list[Commit]: + 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=True, merges=False, format="%h%x09%ae%x09%s" + "log", + base + ".." + branch, + first_parent=first_parent or None, + merges=False, + format="%h%x09%ae%x09%s", ).lines() commits = [] diff --git a/src/git_flow/remote/base.py b/src/git_flow/remote/base.py index 7a239b0..712165d 100644 --- a/src/git_flow/remote/base.py +++ b/src/git_flow/remote/base.py @@ -74,6 +74,11 @@ class RemoteAPI(ABC): 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 diff --git a/src/git_flow/remote/bitbucket.py b/src/git_flow/remote/bitbucket.py index d7b6ce1..6040738 100644 --- a/src/git_flow/remote/bitbucket.py +++ b/src/git_flow/remote/bitbucket.py @@ -48,12 +48,7 @@ class BitbucketRemoteAPI(RemoteAPI): raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) def get_merge_commits(self, merge_commit: str) -> list[Commit]: - 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}") - + pr = self._get_merge_pull_request(merge_commit) commits: list[Commit] = [] url: str | None = self.get_endpoint(f"pullrequests/{pr['id']}/commits") @@ -77,6 +72,18 @@ class BitbucketRemoteAPI(RemoteAPI): 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") diff --git a/src/git_flow/remote/gitea.py b/src/git_flow/remote/gitea.py index 3169fbd..d51df9e 100644 --- a/src/git_flow/remote/gitea.py +++ b/src/git_flow/remote/gitea.py @@ -46,7 +46,7 @@ class GiteaRemoteAPI(RemoteAPI): raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) def get_merge_commits(self, merge_commit: str) -> list[Commit]: - pr = self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pull")).json() + pr = self._get_merge_pull_request(merge_commit) url = self.get_endpoint(f"/pulls/{pr['number']}/commits") items: list = [] page = 1 @@ -74,6 +74,12 @@ class GiteaRemoteAPI(RemoteAPI): 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") diff --git a/src/git_flow/remote/github.py b/src/git_flow/remote/github.py index aed1541..b814692 100644 --- a/src/git_flow/remote/github.py +++ b/src/git_flow/remote/github.py @@ -44,12 +44,7 @@ class GithubRemoteAPI(RemoteAPI): raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) def get_merge_commits(self, merge_commit: str) -> list[Commit]: - 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}") - + 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 @@ -63,6 +58,18 @@ class GithubRemoteAPI(RemoteAPI): 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)] diff --git a/src/git_flow/remote/gitlab.py b/src/git_flow/remote/gitlab.py index 16259a3..d44f77b 100644 --- a/src/git_flow/remote/gitlab.py +++ b/src/git_flow/remote/gitlab.py @@ -49,14 +49,7 @@ class GitlabRemoteAPI(RemoteAPI): raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) def get_merge_commits(self, merge_commit: str) -> list[Commit]: - 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}") - + 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 @@ -70,6 +63,20 @@ class GitlabRemoteAPI(RemoteAPI): 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 -- 2.54.0