From 26ab81b1751ddf93b3c33a59913a03f53ea263cb Mon Sep 17 00:00:00 2001 From: Jonathan Teran Carballo Date: Sun, 20 Sep 2026 21:05:21 -0300 Subject: [PATCH 1/2] feature: tag from ci fetches commits from remote api --- src/git_flow/changelog.py | 23 ++++++++---------- src/git_flow/command/tag.py | 40 ++++++++++++++++++-------------- src/git_flow/git.py | 25 ++++++++++++++++++++ src/git_flow/remote/base.py | 31 ++++++++++++++++++++++++- src/git_flow/remote/bitbucket.py | 33 ++++++++++++++++++++++++++ src/git_flow/remote/gitea.py | 30 ++++++++++++++++++++++++ src/git_flow/remote/github.py | 21 +++++++++++++++++ src/git_flow/remote/gitlab.py | 23 ++++++++++++++++++ 8 files changed, 195 insertions(+), 31 deletions(-) diff --git a/src/git_flow/changelog.py b/src/git_flow/changelog.py index 7cc5d51..3a599e5 100644 --- a/src/git_flow/changelog.py +++ b/src/git_flow/changelog.py @@ -1,15 +1,15 @@ from datetime import datetime from os.path import isfile -from git_flow.git import Git +from git_flow.git import Commit class Changelog: FILENAME = "CHANGELOG.md" COMMIT_MESSAGE = "chore: bump version and update CHANGELOG.md [skip ci]" - def update(self, title: str, base: str, branch: str): - self._prepend(self.generate_entry(title, base, branch)) + def update(self, title: str, commits: list[Commit]): + self._prepend(self.generate_entry(title, commits)) def generate_header(self, title): now = datetime.now() @@ -18,16 +18,13 @@ class Changelog: """ - def generate_content(self, base: str, branch: str): - commits = Git( - "log", base + ".." + branch, first_parent=True, format="%h", merges=False - ).lines() + def generate_content(self, commits: list[Commit]): content = "" for commit in commits: - message = Git("show", commit, patch=False, format="%s").firstline() - email = Git("show", commit, patch=False, format="%ae").firstline() - username = email[: email.index("@")] + message = commit.subject + email = commit.email + username = email[: email.index("@")] if "@" in email else email description = message if ":" in message: @@ -36,7 +33,7 @@ class Changelog: commit_message = message[index + 1 :] description = f"**{commit_type}**: {commit_message}" - content += f"- {description} [[{username}](mailto:{email})] ({commit})\n" + content += f"- {description} [[{username}](mailto:{email})] ({commit.sha})\n" return content @@ -47,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 ( self.generate_header(title) - + self.generate_content(base, branch) + + self.generate_content(commits) + self.generate_footer() ) diff --git a/src/git_flow/command/tag.py b/src/git_flow/command/tag.py index 0100c42..dea2ce5 100644 --- a/src/git_flow/command/tag.py +++ b/src/git_flow/command/tag.py @@ -27,12 +27,23 @@ def tag(token: Optional[str] = None): if target not in base.environments: raise GitFlowError("Solo se pueden taggear commits en ramas principales.") - branch = Git( - "show", get_last_merge_commit() + "^2", patch=False, format="%h" - ).firstline() - fork_point = Git.get_first_fork_point(branch, target) - commits = Git("log", fork_point + ".." + branch, format="%s").lines() - next_tag = get_next_tag_from_commits(commits, target) + token, ci = get_token_and_ci(token) + + if ci and not token: + raise GitFlowError("No hay token disponible") + + merge_commit = get_last_merge_commit() + + if ci: + # Pipeline: no asumimos historial completo, los commits del PR se piden a la API + commits = base.get_remote_api(token).get_merge_commits(merge_commit) + commits = [c for c in commits if not c.is_merge] + else: + 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) + + next_tag = get_next_tag_from_commits([c.subject for c in commits], target) if not next_tag: base.io_info( @@ -40,14 +51,9 @@ def tag(token: Optional[str] = None): ) return - token, ci = get_token_and_ci(token) - - if not token: - raise GitFlowError("No hay token disponible") - changelog = Changelog() - changelog.update(next_tag, fork_point, branch) + changelog.update(next_tag, commits) Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md") Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit") tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline() @@ -60,15 +66,15 @@ def tag(token: Optional[str] = None): def get_token_and_ci(token: Optional[str]): - try: - return (base.ensure_repository_token(), False) - except GitFlowError: + if token: return (token, True) + else: + return (base.ensure_repository_token(), False) def get_last_merge_commit(): commit = Git( - "log", first_parent=True, merges=True, max_count=1, format="%h" + "log", first_parent=True, merges=True, max_count=1, format="%H" ).firstline() if not commit: @@ -87,7 +93,7 @@ def check_not_tagged(commit: str): def get_next_tag_from_commits(commits: list[str], branch: str) -> str | None: increment = SEMVER_SKIP for commit in commits: - commit_type = commit[: commit.index(":")] + commit_type = commit.partition(":")[0] increment = max(increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP)) tag = Git.get_current_tag() or f"v0.0.0-{branch}" diff --git a/src/git_flow/git.py b/src/git_flow/git.py index c4822c0..8b2e29a 100644 --- a/src/git_flow/git.py +++ b/src/git_flow/git.py @@ -1,9 +1,20 @@ import subprocess +from dataclasses import dataclass from git_flow import FLOWCONFIG_FILENAME from git_flow.io import * +@dataclass +class Commit: + """Datos de un commit necesarios para versionar y generar el changelog.""" + + sha: str + subject: str + email: str + is_merge: bool = False + + class Git: class Status: worktree: str @@ -65,6 +76,20 @@ class Git: return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline() + @staticmethod + def get_commits(base: str, branch: str) -> 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" + ).lines() + commits = [] + + for line in lines: + [sha, email, subject] = line.split("\t", 2) + commits.append(Commit(sha, subject, email)) + + return commits + @staticmethod def _get_references(kind: str): prefix = "refs/" + kind diff --git a/src/git_flow/remote/base.py b/src/git_flow/remote/base.py index ae140d6..df959be 100644 --- a/src/git_flow/remote/base.py +++ b/src/git_flow/remote/base.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod import giturlparse +import requests from git_flow import SUPPORTED_REMOTE_URL_SCHEMAS, GitFlowError -from git_flow.git import Git +from git_flow.git import Commit, Git class RemoteAPI(ABC): @@ -65,6 +66,12 @@ class RemoteAPI(ABC): def create_tag(self, tag: str, commit: str) -> str: 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_endpoint(self, resource: str) -> str: pass @@ -75,3 +82,25 @@ class RemoteAPI(ABC): "Content-Type": "application/json", "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_all_pages(self, url: str, page_size: int = 50) -> list: + """Recorre un endpoint paginado por número de página (page / per_page).""" + items: list = [] + page = 1 + + while True: + batch = self.get_json(url, {"page": page, "per_page": page_size}).json() + items += batch + + if len(batch) < page_size: + return items + + page += 1 diff --git a/src/git_flow/remote/bitbucket.py b/src/git_flow/remote/bitbucket.py index b329433..85be4db 100644 --- a/src/git_flow/remote/bitbucket.py +++ b/src/git_flow/remote/bitbucket.py @@ -1,5 +1,8 @@ +import re + import requests from git_flow import GitFlowError +from git_flow.git import Commit from git_flow.remote.base import RemoteAPI @@ -44,5 +47,35 @@ class BitbucketRemoteAPI(RemoteAPI): else: 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}") + + 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_endpoint(self, resource: str) -> str: return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}" diff --git a/src/git_flow/remote/gitea.py b/src/git_flow/remote/gitea.py index f9afef7..e99c38e 100644 --- a/src/git_flow/remote/gitea.py +++ b/src/git_flow/remote/gitea.py @@ -1,5 +1,6 @@ import requests from git_flow import GitFlowError +from git_flow.git import Commit from git_flow.remote.base import RemoteAPI @@ -44,6 +45,35 @@ class GiteaRemoteAPI(RemoteAPI): 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_json(self.get_endpoint(f"/commits/{merge_commit}/pull")).json() + 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_endpoint(self, resource: str) -> str: return f"{self.base_url}/api/v1/repos/{self.repository}{resource}" diff --git a/src/git_flow/remote/github.py b/src/git_flow/remote/github.py index ccb31c4..c660d95 100644 --- a/src/git_flow/remote/github.py +++ b/src/git_flow/remote/github.py @@ -1,5 +1,6 @@ import requests from git_flow import GitFlowError +from git_flow.git import Commit from git_flow.remote.base import RemoteAPI @@ -42,5 +43,25 @@ class GithubRemoteAPI(RemoteAPI): else: 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}") + + 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_endpoint(self, resource: str) -> str: return f"{GithubRemoteAPI.API_ENDPOINT}/{self.repository}{resource}" diff --git a/src/git_flow/remote/gitlab.py b/src/git_flow/remote/gitlab.py index 1bc7ae2..820f017 100644 --- a/src/git_flow/remote/gitlab.py +++ b/src/git_flow/remote/gitlab.py @@ -2,6 +2,7 @@ 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 @@ -47,6 +48,28 @@ class GitlabRemoteAPI(RemoteAPI): else: raise GitFlowError("Ocurrió un error al crear el tag para repositorio " + quote(self.repository, safe="") + ": " + 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}") + + 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_endpoint(self, resource: str) -> str: project_id = quote(self.repository, safe="") return f"{self.base_url}/api/v4/projects/{project_id}{resource}" From c50688bb1a1473f493333766934b458c285f3d3a Mon Sep 17 00:00:00 2001 From: Jonathan Teran Carballo Date: Sun, 20 Sep 2026 21:08:12 -0300 Subject: [PATCH 2/2] bugfix: fix parameters for changelog generate_content --- src/git_flow/command/merge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/git_flow/command/merge.py b/src/git_flow/command/merge.py index 0b2dd42..2f0efee 100644 --- a/src/git_flow/command/merge.py +++ b/src/git_flow/command/merge.py @@ -111,11 +111,12 @@ def create_pull_request(token: str, branch: str, target: str): if base.io_confirm("¿Desea cambiar el título del PR?", False): title = base.io_prompt("Título del PR", persistent=True) + commits = Git.get_commits(target, branch) message = base.get_remote_api(token).create_pull_request( branch, target, title, - changelog.generate_content(target, branch), + changelog.generate_content(commits), branch not in base.environments, )