194 lines
7.5 KiB
Python
194 lines
7.5 KiB
Python
# /// script
|
|
# requires-python = ">=3.12"
|
|
# dependencies = ["copier>=9.10", "questionary>=2.1"]
|
|
# ///
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import questionary
|
|
from copier import run_copy, run_update
|
|
|
|
SOURCE = os.environ.get("TEMPLATES_DIR", "https://git.kotikot.com/templates")
|
|
REF = os.environ.get("TEMPLATES_REF")
|
|
BACKENDS = {"python": "backend-python"}
|
|
FRONTENDS = {"svelte": "frontend-svelte"}
|
|
SERVE = ["bun", "static", "api"]
|
|
|
|
|
|
def template(name: str) -> str:
|
|
local = Path(SOURCE) / name
|
|
return str(local) if local.is_dir() else f"{SOURCE}/{name}.git"
|
|
|
|
|
|
def git_config(key: str) -> str:
|
|
result = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def interactive() -> bool:
|
|
return sys.stdin.isatty()
|
|
|
|
|
|
def ask_text(prompt: str, default: str) -> str:
|
|
return questionary.text(prompt, default=default).unsafe_ask() if interactive() else default
|
|
|
|
|
|
def ask_select(prompt: str, choices: list[str], default: str) -> str:
|
|
if not interactive():
|
|
return default
|
|
return questionary.select(prompt, choices=choices, default=default).unsafe_ask()
|
|
|
|
|
|
def ask_checkbox(prompt: str, choices: list[str], default: list[str]) -> list[str]:
|
|
if not interactive():
|
|
return default
|
|
items = [questionary.Choice(c, checked=c in default) for c in choices]
|
|
return questionary.checkbox(prompt, choices=items).unsafe_ask()
|
|
|
|
|
|
def slugify(name: str) -> str:
|
|
return re.sub(r"[^a-z0-9]", "_", name.lower())
|
|
|
|
|
|
def new(args: argparse.Namespace) -> None:
|
|
dest = Path(args.dest).resolve()
|
|
name = args.name or ask_text("Project name", dest.name)
|
|
slug = args.slug or ask_text("Slug", slugify(name))
|
|
parts = args.parts.split(",") if args.parts else ask_checkbox("Parts", ["backend", "frontend", "caddy"], ["backend", "frontend", "caddy"])
|
|
backend = frontend = None
|
|
if "backend" in parts:
|
|
backend = args.backend or ask_select("Backend", list(BACKENDS), "python")
|
|
if "frontend" in parts:
|
|
frontend = args.frontend or ask_select("Frontend", list(FRONTENDS), "svelte")
|
|
serve = "bun"
|
|
if frontend:
|
|
options = SERVE if backend else SERVE[:-1]
|
|
serve = args.serve or ask_select("Frontend served by", options, "bun")
|
|
stack = [p for p in ("backend", "frontend") if p in parts]
|
|
single = len(stack) == 1
|
|
routes = [r for r, ok in (("api", backend), ("frontend", frontend and serve != "api")) if ok]
|
|
|
|
def copy(name: str, data: dict) -> None:
|
|
run_copy(template(name), dest, data=data, unsafe=True, defaults=args.defaults, vcs_ref=REF)
|
|
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
identity = {"project_name": name, "project_slug": slug}
|
|
copy("infra", {"project_name": name, "layout": "single" if single else "parts", "parts": [] if single else stack})
|
|
layout = "root" if single else "part"
|
|
if backend:
|
|
copy(BACKENDS[backend], {**identity, "layout": layout, "serve_spa": serve == "api",
|
|
"author_name": git_config("user.name"), "author_email": git_config("user.email")})
|
|
if frontend:
|
|
copy(FRONTENDS[frontend], {**identity, "layout": layout, "serve": serve})
|
|
if "caddy" in parts:
|
|
copy("caddy", {"project_slug": slug, "routes": routes})
|
|
if (dest / "Makefile").is_file():
|
|
subprocess.run(["make", "env"], cwd=dest, check=True)
|
|
|
|
|
|
def git_dirty() -> bool:
|
|
result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=False)
|
|
return bool(result.stdout.strip())
|
|
|
|
|
|
def update(args: argparse.Namespace) -> None:
|
|
answers = sorted(Path(".copier").glob("*.yml"))
|
|
if args.parts:
|
|
answers = [Path(".copier") / f"{p}.yml" for p in args.parts]
|
|
if not answers:
|
|
sys.exit("no .copier/*.yml here")
|
|
if git_dirty():
|
|
sys.exit("working tree is dirty, commit or stash first")
|
|
for file in answers:
|
|
run_update(".", answers_file=str(file), unsafe=True, overwrite=True,
|
|
skip_answered=not args.ask, defaults=args.defaults, vcs_ref=REF)
|
|
if git_dirty():
|
|
subprocess.run(["git", "add", "-A"], check=True)
|
|
subprocess.run(["git", "commit", "-qm", f"Update {file.stem} template"], check=True)
|
|
print(f"committed {file.stem} update")
|
|
|
|
|
|
GITEA = os.environ.get("GITEA_URL", "https://git.kotikot.com").rstrip("/")
|
|
RENOVATE_LABELS = [
|
|
("renovate", "1f6feb", "Pull request from the Renovate bot"),
|
|
("wait-3d", "d29922", "Waits three days: the registry has no release timestamp, the age gate merges it"),
|
|
("deps-code", "7d8590", "Code dependencies: pull requests are collected, nothing merges itself"),
|
|
]
|
|
|
|
|
|
def gitea_token() -> str:
|
|
token = os.environ.get("GITEA_TOKEN", "")
|
|
if token:
|
|
return token
|
|
path = Path.home() / ".config/gitea/token"
|
|
if path.exists():
|
|
return path.read_text(encoding="utf-8").strip()
|
|
sys.exit("no Gitea token: set GITEA_TOKEN or write ~/.config/gitea/token")
|
|
|
|
|
|
def gitea(args: argparse.Namespace) -> None:
|
|
"""Prepare a Gitea repo for Renovate: the labels it fails without."""
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
token = gitea_token()
|
|
for name, color, description in RENOVATE_LABELS:
|
|
body = json.dumps({"name": name, "color": color, "description": description}).encode()
|
|
request = urllib.request.Request(
|
|
f"{GITEA}/api/v1/repos/{args.repo}/labels",
|
|
data=body,
|
|
method="POST",
|
|
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
urllib.request.urlopen(request, timeout=30)
|
|
print(f"label {name}: created")
|
|
except urllib.error.HTTPError as error:
|
|
if error.code in (409, 422):
|
|
print(f"label {name}: already there")
|
|
else:
|
|
sys.exit(f"label {name}: {error.code} {error.read()[:200]!r}")
|
|
|
|
print(
|
|
"\nLabels done. Two things are still manual, and neither happens by itself:\n"
|
|
f" 1. renovate.jsonc in {args.repo} with"
|
|
' {"extends": ["local>templates/renovate"]}\n'
|
|
" 2. the repo added to `repositories` in renovate-config.js of infra/komodo,\n"
|
|
" plus the bot given write access to it. Extending the preset subscribes\n"
|
|
" nothing: the bot only visits repos named in that list."
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(prog="templates")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
n = sub.add_parser("new", help="Compose a project from templates")
|
|
n.add_argument("dest")
|
|
n.add_argument("--name")
|
|
n.add_argument("--slug")
|
|
n.add_argument("--parts", help="comma-separated: backend,frontend,caddy")
|
|
n.add_argument("--backend", choices=list(BACKENDS))
|
|
n.add_argument("--frontend", choices=list(FRONTENDS))
|
|
n.add_argument("--serve", choices=SERVE)
|
|
n.add_argument("--defaults", action="store_true", help="accept template defaults without asking")
|
|
n.set_defaults(func=new)
|
|
u = sub.add_parser("update", help="Update every part from its template")
|
|
u.add_argument("parts", nargs="*", help="parts to update, default all")
|
|
u.add_argument("--ask", action="store_true", help="ask every question again")
|
|
u.add_argument("--defaults", action="store_true")
|
|
u.set_defaults(func=update)
|
|
g = sub.add_parser("gitea", help="Create the Renovate labels a repo needs")
|
|
g.add_argument("repo", help="org/name on Gitea")
|
|
g.set_defaults(func=gitea)
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|