301 lines
9.1 KiB
Python
301 lines
9.1 KiB
Python
import re
|
||
import time
|
||
from os.path import isfile
|
||
|
||
import questionary
|
||
import rich
|
||
import rich.panel
|
||
from rich.table import Table
|
||
|
||
from git_flow import (
|
||
BRANCH_TYPES,
|
||
COMMIT_TYPE_INCREMENT,
|
||
COMMIT_TYPES,
|
||
FLOWCONFIG_FILENAME,
|
||
FLOWCONFIG_VERSION,
|
||
REPOSITORY_TOKEN_FILENAME,
|
||
GitFlowError,
|
||
)
|
||
from git_flow.git import Git
|
||
from git_flow.io import *
|
||
from git_flow.remote.base import RemoteAPI
|
||
from git_flow.remote.bitbucket import BitbucketRemoteAPI
|
||
from git_flow.remote.gitea import GiteaRemoteAPI
|
||
from git_flow.remote.github import GithubRemoteAPI
|
||
from git_flow.remote.gitlab import GitlabRemoteAPI
|
||
|
||
flowconfig = Git.get_config(FLOWCONFIG_FILENAME) if isfile(FLOWCONFIG_FILENAME) else {}
|
||
environments = (
|
||
flowconfig["flow.branches"].split(",") if "flow.branches" in flowconfig else []
|
||
)
|
||
|
||
|
||
def ensure_initialized():
|
||
initialized = flowconfig["flow.initialized"] if 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."
|
||
)
|
||
|
||
version = int(flowconfig.get("flow.version", "1"))
|
||
|
||
if version != FLOWCONFIG_VERSION:
|
||
raise GitFlowError(
|
||
f"La versión del flowconfig ({version}) no es compatible con esta versión de git-flow ({FLOWCONFIG_VERSION}). "
|
||
f"Debe reinicializar el repositorio."
|
||
)
|
||
|
||
|
||
def ensure_right_branch():
|
||
branch = Git.get_current_branch()
|
||
|
||
if branch == "HEAD":
|
||
raise GitFlowError("No se encuentra parado sobre una rama.")
|
||
elif io_confirm(f"Rama actual: {branch}. ¿Es correcto?"):
|
||
return branch
|
||
else:
|
||
raise GitFlowError("Ejecución cancelada.")
|
||
|
||
|
||
def get_branch_env_and_type(branch: str) -> tuple[str, str]:
|
||
components = branch.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 (environments[0], components[0])
|
||
elif len(components) == 4:
|
||
valid_environments = environments[1:]
|
||
|
||
if (
|
||
components[0] != "release"
|
||
or components[1] not in valid_environments
|
||
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 ensure_repository_token():
|
||
if not isfile(REPOSITORY_TOKEN_FILENAME):
|
||
raise GitFlowError(
|
||
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
|
||
)
|
||
|
||
with open(REPOSITORY_TOKEN_FILENAME) as f:
|
||
return f.readline().strip()
|
||
|
||
|
||
def ensure_clean_worktree(has_remote: bool):
|
||
status = Git.status()
|
||
|
||
if not status:
|
||
return
|
||
|
||
not_empty = any(s.index != " " or s.worktree != " " for s in status)
|
||
|
||
if not_empty:
|
||
if not has_remote:
|
||
io_warning("Existen cambios en tu entorno de trabajo sin commitear.")
|
||
|
||
if not io_confirm("¿Desea continuar?", False):
|
||
raise GitFlowError("Ejecución abortada")
|
||
else:
|
||
raise GitFlowError("No se puede continuar con cambios pendientes.")
|
||
|
||
|
||
def get_remote_api(token: str) -> RemoteAPI:
|
||
if "flow.remote" not in flowconfig:
|
||
raise GitFlowError("El repositorio no tiene configurado un remoto.")
|
||
|
||
remote_type = flowconfig.get("flow.remote-type", "").lower()
|
||
[schema, host, repository] = RemoteAPI.parse(flowconfig["flow.remote"])
|
||
|
||
if remote_type == "gitea":
|
||
remote_schema = "https://" if schema == "ssh://" else schema
|
||
return GiteaRemoteAPI(remote_schema + host, repository, token)
|
||
elif remote_type == "gitlab":
|
||
remote_schema = "https://" if schema == "ssh://" else schema
|
||
return GitlabRemoteAPI(remote_schema + host, repository, token)
|
||
elif remote_type == "bitbucket":
|
||
return BitbucketRemoteAPI(repository, token)
|
||
elif remote_type == "github":
|
||
return GithubRemoteAPI(repository, token)
|
||
else:
|
||
raise GitFlowError("El tipo del remoto es inválido")
|
||
|
||
|
||
def is_valid_ticket(ticket: str) -> bool:
|
||
"""Check if the specified string is a valid ticket number (ABC-123)"""
|
||
components = ticket.split("-")
|
||
|
||
if len(components) != 2:
|
||
return False
|
||
|
||
ticket_project = components[0]
|
||
ticket_number = components[1]
|
||
|
||
return (
|
||
ticket_project.isalpha()
|
||
and ticket_project.isupper()
|
||
and ticket_number.isnumeric()
|
||
)
|
||
|
||
|
||
CUSTOM_COMMIT_TYPE_OPTION = "otro"
|
||
|
||
|
||
def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
|
||
commit_type = io_choice("Tipo de commit", commit_types + [CUSTOM_COMMIT_TYPE_OPTION])
|
||
|
||
if commit_type == CUSTOM_COMMIT_TYPE_OPTION:
|
||
commit_type = io_prompt(
|
||
"Tipo de commit personalizado",
|
||
strip=True,
|
||
validator=lambda s: re.fullmatch(r"\w+", s.strip()) is not None,
|
||
instruction="letras, números o _",
|
||
)
|
||
|
||
if commit_type not in COMMIT_TYPE_INCREMENT:
|
||
io_warning(
|
||
f"El tipo '{commit_type}' no produce un salto de versión, "
|
||
"salvo que se marque como breaking change."
|
||
)
|
||
|
||
# Los commits WIP se squashean en uno final, que es el que puede marcarse como breaking
|
||
if commit_type != "wip" and io_confirm("¿Es un breaking change?", False):
|
||
commit_type += "!"
|
||
|
||
commit_message = io_prompt(
|
||
"Mensaje de commit",
|
||
validator=lambda s: 0 < len(s) and len(s) < 100,
|
||
instruction="100 caracteres máximo",
|
||
)
|
||
return commit_type + ": " + commit_message
|
||
|
||
|
||
# <tipo>[(alcance)][!]: <mensaje>, para no confundir "Merge pull request 'feature: ...'" con un tipo
|
||
COMMIT_SUBJECT_RE = re.compile(r"^(\w+)(?:\([^)]*\))?(!)?:\s*(.*)$")
|
||
|
||
|
||
def show_commits(title: str, revision_range: str):
|
||
"""Muestra en una tabla los commits del rango (fecha relativa, tipo y mensaje).
|
||
Si no hay commits aborta, ya que no hay nada para mergear."""
|
||
# fecha de autor: la de commit cambia al hacer rebase (por ejemplo, en release)
|
||
lines = Git("log", revision_range, format="%at%x09%s").lines()
|
||
|
||
if not lines:
|
||
raise GitFlowError("No hay cambios a mergear.")
|
||
|
||
table = Table(title=title, title_justify="left", title_style="bold")
|
||
table.add_column("Fecha", style="dim", no_wrap=True)
|
||
table.add_column("Tipo", style="cyan", no_wrap=True)
|
||
table.add_column("Mensaje")
|
||
|
||
now = time.time()
|
||
|
||
for line in lines:
|
||
timestamp, subject = line.split("\t", 1)
|
||
match = COMMIT_SUBJECT_RE.match(subject)
|
||
commit_type, breaking, message = match.groups() if match else ("-", None, subject)
|
||
commit_type += breaking or ""
|
||
table.add_row(format_time_ago(now - int(timestamp)), commit_type, message)
|
||
|
||
console.print(table)
|
||
|
||
|
||
def format_time_ago(seconds: float) -> str:
|
||
units = [
|
||
(365 * 24 * 3600, "año", "años"),
|
||
(30 * 24 * 3600, "mes", "meses"),
|
||
(7 * 24 * 3600, "semana", "semanas"),
|
||
(24 * 3600, "día", "días"),
|
||
(3600, "hora", "horas"),
|
||
(60, "minuto", "minutos"),
|
||
]
|
||
|
||
for size, singular, plural in units:
|
||
amount = int(seconds // size)
|
||
|
||
if amount >= 1:
|
||
return f"hace {amount} {singular if amount == 1 else plural}"
|
||
|
||
return "hace un momento"
|
||
|
||
|
||
def io_error(message: str, title: str | None = None):
|
||
_io_status(message, "✖ Error: ", "red", title)
|
||
|
||
|
||
def io_warning(message: str, title: str | None = None):
|
||
_io_status(message, "⚠ Warning: ", "yellow", title)
|
||
|
||
|
||
def io_info(message: str, title: str | None = None):
|
||
_io_status(message, "ℹ Info: ", "blue", title)
|
||
|
||
|
||
def io_success(message: str, title: str | None = None):
|
||
_io_status(message, "✔ Success: ", "green", title)
|
||
|
||
|
||
def _io_status(message: str, prefix: str, style: str, title: str | None = None):
|
||
if title:
|
||
rich.print(
|
||
rich.panel.Panel(
|
||
message,
|
||
title=prefix + title,
|
||
style=style,
|
||
expand=False,
|
||
title_align="left",
|
||
)
|
||
)
|
||
else:
|
||
rich.print(f"[{style}]{prefix}{message}[/{style}]")
|
||
|
||
|
||
def io_confirm(question: str, default: bool = True) -> bool:
|
||
return questionary.confirm(question, default=default, auto_enter=False).unsafe_ask()
|
||
|
||
|
||
def io_choice(prompt: str, options: list[str]) -> str:
|
||
return questionary.select(
|
||
prompt,
|
||
options,
|
||
use_search_filter=True,
|
||
use_jk_keys=False,
|
||
instruction="Tipear o usar flechas",
|
||
).unsafe_ask()
|
||
|
||
|
||
def io_prompt(
|
||
message: str,
|
||
default: str = "",
|
||
persistent: bool = False,
|
||
strip: bool = False,
|
||
validator=None,
|
||
instruction: str | None = None,
|
||
) -> str:
|
||
if persistent and not validator:
|
||
validator = lambda s: len(s.strip()) > 0
|
||
|
||
value = questionary.text(
|
||
message, default, validate=validator, instruction=instruction
|
||
).unsafe_ask()
|
||
|
||
return value.strip() if strip else value
|