Compare commits

...
29 Commits
Author SHA1 Message Date
bitbucket-pipelines 9c5ec26529 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-22 15:09:27 +00:00
JonathanGitFlowandjt e6386ed252 Merged in feature/add-git-exec-options (pull request #31)
feature: add git exec options

Approved-by: Jonathan Teran
2025-11-22 15:08:46 +00:00
jt 34aba71d32 bugfix: deshabilito check en merge --abort al buscar conflictos 2025-11-22 12:07:46 -03:00
jt 4135a789e3 feature: agregando descripcion a ejecucion de comandos importantes en new, tag y merge 2025-11-22 12:02:39 -03:00
jt faea8c2d2a feature: agregando descripcion a ejecucion de comandos importantes en init y commit 2025-11-22 11:49:12 -03:00
jt 11e2a9daa9 bugfix: cambiando tipo de print para agregar soporte de titulos 2025-11-22 11:48:19 -03:00
jt d133c480c5 bugfix: descartar output cuando el comando git se ejecuta con _run 2025-11-22 11:28:51 -03:00
jt 7346cfd1a8 feature: muevo opciones para check y print a metodos para ejecutar git 2025-11-22 11:23:24 -03:00
JonathanGitFlowandjt 37a7c101af Merged in refactor/read-flowconfig-before-run (pull request #30)
refactor: read flowconfig before run

Approved-by: Jonathan Teran
2025-11-21 23:48:58 +00:00
jt edf4948719 refactor: realizo parseo de flowconfig antes de ejecutar run, no en __init__ 2025-11-21 20:48:20 -03:00
bitbucket-pipelines 4dbef58e90 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-21 23:44:00 +00:00
JonathanGitFlowandjt da385154d4 Merged in bugfix/tag-on-update-changelog (pull request #29)
bugfix: tag on update changelog

Approved-by: Jonathan Teran
2025-11-21 23:42:01 +00:00
jt 222c819b36 bugfix: creo tag en el commit que actualiza el changelog 2025-11-21 20:41:37 -03:00
jt 0e75a8ec1f style: ignorar carpeta dist/ 2025-11-21 20:33:55 -03:00
bitbucket-pipelines 58401bf6e1 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-21 17:43:26 +00:00
JonathanGitFlowandjt acf1c0fdc8 Merged in refactor/separate-command-files (pull request #28)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-21 17:42:59 +00:00
jt bf829a114b bugfix: separar etapa de lectura y escritura de changelog 2025-11-21 14:41:49 -03:00
JonathanGitFlowandjt b8e43f065c Merged in refactor/separate-command-files (pull request #27)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-20 00:06:34 +00:00
jt 8d973f0b2b bugfix: separar etapa de lectura y escritura de changelog 2025-11-19 21:06:09 -03:00
JonathanGitFlowandjt bac1f04d9a Merged in refactor/separate-command-files (pull request #26)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-20 00:03:06 +00:00
jt 8f79a6a288 bugfix: agrego http:// como protocolo soportado para pipelines 2025-11-19 21:02:33 -03:00
JonathanGitFlowandjt ccda277674 Merged in refactor/separate-command-files (pull request #25)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-19 23:57:52 +00:00
jt 4a0356c229 bugfix: uso base para generar contenido del PR y muestro mensajes de exito 2025-11-19 20:57:14 -03:00
jt be87c2098b feature: cambio merge y tag para que el changelog se genere al crear un tag nuevo 2025-11-19 20:43:22 -03:00
bitbucket-pipelines 30c6c36beb chore: bump version and update CHANGELOG.md [skip ci] 2025-11-08 00:28:27 +00:00
JonathanGitFlowandjt 0495f96f8d Merged in bugfix/pipeline-changelog-generation (pull request #24)
bugfix: pipeline changelog generation

Approved-by: Jonathan Teran
2025-11-08 00:28:02 +00:00
jt 14beea150d bugfix: cambio el commit que se usa para generar las entradas del changelog 2025-11-07 21:26:59 -03:00
JonathanGitFlowandjt 1c703f285e Merged in bugfix/changelog-username-links (pull request #23)
bugfix: changelog username links

Approved-by: Jonathan Teran
2025-11-08 00:20:51 +00:00
jt 9799df2147 bugfix: arreglo generacion de links en changelog entries 2025-11-07 21:20:22 -03:00
17 changed files with 1117 additions and 262 deletions
+1
View File
@@ -3,3 +3,4 @@
**/__pycache__/ **/__pycache__/
build/ build/
**/*.egg-info/ **/*.egg-info/
dist/
+45
View File
@@ -1,3 +1,48 @@
## v1.2.0 - 2025-11-22
### Commits
- **bugfix**: deshabilito check en merge --abort al buscar conflictos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (34aba71)
- **feature**: agregando descripcion a ejecucion de comandos importantes en new, tag y merge [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (4135a78)
- **feature**: agregando descripcion a ejecucion de comandos importantes en init y commit [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (faea8c2)
- **bugfix**: cambiando tipo de print para agregar soporte de titulos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (11e2a9d)
- **bugfix**: descartar output cuando el comando git se ejecuta con _run [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d133c48)
- **feature**: muevo opciones para check y print a metodos para ejecutar git [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7346cfd)
---
## v1.1.5 - 2025-11-21
### Commits
- **bugfix**: creo tag en el commit que actualiza el changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (222c819)
- **style**: ignorar carpeta dist/ [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0e75a8e)
---
## v1.1.4 - 2025-11-21
### Commits
- **bugfix**: separar etapa de lectura y escritura de changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf829a1)
- **bugfix**: agrego http:// como protocolo soportado para pipelines [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (8f79a6a)
---
## Saturday, 8 de November de 2025, 00:28
- Autor: [bitbucket-pipelines](mailto:commits-noreply@bitbucket.org)
- Rama: `14beea150de16e06f7b4e54361584dae5d5ae713`
### Commits
- **bugfix**: cambio el commit que se usa para generar las entradas del changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (14beea1)
---
## Viernes, 7 de Noviembre de 2025, 11:42 ## Viernes, 7 de Noviembre de 2025, 11:42
- Autor: [Jonathan Teran](mailto:jteran@renatre.org.ar) - Autor: [Jonathan Teran](mailto:jteran@renatre.org.ar)
+1 -2
View File
@@ -9,6 +9,5 @@ pipelines:
- pip - pip
script: script:
- cd $BITBUCKET_CLONE_DIR - cd $BITBUCKET_CLONE_DIR
- pip install --upgrade pip
- pip install . - pip install .
- git-flow tag $BEARER - git-flow tag --token=$BEARER
+47
View File
@@ -0,0 +1,47 @@
GitFlowError = ValueError
BRANCH_TYPES = [
"feature",
"refactor",
"bugfix",
"hotfix",
"chore",
]
COMMIT_TYPES = [
"feature",
"bugfix",
"hotfix",
"refactor",
"perf",
"docs",
"style",
"test",
"wip"
]
# Commits con estos valores incrementan el componente correspondiente
SEMVER_MAJOR = 3
SEMVER_MINOR = 2
SEMVER_PATCH = 1
# Commits con estos tipos no alteran la versión
SEMVER_SKIP = 0
COMMIT_TYPE_INCREMENT = {
"feature": SEMVER_MINOR,
"bugfix": SEMVER_PATCH,
"hotfix": SEMVER_PATCH,
"refactor": SEMVER_SKIP,
"perf": SEMVER_SKIP,
"docs": SEMVER_SKIP,
"style": SEMVER_SKIP,
"test": SEMVER_SKIP,
}
SUPPORTED_REMOTE_APIS = [
"bitbucket.org",
"github.com",
]
REPOSITORY_TOKEN_FILENAME = ".repository-token"
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime
from os.path import isfile
from git_flow.git import Git
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 generate_header(self, title):
now = datetime.now()
return f"""## {title} - {now.strftime("%F")}
### Commits
"""
def generate_content(self, base: str, branch: str):
commits = Git(
"log", base + ".." + branch, first_parent=True, format="%h", merges=False
).lines()
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("@")]
index = message.index(":")
commit_type = message[:index]
commit_message = message[index + 1 :]
content += f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})\n"
return content
def generate_footer(self):
return """
---
"""
def generate_entry(self, title: str, base: str, branch: str):
return (
self.generate_header(title)
+ self.generate_content(base, branch)
+ self.generate_footer()
)
def _prepend(self, content: str):
if isfile(self.FILENAME):
with open(self.FILENAME, "r") as f:
content += f.read()
with open(self.FILENAME, "w") as f:
f.write(content)
View File
+221
View File
@@ -0,0 +1,221 @@
from argparse import Namespace, ArgumentParser
from abc import ABC, abstractmethod
import os
from os.path import isfile
from git_flow import (
BRANCH_TYPES,
REPOSITORY_TOKEN_FILENAME,
GitFlowError,
)
from git_flow.git import FLOWCONFIG_FILE, Git
from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.github import GithubRemoteAPI
COLOR_BLACK = "\033[30m"
COLOR_RED = "\033[31m"
COLOR_GREEN = "\033[32m"
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_RESET = "\033[0m"
TYPE_SUCCESS = 0
TYPE_WARNING = 1
TYPE_ERROR = 2
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 success(self, msg: str):
self._print(TYPE_SUCCESS, msg)
def warning(self, msg: str):
self._print(TYPE_WARNING, msg)
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:
raise GitFlowError(
"El repositorio no fue inicializado, debe ejecutar el comando `init`."
)
elif initialized != "true":
raise GitFlowError(
f"El valor de `flow.initialized` ({initialized}) es inválido."
)
def ensure_right_branch(self):
branch = Git.get_current_branch()
if branch == "HEAD":
raise GitFlowError("No se encuentra parado sobre una rama.")
elif self.confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
return branch
else:
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not os.path.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 get_branch_env_and_type(self, branch: str) -> tuple[str, str]:
components = branch.split("/")
target_branches = self.flowconfig["flow.branches"].split(" ")
if len(components) == 2:
if components[0] not in BRANCH_TYPES:
raise GitFlowError(
f"Branch inválida, el tipo '{components[0]}' no es válido."
)
return (target_branches[0], components[0])
elif len(components) == 4:
target_branches = target_branches[1:]
if (
components[0] != "release"
or components[1] not in target_branches
or components[2] not in BRANCH_TYPES
):
raise GitFlowError(
"Branch release inválido, debe tener el siguiente formato: "
"release/<env>/<type>/<name>, pero es: " + branch
)
return (components[1], components[2])
else:
raise GitFlowError("Branch inválido: " + branch)
def get_remote_api(self, token: str) -> RemoteAPI:
if "flow.remote" not in self.flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.")
[host, repository] = RemoteAPI.parse(self.flowconfig["flow.remote"])
if host == "bitbucket.org":
return BitbucketRemoteAPI(repository, token)
elif host == "github.com":
return GithubRemoteAPI(repository, token)
else:
raise GitFlowError("El host del repositorio 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:
if type == TYPE_SUCCESS:
return COLOR_GREEN
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 type == TYPE_SUCCESS:
return "[success]"
elif type == TYPE_WARNING:
return "[warning]"
elif type == TYPE_ERROR:
return "[error]"
elif type == TYPE_INFO:
return "[info]"
return ""
+62
View File
@@ -0,0 +1,62 @@
from argparse import Namespace
from datetime import datetime
from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
class CommitCommand(Command):
def name(self) -> str:
return "commit"
def description(self) -> str:
return """Crea un commit siguiendo el formato de Conventional Commits"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
if not self._has_files_staged():
self.warning("No hay cambios en el indice para commitear.")
if self.confirm("¿Desea agregar la carpeta actual?"):
Git("add", ".").exec(print="Agregando cambios")
else:
raise GitFlowError(
"Debe agregar algún cambio al indice para continuar."
)
commit_type = self.choice("Tipo de commit", COMMIT_TYPES)
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
if branch.startswith("wip/"):
original_branch = branch.removeprefix("wip/")
Git("commit", m=message).exec(print="Creando commit en rama WIP")
if commit_type != "wip":
suffix = datetime.now().strftime("%Y%m%dT%H%M")
Git("switch", original_branch).exec(print="Volviendo a rama original")
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("branch", branch, "trash/" + branch + "/" + suffix, move=True).exec(print="Backup de rama WIP")
else:
if commit_type == "wip":
Git("switch", "wip/" + branch, create=True).exec(print="Creando nueva rama WIP")
Git("commit", m=message).exec(print="Creando commit")
def _has_files_staged(self):
status = Git.status()
if not status:
raise GitFlowError("No hay cambios para commitear.")
files_in_index = []
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
return len(files_in_index) > 0
+116
View File
@@ -0,0 +1,116 @@
from argparse import Namespace
import os
from git_flow import GitFlowError
from git_flow.command.base import Command
from git_flow.git import FLOWCONFIG_FILE, Git
class InitCommand(Command):
def name(self) -> str:
return "init"
def description(self) -> str:
return """Inicializa el repositorio para utilizar git-flow"""
def run(self, args: Namespace = Namespace()):
self._ensure_is_repository()
self._ensure_not_already_initialized()
branches = self._setup_flow_branches()
remote = self._setup_flow_remote()
flowconfig = {
"flow.initialized": "true",
"flow.branches": ",".join(branches),
}
if remote:
flowconfig["flow.remote"] = remote
Git.set_config(flowconfig, FLOWCONFIG_FILE)
Git("add", FLOWCONFIG_FILE).exec()
Git("commit", message="feature: initialize git-flow").exec()
self._ensure_all_flow_branches_exist(branches, remote)
def _ensure_is_repository(self):
if not Git.is_repository():
self.warning("El directorio actual no es un repositorio.")
if not self.confirm("¿Desea inicializarlo?"):
raise GitFlowError(
"No se puede continuar sin inicializar el repositorio"
)
Git("init").exec()
def _ensure_not_already_initialized(self):
if not self.flowconfig:
return
elif self.flowconfig.get("flow.initialized") == "true":
raise GitFlowError("El repositorio ya fue inicializado para usar git-flow")
else:
raise GitFlowError("Valor de 'flow.initialized' es inválido")
def _setup_flow_branches(self):
self.info(
"""Ingrese las ramas que representan los entornos de deploy del proyecto en
orden creciente de cercanía al entorno productivo, y separados por coma.
Por ejemplo: "dev,test,prod"."""
)
branches = list(
map(lambda b: b.strip(), self.prompt("Ramas", "main").split(","))
)
if not all(branches):
raise GitFlowError("No puede ingresar una rama vacia")
return branches
def _setup_flow_remote(self):
self.info(
"""Puede elegir o crear un repositorio remoto para generar automáticamente PRs"""
)
remote = None
if self.confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes:
self.info("No tiene ningún repositorio remoto, se creará uno.")
url = self.prompt(
"Ingrese la URL del repositorio (e.g. https://github.com/user/repo.git)",
persistent=True,
)
remote = "origin"
Git("remote", "add", remote, url).exec(print="Agregando remoto")
elif len(remotes) == 1:
remote = remotes[0]
else:
self.info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = self.choice("Remoto", remotes)
return remote
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str | None):
existing_branches = Git.get_branches()
if not all(map(lambda b: b in existing_branches, branches)):
self.warning("Algunas de las ramas de entornos expecificadas no existen.")
self.info("Debe indicar sobre que rama se crearán las ramas de entorno.")
target_branch = self.choice("Rama", existing_branches)
for branch in branches:
if branch not in existing_branches:
Git("branch", branch, target_branch).exec(
print="Creando rama inexistente"
)
if remote:
Git("push", remote, branch, set_upstream=True).exec(
print="Creando rama en remoto"
)
+109
View File
@@ -0,0 +1,109 @@
from argparse import Namespace
from git_flow import GitFlowError
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
class MergeCommand(Command):
def name(self) -> str:
return "merge"
def description(self) -> str:
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
(target, _) = self.get_branch_env_and_type(branch)
base = Git.get_first_fork_point(branch, target)
if "flow.remote" in self.flowconfig:
remote = self.flowconfig["flow.remote"]
token = self.ensure_repository_token()
self.run_remote(remote, token, base, branch, target)
else:
self.run_local(base, branch, target)
def run_remote(self, remote: str, token: str, base: str, branch: str, target: str):
self.check_pending_changes(True)
Git("switch", target).exec(print="Cambiando a rama destino")
Git("pull").exec(print="Obteniendo ultimos cambios del remoto")
Git("switch", "-").exec(print="Volviendo a rama a mergear")
self.check_merge_conflicts(target)
self.show_commits_to_merge(base)
if self.confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
Git("push", remote, branch, set_upstream=True).exec(
print="Subiendo rama al remoto para crear PR"
)
self.create_pull_request(token, base, branch, target)
def run_local(self, base: str, branch: str, target: str):
self.check_pending_changes(False)
self.check_merge_conflicts(target)
self.show_commits_to_merge(base)
if self.confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
Git("switch", target).exec(print="Cambiando a rama destino")
Git("merge", branch, ff=False).exec(print="Mergeando")
def check_pending_changes(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 show_commits_to_merge(self, base):
commits = Git("log", base + "..", format="%s").lines()
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a mergear:")
for commit in commits:
print("- " + commit)
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:
self.error(f"La rama actual tiene conflictos con {target}")
self.info(
f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando"""
)
raise GitFlowError("Ejecución abortada.")
else:
self.success("No se detectaron merge conflicts.")
def create_pull_request(self, token: str, base: str, branch: str, target: str):
changelog = Changelog()
message = self.get_remote_api(token).create_pull_request(
branch,
target,
branch.replace("/", ": ").replace("-", " "),
changelog.generate_content(base, branch),
)
self.success(message)
+49
View File
@@ -0,0 +1,49 @@
from argparse import Namespace
from git_flow import BRANCH_TYPES
from git_flow.command.base import Command
from git_flow.git import Git
class NewCommand(Command):
def name(self) -> str:
return "new"
def description(self) -> str:
return """Crea una nueva rama siguiendo conventional branches"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
self.ensure_right_branch()
self.info("Se creará una nueva rama de trabajo sobre la rama actual.")
self.info("Debe seleccionar el tipo de cambio a realizar.")
branch_type = self.choice("Tipos de cambio", BRANCH_TYPES)
new_branch = self._get_unique_branch_name(branch_type)
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
def _get_unique_branch_name(self, branch_type: str) -> str:
self.info(
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'."
)
branch = None
branches = Git.get_branches()
while branch is None:
keywords = self.prompt(
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True,
)
keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
branch = branch_type + "/" + "-".join(keywords)
if branch in branches:
self.error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
)
branch = None
return branch
+107
View File
@@ -0,0 +1,107 @@
from argparse import ArgumentParser, Namespace
from git_flow import (
COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR,
SEMVER_MINOR,
SEMVER_PATCH,
SEMVER_SKIP,
GitFlowError,
)
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
class TagCommand(Command):
def name(self) -> str:
return "tag"
def description(self) -> str:
return """Crea un nuevo tag para el último merge."""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
envs = self.flowconfig["flow.branches"].split(",")
target = Git.get_current_branch()
if target not in envs:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
branch = Git("show", self.get_last_merge_commit() + "^2", patch=False, format="%h").firstline()
base = Git.get_first_fork_point(branch, target)
commits = Git("log", base + ".." + branch, format="%s").lines()
next_tag = self.get_next_tag_from_commits(commits, target)
if not next_tag:
self.info(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
return
(token, ci) = self.get_token_and_ci(args)
changelog = Changelog()
changelog.update(next_tag, base, branch)
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()
if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo")
self.success(self.get_remote_api(token).create_tag(next_tag, tag_commit))
else:
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 as e:
return (args.token, True)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument("--token")
return parser
def get_last_merge_commit(self):
commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).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
for commit in commits:
commit_type = commit[: commit.index(":")]
increment = max(
increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP)
)
tag = Git.get_current_tag() or f"v0.0.0-{branch}"
until_dash = tag.index("-") if "-" in tag else None
suffix = tag[until_dash + 1 :] if until_dash is not None else ""
[major, minor, patch] = map(int, tag[1:until_dash].split("."))
if increment == SEMVER_MAJOR:
major += 1
minor = 0
patch = 0
elif increment == SEMVER_MINOR:
minor += 1
patch = 0
elif increment == SEMVER_PATCH:
patch += 1
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}"
+75 -41
View File
@@ -1,8 +1,16 @@
import subprocess import subprocess
from .io import * from git_flow.io import *
FLOWCONFIG_FILE = ".flowconfig"
CHANGELOG_FILE = "CHANGELOG.md"
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]"
class Git:
class Status: class Status:
worktree: str worktree: str
index: str index: str
@@ -13,18 +21,29 @@ class Status:
self.index = line[1] self.index = line[1]
self.file = line[3:].strip("\n") self.file = line[3:].strip("\n")
class Git:
FLOWCONFIG_FILE = ".flowconfig"
CHANGELOG_FILE = "CHANGELOG.md"
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]"
command: list[str] command: list[str]
check_returncode: bool = True
@staticmethod
def get_config(file: str | None = None):
config: dict[str, str] = {}
lines = Git("config", file=file, list=True).lines()
for line in lines:
pos = line.index("=")
key = line[:pos]
value = line[pos + 1 :]
config[key] = value
return config
@staticmethod
def set_config(config: dict[str, str], file: str | None = None):
for key, value in config:
Git("config", key, value, file=file).exec()
@staticmethod @staticmethod
def flow_config(*args, **kwargs): def flow_config(*args, **kwargs):
return Git("config", *args, file=Git.FLOWCONFIG_FILE, **kwargs) return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs)
@staticmethod @staticmethod
def get_current_branch(): def get_current_branch():
@@ -36,18 +55,22 @@ class Git:
@staticmethod @staticmethod
def status(): def status():
return list(map(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():
return Git("describe", abbrev="0", tags=True).without_checking().firstline() return Git("describe", abbrev="0", tags=True).firstline(check=False)
@staticmethod @staticmethod
def get_first_fork_point(branch: str, env: str): def get_first_fork_point(branch: str, env: str):
boundary_commits = list(filter( boundary_commits = list(
filter(
lambda c: c.startswith("-"), # boundary commits lambda c: c.startswith("-"), # boundary commits
Git("rev-list", env + "..." + branch, topo_order=True, boundary=True).lines() Git(
)) "rev-list", env + "..." + branch, topo_order=True, boundary=True
).lines(),
)
)
if not boundary_commits: if not boundary_commits:
raise RuntimeError("No se encontro un commit base para realizar el rebase.") raise RuntimeError("No se encontro un commit base para realizar el rebase.")
@@ -57,12 +80,16 @@ class Git:
@staticmethod @staticmethod
def _get_references(kind: str): def _get_references(kind: str):
prefix = "refs/" + kind + "/" prefix = "refs/" + kind + "/"
return map( return list(map(
lambda r: r.removeprefix(prefix), lambda r: r.removeprefix(prefix),
Git("for-each-ref", prefix + "*", format="%(refname)").lines(), Git("for-each-ref", prefix + "*", format="%(refname)").lines(),
) ))
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool) -> None: @staticmethod
def is_repository() -> bool:
return Git("rev-parse", is_inside_work_tree=True).code() == 0
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | None) -> None:
self.command = ["git", subcommand] self.command = ["git", subcommand]
for option, value in kwargs.items(): for option, value in kwargs.items():
@@ -74,6 +101,8 @@ class Git:
else: else:
prefix = "--" if value else "--no-" prefix = "--" if value else "--no-"
self.command.append(prefix + option) self.command.append(prefix + option)
elif value is None:
continue
else: else:
value = value if isinstance(value, str) else str(value) value = value if isinstance(value, str) else str(value)
@@ -85,44 +114,47 @@ class Git:
self.command += args self.command += args
def lines(self, strip: bool = True): def lines(self, strip: bool = True, **kwargs):
process = subprocess.run(self.command, capture_output=True, text=True) process = self._get(**kwargs)
self.__print_process(process.stdout, process.stderr)
if self.check_returncode:
process.check_returncode()
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 list(map(lambda l: l.strip(), lines))
def firstline(self): def firstline(self, **kwargs: bool):
lines = self.lines() lines = self.lines(**kwargs)
return lines[0] if lines else "" return lines[0] if lines else ""
def exec(self): def code(self, **kwargs):
return self._run(check=False, **kwargs).returncode
def exec(self, **kwargs):
self._run(**kwargs)
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)
self.__print_process(process.stdout, process.stderr) if print is not None:
self._print_process(process.stdout, process.stderr, print)
if self.check_returncode: if check:
process.check_returncode() process.check_returncode()
def code(self): return process
process = subprocess.run(self.command, capture_output=True, text=True)
self.__print_process(process.stdout, process.stderr)
return process.returncode def _run(self, print: str|None = None, check: bool = True):
process = subprocess.run(self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def without_checking(self): if print is not None:
self.check_returncode = False self._print_process("", "", title=print)
return self if check:
process.check_returncode()
def __print_process(self, stdout: str, stderr: str): return process
def _print_process(self, stdout: str, stderr: str, title: str):
command = [] command = []
for arg in self.command: for arg in self.command:
@@ -134,10 +166,12 @@ class Git:
command.append(arg) command.append(arg)
print(COLOR_YELLOW + "> " + " ".join(command) + COLOR_RESET) title = title or "Ejecutando"
print(COLOR_YELLOW + "| " + title + COLOR_RESET)
print(COLOR_YELLOW + "| $ " + " ".join(command) + COLOR_RESET)
for line in stdout.splitlines(): for line in stdout.splitlines():
print(COLOR_BLUE + "< " + line + COLOR_RESET) print(COLOR_YELLOW + "| " + COLOR_RESET + "[out] " + line)
for line in stderr.splitlines(): for line in stderr.splitlines():
print(COLOR_RED + "! " + line + COLOR_RESET) print(COLOR_YELLOW + "| " + COLOR_RED + "[err] " + line + COLOR_RESET)
+75 -213
View File
@@ -1,18 +1,63 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
from os.path import isfile
import sys import sys
import datetime import datetime
import requests import requests
import locale import locale
from argparse import Namespace, ArgumentParser
from .git import Git from git_flow import GitFlowError
from .io import * from git_flow.changelog import Changelog
from git_flow.command.base import Command
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.tag import TagCommand
from git_flow.git import BUMP_VERSION, FLOWCONFIG_FILE, Git
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):
commands: tuple[Command, ...]
def __init__(self, *args: Command) -> None:
self.commands = args
def name(self) -> str:
return "git-flow"
def description(self) -> str:
return """Git flow es un workflow automatizado siguiendo conventional commits, semver, y
deploys aislados a distintos entornos."""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
subparsers = parser.add_subparsers(title="comandos", dest="command", required=True)
for command in self.commands:
subparser = subparsers.add_parser(command.name())
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.flowconfig = Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {}
return command.run(args)
def main(): def main():
try: try:
@@ -20,6 +65,22 @@ def main():
except locale.Error as e: except locale.Error as e:
print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}") print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}")
command = GitFlowCommand(
InitCommand(),
NewCommand(),
CommitCommand(),
MergeCommand(),
TagCommand(),
)
try:
command.run()
except GitFlowError as e:
command.error(str(e))
except Exception as e:
command.error("Ocurrió un error inesperado: " + str(e))
return
args = sys.argv args = sys.argv
if len(args) < 2: if len(args) < 2:
@@ -29,15 +90,7 @@ def main():
command = args[1] command = args[1]
try: try:
if command == "new": if command == "tag":
new_command()
elif command == "init":
init_command()
elif command == "commit":
commit_command()
elif command == "merge":
merge_command()
elif command == "tag":
tag_command() tag_command()
elif command == "release": elif command == "release":
release_command() release_command()
@@ -89,198 +142,6 @@ COMMIT_TYPES = [
DEFAULT_REMOTE = "origin" DEFAULT_REMOTE = "origin"
def new_command():
current_branch = ensure_right_branch()
print_info("Se creará una nueva rama de trabajo sobre la rama actual.")
print_info("Seleccione el tipo de cambio a realizar.")
branch_type = choose("Tipos de cambio", BRANCH_TYPES)
branch = get_unique_branch_name(branch_type)
if confirm(f"Crear rama '{branch}' sobre rama '{current_branch}'"):
Git("switch", branch, create=True).exec()
else:
raise RuntimeError("Abortando operación")
def init_command():
if not Git("rev-parse", is_inside_work_tree=True).code() == 0:
print_warning("El directorio actual no es un repositorio.")
if not confirm("¿Desea inicializarlo?"):
raise RuntimeError("Abortando operación")
Git("init").exec()
initialized = Git.flow_config("flow.initialized").without_checking().firstline()
if initialized == "true":
print_error("El repositorio ya fue inicializado")
return
elif initialized != "":
print_error("El valor de 'flow.initialized' ({initialized}) es inválido")
return
print_info("Ingrese las ramas principales del repositorio separadas por espacio.")
branches = input("Ramas [dev main]: ").strip()
branches = branches.split(" ") if len(branches) > 0 else ["dev", "main"]
base = None
if confirm("¿Desea configurar un repositorio remoto?"):
remote = DEFAULT_REMOTE
remotes = Git("remote").lines()
if len(remotes) == 0:
print("El repositorio no tiene ningún remoto.")
url = None
while url is None:
url = input("Ingrese la URL del repositorio (https://github.com/user/repo.git): ").strip()
if len(url) == 0:
print_error("Debe ingresar una URL")
url = None
elif len(remotes) == 1:
remote = remotes[0]
else:
print("Tiene más de un remoto, seleccione el que desea utilizar.")
remote = choose("Remotos: ", remotes)
Git.flow_config("flow.remote", remote).exec()
Git.flow_config("flow.branches", " ".join(branches)).exec()
Git.flow_config("flow.initialized", "true").exec()
Git("add", Git.FLOWCONFIG_FILE).exec()
Git("commit", m="feature: initialize git-flow").exec()
existing_branches = Git.get_branches()
while base is None:
default = Git("rev-parse", "HEAD", abbrev_ref=True).firstline()
base = input(f"Ingrese la rama sobre la que desea crear las ramas principales [{default}]: ")
if not base:
base = default
elif base not in existing_branches:
print_error("Debe ingresar una rama existente.")
base = None
if confirm(f"¿Crear las ramas ingresadas sobre '{base}'?"):
for branch in branches:
if branch in existing_branches:
continue
if base == "HEAD":
Git("branch", branch).exec()
else:
Git("branch", branch, base).exec()
def commit_command():
ensure_initialized()
branch = ensure_right_branch()
status = Git.status()
if not status:
print_error("No hay cambios para commitear.")
return
files_in_index = []
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
if not files_in_index:
print_warning("No hay cambios en el indice para commitear.")
if confirm("¿Desea agregar la carpeta actual (`git add .`)?"):
Git("add", ".").exec()
else:
print_error("Debe agregar algún cambio al indice para continuar.")
return
commit_type = choose("Tipo de commit", COMMIT_TYPES)
message = commit_type + ": " + get_commit_message()
if branch.startswith("wip/"):
original_branch = branch.removeprefix("wip/")
Git("commit", m=message).exec()
if commit_type != "wip":
now = datetime.datetime.now()
suffix = now.strftime("%Y-%m-%d_%H.%M.%S")
Git("switch", original_branch).exec()
Git("merge", branch, squash=True).exec()
Git("commit", m=message).exec()
Git("branch", branch, branch + "/" + suffix, move=True).exec()
else:
if commit_type == "wip":
Git("switch", "wip/" + branch, create=True).exec()
Git("commit", m=message).exec()
def merge_command():
ensure_initialized()
branch = ensure_right_branch()
if branch.startswith("wip/"):
print_error("No se pueden mergear ramas de tipo 'wip'")
return
(target, _) = get_branch_env_and_type(branch)
status = Git.status()
remote = Git.flow_config("flow.remote").firstline()
if status:
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
print_warning("Existen cambios en tu entorno de trabajo sin commitear.")
if not confirm("¿Desea continuar?", False):
return
if remote:
Git("switch", target).exec()
Git("pull").exec()
Git("switch", "-").exec()
commits = Git("log", target + "..", format="%s").lines()
if not commits:
print_error("No hay cambios a mergear.")
return
print("Cambios a mergear:")
for commit in commits:
if commit != Git.BUMP_VERSION:
print("- " + commit)
merge_conflicts = Git("merge", target, ff=False, commit=False).exec()
if merge_conflicts:
Git("merge", abort=True).exec()
print_error(f"La rama actual tiene conflictos con '{target}'. "
"Se recomienda mergear la rama a la actual, resolver los conflictos, "
"y ejecutar nuevamente este comando.")
return
if remote:
Git("push", remote, branch, set_upstream=True).exec()
url = Git("remote", "get-url", remote).firstline()
if url is not None:
[service, repository] = parse_url(url)
create_pull_request(service, repository, branch, target)
else:
if confirm(f"Mergear rama '{target}' <= '{branch}'?"):
Git("switch", target).exec()
Git("merge", branch, ff=False).exec()
def parse_url(url: str) -> list[str]: def parse_url(url: str) -> list[str]:
if "@" in url: if "@" in url:
@@ -357,7 +218,7 @@ def tag_command():
message = output[pos+1:] message = output[pos+1:]
merged_branch = None merged_branch = None
output = Git("describe", commit, tags=True, exact_match=True).without_checking().firstline() output = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if output: if output:
print_error("El ultimo merge ya tiene tag: " + output) print_error("El ultimo merge ya tiene tag: " + output)
@@ -404,9 +265,10 @@ def tag_command():
[service, repository] = parse_url(url) [service, repository] = parse_url(url)
create_tag(service, repository, token, new_version, commit) create_tag(service, repository, token, new_version, commit)
if update_changelog(merged_branch, branch): merged_commit = Git("show", "HEAD^2", patch=False, format="%H").firstline()
Git("add", Git.CHANGELOG_FILE).exec() if update_changelog(merged_commit, branch):
Git("commit", message=Git.BUMP_VERSION).exec() Git("add", Changelog.FILENAME).exec()
Git("commit", message=BUMP_VERSION).exec()
Git("push").exec() Git("push").exec()
else: else:
Git("tag", new_version, commit).exec() Git("tag", new_version, commit).exec()
@@ -523,16 +385,16 @@ def get_unique_branch_name(branch_type):
def update_changelog(branch: str, target: str): def update_changelog(branch: str, target: str):
last_commit = Git("show", "HEAD", no_patch=True, format="%s").firstline() last_commit = Git("show", "HEAD", no_patch=True, format="%s").firstline()
if last_commit == Git.BUMP_VERSION: if last_commit == BUMP_VERSION:
return False return False
else: else:
content = generate_changelog_entry(branch, target) content = generate_changelog_entry(branch, target)
if os.path.isfile(Git.CHANGELOG_FILE): if os.path.isfile(Changelog.FILENAME):
with open(Git.CHANGELOG_FILE, "r") as changelog: with open(Changelog.FILENAME, "r") as changelog:
content = content + changelog.read() content = content + changelog.read()
with open(Git.CHANGELOG_FILE, "w") as changelog: with open(Changelog.FILENAME, "w") as changelog:
changelog.write(content) changelog.write(content)
@@ -568,14 +430,14 @@ def get_changelog_content_lines(branch: str, target: str):
email = Git("show", commit, patch=False, format="%ae").firstline() email = Git("show", commit, patch=False, format="%ae").firstline()
username = email[:email.index('@')] username = email[:email.index('@')]
if message == Git.BUMP_VERSION: if message == BUMP_VERSION:
continue continue
index = message.index(":") index = message.index(":")
commit_type = message[:index] commit_type = message[:index]
commit_message = message[index+1:] commit_message = message[index+1:]
lines.append(f"- **{commit_type}**: {commit_message} por [{username}]({email}) ({commit})") lines.append(f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})")
return lines return lines
+75
View File
@@ -0,0 +1,75 @@
from abc import ABC, abstractmethod
from git_flow import SUPPORTED_REMOTE_APIS, GitFlowError
from git_flow.git import Git
class RemoteAPI(ABC):
def __init__(self, repository: str, token: str) -> None:
self.repository = repository
self.token = token
@staticmethod
def parse(remote: str):
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@"):
url_type = "ssh"
elif url.startswith("http://") or url.startswith("https://"):
url_type = "https"
else:
raise invalid_url_error
if url_type == "ssh":
# git@host:user/repo[.git]
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 host.endswith(".bitbucket.org"):
host = "bitbucket.org"
elif host.endswith(".github.com"):
host = "github.com"
if host not in SUPPORTED_REMOTE_APIS:
raise GitFlowError("El host del repositorio remoto es inválido")
repository.removesuffix(".git")
return [host, repository]
@abstractmethod
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
) -> str:
pass
@abstractmethod
def create_tag(self, tag: str, commit: str) -> str:
pass
@abstractmethod
def get_endpoint(self, resource: str) -> str:
pass
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + self.token,
}
+47
View File
@@ -0,0 +1,47 @@
import requests
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class BitbucketRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://api.bitbucket.org/2.0/repositories"
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
):
request = requests.post(
self.get_endpoint("/pullrequests"),
headers=self.get_headers(),
json={
"title": title,
"description": description,
"source": {"branch": {"name": source}},
"destination": {"branch": {"name": destination}},
"close_source_branch": True,
},
)
if request.ok:
json = request.json()
return "PR creado exitosamente: " + json["links"]["html"]["href"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + request.text)
def create_tag(self, tag: str, commit: str):
response = requests.post(
self.get_endpoint("/refs/tags"),
headers=self.get_headers(),
json={"name": tag, "target": {"hash": commit}},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_endpoint(self, resource: str) -> str:
return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}"
+21
View File
@@ -0,0 +1,21 @@
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class GithubRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://github.com"
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
) -> str:
raise GitFlowError("not implemented yet!")
def create_tag(self, tag: str, commit: str) -> str:
raise GitFlowError("not implemented yet!")
def get_endpoint(self, resource: str) -> str:
return GithubRemoteAPI.API_ENDPOINT