Suport de repos privats: token de Gitea global configurable des del menú

This commit is contained in:
2026-05-29 22:30:11 +02:00
parent bfa01f31e3
commit 667eade660
4 changed files with 79 additions and 25 deletions
+46 -19
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import datetime as _dt
import json
import os
import shutil
import subprocess
import urllib.error
@@ -28,20 +29,39 @@ def _noop(_: str) -> None:
pass
def _run_git(args: list[str], cwd: Path | None, log: LogFn) -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla."""
cmd = ["git", *args]
log("$ " + " ".join(cmd))
def _auth_args(token: str) -> list[str]:
"""Args -c para autenticar git ante Gitea con un token, sin tocar .git/config."""
if not token:
return []
return ["-c", f"http.extraHeader=Authorization: token {token}"]
def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla.
Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch
de repos privados) y la redacta en el log para no filtrarla.
"""
cmd = ["git", *_auth_args(token), *args]
def emit(line: str) -> None:
log(line.replace(token, "***") if token else line)
# Echo del comando: nunca mostramos los args -c con el token.
emit("$ " + " ".join(["git", *args]))
# GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido
# en vez de colgarse esperando credenciales (no hay terminal en la GUI).
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
if proc.stdout:
log(proc.stdout.rstrip())
emit(proc.stdout.rstrip())
if proc.stderr:
log(proc.stderr.rstrip())
emit(proc.stderr.rstrip())
if proc.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} falló (código {proc.returncode})")
return proc.stdout.strip()
@@ -52,7 +72,7 @@ def is_installed(root: Path, game: Game) -> bool:
return (repo_dir(root, game.id) / ".git").exists()
def check_update(root: Path, game: Game, log: LogFn = _noop) -> bool:
def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> bool:
"""Hace fetch y devuelve True si el clone local está por detrás del remoto.
No modifica el árbol de trabajo: solo cuenta los commits de origin/<rama> que no
@@ -61,7 +81,7 @@ def check_update(root: Path, game: Game, log: LogFn = _noop) -> bool:
repo = repo_dir(root, game.id)
if not (repo / ".git").exists():
return False
_run_git(["fetch", "origin", "--prune"], repo, log)
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
target = (
load_meta(root, game.id).default_branch
or _detect_origin_head(repo, log)
@@ -86,7 +106,7 @@ def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
shutil.rmtree(target, ignore_errors=True)
def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta:
def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> GameMeta:
"""Clona (si no existe) o trae el remoto forzado (descartando cambios locales).
Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve.
@@ -94,11 +114,11 @@ def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta:
repo = repo_dir(root, game.id)
repo.parent.mkdir(parents=True, exist_ok=True)
branch = _fetch_default_branch(game, log)
branch = _fetch_default_branch(game, log, token)
if (repo / ".git").exists():
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
_run_git(["fetch", "origin", "--prune"], repo, log)
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
target = branch or _detect_origin_head(repo, log) or "HEAD"
_run_git(["reset", "--hard", f"origin/{target}"], repo, log)
_run_git(["clean", "-fd"], repo, log)
@@ -106,20 +126,24 @@ def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta:
log(f"Clonant {game.name}")
if repo.exists(): # carpeta a medias sin .git: limpiarla
shutil.rmtree(repo, ignore_errors=True)
_run_git(["clone", game.clone_url, str(repo)], None, log)
_run_git(["clone", game.clone_url, str(repo)], None, log, token=token)
return refresh_metadata(root, game, branch, log)
return refresh_metadata(root, game, branch, log, token)
def refresh_metadata(
root: Path, game: Game, branch: str | None = None, log: LogFn = _noop
root: Path,
game: Game,
branch: str | None = None,
log: LogFn = _noop,
token: str = "",
) -> GameMeta:
"""Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea."""
repo = repo_dir(root, game.id)
meta = load_meta(root, game.id)
# Descripción + rama por defecto desde la API de Gitea (best-effort).
api = _fetch_gitea_info(game, log)
api = _fetch_gitea_info(game, log, token)
if api is not None:
meta.description = api.get("description", meta.description) or meta.description
meta.default_branch = api.get("default_branch", meta.default_branch)
@@ -170,11 +194,14 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str:
return out.splitlines()[0] if out else ""
def _fetch_gitea_info(game: Game, log: LogFn) -> dict | None:
def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
if not game.info_url:
return None
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"token {token}"
try:
req = urllib.request.Request(game.info_url, headers={"Accept": "application/json"})
req = urllib.request.Request(game.info_url, headers=headers)
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc:
@@ -182,8 +209,8 @@ def _fetch_gitea_info(game: Game, log: LogFn) -> dict | None:
return None
def _fetch_default_branch(game: Game, log: LogFn) -> str | None:
info = _fetch_gitea_info(game, log)
def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None:
info = _fetch_gitea_info(game, log, token)
if info:
return info.get("default_branch")
return None