diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index 3516705..b0ff9b9 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -9,6 +9,5 @@ pipelines: - pip script: - cd $BITBUCKET_CLONE_DIR - - pip install --upgrade pip - pip install . - - git-flow tag $BEARER + - git-flow tag --token=$BEARER diff --git a/src/git_flow/__init__.py b/src/git_flow/__init__.py index e69de29..c4fbdf6 100644 --- a/src/git_flow/__init__.py +++ b/src/git_flow/__init__.py @@ -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" diff --git a/src/git_flow/changelog.py b/src/git_flow/changelog.py new file mode 100644 index 0000000..b1cbba6 --- /dev/null +++ b/src/git_flow/changelog.py @@ -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, "rw") as f: + content += f.read() + f.seek(0) + f.write(content) diff --git a/src/git_flow/command/__init__.py b/src/git_flow/command/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/git_flow/command/base.py b/src/git_flow/command/base.py new file mode 100644 index 0000000..cf58f83 --- /dev/null +++ b/src/git_flow/command/base.py @@ -0,0 +1,224 @@ +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): + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def description(self) -> str: + pass + + @abstractmethod + def run(self, args: Namespace = Namespace()): + pass + + def __init__(self) -> None: + 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): + 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///, 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 "" diff --git a/src/git_flow/command/commit.py b/src/git_flow/command/commit.py new file mode 100644 index 0000000..55aebc1 --- /dev/null +++ b/src/git_flow/command/commit.py @@ -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 .`)?"): + Git("add", ".").exec() + 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() + + if commit_type != "wip": + suffix = datetime.now().strftime("%Y%m%dT%H%M") + Git("switch", original_branch).exec() + Git("merge", branch, squash=True).exec() + Git("commit", m=message).exec() + Git("branch", branch, "trash/" + branch + "/" + suffix, move=True).exec() + else: + if commit_type == "wip": + Git("switch", "wip/" + branch, create=True).exec() + + Git("commit", m=message).exec() + + 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 diff --git a/src/git_flow/command/init.py b/src/git_flow/command/init.py new file mode 100644 index 0000000..2a425dd --- /dev/null +++ b/src/git_flow/command/init.py @@ -0,0 +1,115 @@ +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 os.path.isfile(FLOWCONFIG_FILE): + flowconfig = Git.get_config(FLOWCONFIG_FILE) + + if flowconfig["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() + + 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() + 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() + + if remote: + Git("push", remote, branch, set_upstream=True).exec() diff --git a/src/git_flow/command/merge.py b/src/git_flow/command/merge.py new file mode 100644 index 0000000..70d8f79 --- /dev/null +++ b/src/git_flow/command/merge.py @@ -0,0 +1,103 @@ +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() + Git("pull").exec() + Git("switch", "-").exec() + + self.check_merge_conflicts(target) + self.show_commits_to_merge(base) + + Git("push", remote, branch, set_upstream=True).exec() + + 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 '{target}' <= '{branch}'?"): + Git("switch", target).exec() + Git("merge", branch, ff=False).exec() + + 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() != 0 + + if merge_conflicts: + Git("merge", abort=True).exec() + 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) diff --git a/src/git_flow/command/new.py b/src/git_flow/command/new.py new file mode 100644 index 0000000..e5df5e9 --- /dev/null +++ b/src/git_flow/command/new.py @@ -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() + + 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 diff --git a/src/git_flow/command/tag.py b/src/git_flow/command/tag.py new file mode 100644 index 0000000..53b4fdf --- /dev/null +++ b/src/git_flow/command/tag.py @@ -0,0 +1,115 @@ +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.") + + merge_commit = self.get_last_merge_commit() + + self.check_not_tagged(merge_commit) + + branch = Git("show", 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) + + if ci: + message = self.get_remote_api(token).create_tag(next_tag, merge_commit) + changelog = Changelog() + + self.success(message) + changelog.update(next_tag, base, branch) + Git("add", Changelog.FILENAME).exec() + Git("commit", message=Changelog.COMMIT_MESSAGE).exec() + Git("push").exec() + else: + Git("tag", next_tag, merge_commit).exec() + + 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) + .without_checking() + .firstline() + ) + + if tag: + raise GitFlowError("El ultimo merge 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}" diff --git a/src/git_flow/git.py b/src/git_flow/git.py index f9324bb..3408378 100644 --- a/src/git_flow/git.py +++ b/src/git_flow/git.py @@ -1,30 +1,50 @@ import subprocess -from .io import * +from git_flow.io import * -class Status: - worktree: str - index: str - file: str +FLOWCONFIG_FILE = ".flowconfig" - def __init__(self, line: str) -> None: - self.worktree = line[0] - self.index = line[1] - self.file = line[3:].strip("\n") +CHANGELOG_FILE = "CHANGELOG.md" + +BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]" class Git: - FLOWCONFIG_FILE = ".flowconfig" - CHANGELOG_FILE = "CHANGELOG.md" - BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]" + class Status: + worktree: str + index: str + file: str + + def __init__(self, line: str) -> None: + self.worktree = line[0] + self.index = line[1] + self.file = line[3:].strip("\n") 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 def flow_config(*args, **kwargs): - return Git("config", *args, file=Git.FLOWCONFIG_FILE, **kwargs) + return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs) @staticmethod def get_current_branch(): @@ -36,7 +56,7 @@ class Git: @staticmethod def status(): - return list(map(Status, Git("status", porcelain=True).lines(False))) + return list(map(Git.Status, Git("status", porcelain=True).lines(False))) @staticmethod def get_current_tag(): @@ -44,10 +64,14 @@ class Git: @staticmethod def get_first_fork_point(branch: str, env: str): - boundary_commits = list(filter( - lambda c: c.startswith("-"), # boundary commits - Git("rev-list", env + "..." + branch, topo_order=True, boundary=True).lines() - )) + boundary_commits = list( + filter( + lambda c: c.startswith("-"), # boundary commits + Git( + "rev-list", env + "..." + branch, topo_order=True, boundary=True + ).lines(), + ) + ) if not boundary_commits: raise RuntimeError("No se encontro un commit base para realizar el rebase.") @@ -57,12 +81,16 @@ class Git: @staticmethod def _get_references(kind: str): prefix = "refs/" + kind + "/" - return map( + return list(map( lambda r: r.removeprefix(prefix), 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] for option, value in kwargs.items(): @@ -74,6 +102,8 @@ class Git: else: prefix = "--" if value else "--no-" self.command.append(prefix + option) + elif value is None: + continue else: value = value if isinstance(value, str) else str(value) diff --git a/src/git_flow/main.py b/src/git_flow/main.py index effc213..b7c89b8 100755 --- a/src/git_flow/main.py +++ b/src/git_flow/main.py @@ -5,14 +5,57 @@ import sys import datetime import requests import locale +from argparse import Namespace, ArgumentParser -from .git import Git -from .io import * +from git_flow import GitFlowError +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, Git +from git_flow.io import * REPOSITORY_TOKEN_PATH=".repository-token" 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: + return command.run(args) + def main(): try: @@ -20,6 +63,22 @@ def main(): except locale.Error as 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 if len(args) < 2: @@ -29,15 +88,7 @@ def main(): command = args[1] try: - if command == "new": - new_command() - elif command == "init": - init_command() - elif command == "commit": - commit_command() - elif command == "merge": - merge_command() - elif command == "tag": + if command == "tag": tag_command() elif command == "release": release_command() @@ -89,198 +140,6 @@ COMMIT_TYPES = [ 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]: if "@" in url: @@ -406,8 +265,8 @@ def tag_command(): merged_commit = Git("show", "HEAD^2", patch=False, format="%H").firstline() if update_changelog(merged_commit, branch): - Git("add", Git.CHANGELOG_FILE).exec() - Git("commit", message=Git.BUMP_VERSION).exec() + Git("add", Changelog.FILENAME).exec() + Git("commit", message=BUMP_VERSION).exec() Git("push").exec() else: Git("tag", new_version, commit).exec() @@ -524,16 +383,16 @@ def get_unique_branch_name(branch_type): def update_changelog(branch: str, target: str): last_commit = Git("show", "HEAD", no_patch=True, format="%s").firstline() - if last_commit == Git.BUMP_VERSION: + if last_commit == BUMP_VERSION: return False else: content = generate_changelog_entry(branch, target) - if os.path.isfile(Git.CHANGELOG_FILE): - with open(Git.CHANGELOG_FILE, "r") as changelog: + if os.path.isfile(Changelog.FILENAME): + with open(Changelog.FILENAME, "r") as changelog: content = content + changelog.read() - with open(Git.CHANGELOG_FILE, "w") as changelog: + with open(Changelog.FILENAME, "w") as changelog: changelog.write(content) @@ -569,7 +428,7 @@ def get_changelog_content_lines(branch: str, target: str): email = Git("show", commit, patch=False, format="%ae").firstline() username = email[:email.index('@')] - if message == Git.BUMP_VERSION: + if message == BUMP_VERSION: continue index = message.index(":") diff --git a/src/git_flow/remote/base.py b/src/git_flow/remote/base.py new file mode 100644 index 0000000..47edc89 --- /dev/null +++ b/src/git_flow/remote/base.py @@ -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("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, + } diff --git a/src/git_flow/remote/bitbucket.py b/src/git_flow/remote/bitbucket.py new file mode 100644 index 0000000..8874c10 --- /dev/null +++ b/src/git_flow/remote/bitbucket.py @@ -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}" diff --git a/src/git_flow/remote/github.py b/src/git_flow/remote/github.py new file mode 100644 index 0000000..f46a5cc --- /dev/null +++ b/src/git_flow/remote/github.py @@ -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