|
|
|
@@ -12,7 +12,8 @@ from pathlib import Path
|
|
|
|
|
import questionary
|
|
|
|
|
from copier import run_copy, run_update
|
|
|
|
|
|
|
|
|
|
SOURCE = os.environ.get("TEMPLATES_DIR", "https://git.kotikot.com/templates")
|
|
|
|
|
PUBLISHED = "https://git.kotikot.com/templates"
|
|
|
|
|
SOURCE = os.environ.get("TEMPLATES_DIR", PUBLISHED)
|
|
|
|
|
REF = os.environ.get("TEMPLATES_REF")
|
|
|
|
|
BLANK = "blank"
|
|
|
|
|
BACKENDS = {"python": "backend-python", "other": BLANK}
|
|
|
|
@@ -26,6 +27,26 @@ def template(name: str) -> str:
|
|
|
|
|
return str(local) if local.is_dir() else f"{SOURCE}/{name}.git"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def publish_answers(dest: Path) -> None:
|
|
|
|
|
"""Answers rendered from local checkouts (TEMPLATES_DIR) point at Gitea.
|
|
|
|
|
|
|
|
|
|
copier writes the path it rendered from into `_src_path`; a local path
|
|
|
|
|
only exists on this machine, and `updateproject` anywhere else fails.
|
|
|
|
|
"""
|
|
|
|
|
local = Path(SOURCE)
|
|
|
|
|
if not local.is_dir():
|
|
|
|
|
return
|
|
|
|
|
for answers in (dest / ".copier").glob("*.yml"):
|
|
|
|
|
lines = answers.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
if not line.startswith("_src_path:"):
|
|
|
|
|
continue
|
|
|
|
|
src = Path(line.split(":", 1)[1].strip().strip("'\""))
|
|
|
|
|
if src.is_dir() and src.resolve().parent == local.resolve():
|
|
|
|
|
lines[i] = f"_src_path: {PUBLISHED}/{src.name}.git\n"
|
|
|
|
|
answers.write_text("".join(lines), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def git_config(key: str) -> str:
|
|
|
|
|
result = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False)
|
|
|
|
|
return result.stdout.strip()
|
|
|
|
@@ -101,8 +122,14 @@ def new(args: argparse.Namespace) -> None:
|
|
|
|
|
copy_part("frontend", FRONTENDS[frontend], {"serve": serve})
|
|
|
|
|
if "caddy" in parts:
|
|
|
|
|
copy("caddy", {"project_slug": slug, "routes": routes})
|
|
|
|
|
publish_answers(dest)
|
|
|
|
|
if (dest / "Makefile").is_file():
|
|
|
|
|
subprocess.run(["make", "env"], cwd=dest, check=True)
|
|
|
|
|
if args.repo or (args.repo is None and interactive()):
|
|
|
|
|
call = gitea_api()
|
|
|
|
|
repo = choose_repo(dest, args.repo, call)
|
|
|
|
|
if repo:
|
|
|
|
|
setup_repo(dest, repo, call)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def git_dirty() -> bool:
|
|
|
|
@@ -151,7 +178,16 @@ def update(args: argparse.Namespace) -> None:
|
|
|
|
|
print(f"committed {file.stem} update")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GITEA = os.environ.get("GITEA_URL", "https://git.kotikot.com").rstrip("/")
|
|
|
|
|
CONFIG_FILE = Path.home() / ".config/templates/config.toml"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def config() -> dict:
|
|
|
|
|
import tomllib
|
|
|
|
|
|
|
|
|
|
return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8")) if CONFIG_FILE.is_file() else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GITEA = os.environ.get("GITEA_URL", config().get("gitea_url", "https://git.kotikot.com")).rstrip("/")
|
|
|
|
|
RENOVATE_FILE = "renovate.jsonc"
|
|
|
|
|
RENOVATE_STUB = """{
|
|
|
|
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
|
|
@@ -175,11 +211,8 @@ def gitea_token() -> str:
|
|
|
|
|
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: labels and the preset stub."""
|
|
|
|
|
import base64
|
|
|
|
|
def gitea_api():
|
|
|
|
|
import json
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
token = gitea_token()
|
|
|
|
@@ -195,30 +228,124 @@ def gitea(args: argparse.Namespace) -> None:
|
|
|
|
|
raw = response.read()
|
|
|
|
|
return json.loads(raw) if raw else None
|
|
|
|
|
|
|
|
|
|
# Gitea happily creates a second label with the same name, so the only way to
|
|
|
|
|
# stay idempotent is to look first.
|
|
|
|
|
existing = {label["name"] for label in call("GET", f"/repos/{args.repo}/labels?limit=100") or []}
|
|
|
|
|
for name, color, description in RENOVATE_LABELS:
|
|
|
|
|
if name in existing:
|
|
|
|
|
print(f"label {name}: already there")
|
|
|
|
|
continue
|
|
|
|
|
call("POST", f"/repos/{args.repo}/labels",
|
|
|
|
|
{"name": name, "color": color, "description": description})
|
|
|
|
|
print(f"label {name}: created")
|
|
|
|
|
return call
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def git(*args: str, cwd: Path) -> str:
|
|
|
|
|
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
|
|
|
|
|
return result.stdout.strip() if result.returncode == 0 else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def project_description(cwd: Path) -> str:
|
|
|
|
|
answers = cwd / ".copier/infra.yml"
|
|
|
|
|
if not answers.is_file():
|
|
|
|
|
return ""
|
|
|
|
|
for line in answers.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
if line.startswith("project_description:"):
|
|
|
|
|
return line.split(":", 1)[1].strip().strip("'\"")
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def adopt(cwd: Path, branch: str) -> None:
|
|
|
|
|
"""Put a freshly rendered project on top of an existing repo.
|
|
|
|
|
|
|
|
|
|
The repo's history becomes the local one; what the templates rendered
|
|
|
|
|
stays in the working tree as uncommitted changes against it, files the
|
|
|
|
|
templates do not know are restored as they are in the repo.
|
|
|
|
|
"""
|
|
|
|
|
subprocess.run(["git", "fetch", "-q", "origin"], cwd=cwd, check=True)
|
|
|
|
|
# No commits yet: the branch is unborn, reset creates it at the repo's tip
|
|
|
|
|
# and keeps the working tree.
|
|
|
|
|
subprocess.run(["git", "symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=cwd, check=True)
|
|
|
|
|
subprocess.run(["git", "reset", "-q", f"origin/{branch}"], cwd=cwd, check=True)
|
|
|
|
|
subprocess.run(["git", "branch", "-q", "-u", f"origin/{branch}"], cwd=cwd, check=True)
|
|
|
|
|
deleted = git("ls-files", "--deleted", cwd=cwd).splitlines()
|
|
|
|
|
if deleted:
|
|
|
|
|
subprocess.run(["git", "checkout", "-q", "--", *deleted], cwd=cwd, check=True)
|
|
|
|
|
print(
|
|
|
|
|
f"on {branch} of the existing repo: the rendered files are uncommitted changes on top of it.\n"
|
|
|
|
|
"Review with git status / git diff, keep what is yours (git checkout -- <file>), commit the rest."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def choose_repo(cwd: Path, repo: str | None, call) -> str | None:
|
|
|
|
|
if repo:
|
|
|
|
|
return repo
|
|
|
|
|
orgs = sorted(o["username"] for o in call("GET", "/user/orgs?limit=100") or [])
|
|
|
|
|
default = config().get("org") or (cwd.parent.name if cwd.parent.name in orgs else None)
|
|
|
|
|
if not interactive():
|
|
|
|
|
return f"{default}/{cwd.name}" if default else None
|
|
|
|
|
skip = "(no repo)"
|
|
|
|
|
org = questionary.select("Gitea organization", choices=[skip, *orgs], default=default or skip).unsafe_ask()
|
|
|
|
|
if org == skip:
|
|
|
|
|
return None
|
|
|
|
|
return f"{org}/{ask_text('Repo name', cwd.name)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup_repo(cwd: Path, repo: str, call) -> None:
|
|
|
|
|
import base64
|
|
|
|
|
import urllib.error
|
|
|
|
|
|
|
|
|
|
org, name = repo.split("/", 1)
|
|
|
|
|
try:
|
|
|
|
|
call("GET", f"/repos/{args.repo}/contents/{RENOVATE_FILE}")
|
|
|
|
|
print(f"{RENOVATE_FILE}: already there")
|
|
|
|
|
info = call("GET", f"/repos/{repo}")
|
|
|
|
|
print(f"repo {repo}: already there")
|
|
|
|
|
except urllib.error.HTTPError as error:
|
|
|
|
|
if error.code != 404:
|
|
|
|
|
sys.exit(f"repo {repo}: {error.code} {error.read()[:200]!r}")
|
|
|
|
|
info = call("POST", f"/orgs/{org}/repos", {
|
|
|
|
|
"name": name,
|
|
|
|
|
"description": project_description(cwd),
|
|
|
|
|
"private": config().get("private", True),
|
|
|
|
|
"default_branch": "main",
|
|
|
|
|
})
|
|
|
|
|
print(f"repo {repo}: created")
|
|
|
|
|
|
|
|
|
|
url = info["clone_url"] if config().get("remote") == "https" else info["ssh_url"]
|
|
|
|
|
if not info.get("empty", True) and not git("rev-parse", "--git-dir", cwd=cwd):
|
|
|
|
|
subprocess.run(["git", "init", "-q"], cwd=cwd, check=True)
|
|
|
|
|
if git("rev-parse", "--git-dir", cwd=cwd):
|
|
|
|
|
current = git("remote", "get-url", "origin", cwd=cwd)
|
|
|
|
|
if not current:
|
|
|
|
|
git("remote", "add", "origin", url, cwd=cwd)
|
|
|
|
|
print(f"origin: {url}")
|
|
|
|
|
elif current != url:
|
|
|
|
|
print(f"origin stays {current}, the repo is {url}")
|
|
|
|
|
if info.get("empty", True) and git("rev-parse", "--verify", "-q", "HEAD", cwd=cwd):
|
|
|
|
|
if not git("rev-parse", "--verify", "-q", "refs/heads/main", cwd=cwd):
|
|
|
|
|
git("branch", "-M", "main", cwd=cwd)
|
|
|
|
|
if not git("rev-parse", "--verify", "-q", "refs/heads/dev", cwd=cwd):
|
|
|
|
|
git("branch", "dev", "main", cwd=cwd)
|
|
|
|
|
subprocess.run(["git", "push", "-q", "-u", "origin", "main", "dev"], cwd=cwd, check=True)
|
|
|
|
|
git("switch", "-q", "dev", cwd=cwd)
|
|
|
|
|
print("pushed main and dev, on dev now")
|
|
|
|
|
elif info.get("empty", True):
|
|
|
|
|
print("nothing committed yet: commit, then run giteaproject again to push main and dev")
|
|
|
|
|
elif not git("rev-parse", "--verify", "-q", "HEAD", cwd=cwd):
|
|
|
|
|
adopt(cwd, info["default_branch"])
|
|
|
|
|
else:
|
|
|
|
|
print(f"{repo} is not empty and there are local commits: nothing pushed, merge them by hand")
|
|
|
|
|
|
|
|
|
|
existing = {label["name"] for label in call("GET", f"/repos/{repo}/labels?limit=100") or []}
|
|
|
|
|
for label, color, description in RENOVATE_LABELS:
|
|
|
|
|
if label in existing:
|
|
|
|
|
continue
|
|
|
|
|
call("POST", f"/repos/{repo}/labels", {"name": label, "color": color, "description": description})
|
|
|
|
|
print(f"label {label}: created")
|
|
|
|
|
|
|
|
|
|
remote_empty = call("GET", f"/repos/{repo}").get("empty", True)
|
|
|
|
|
if not remote_empty and not (cwd / RENOVATE_FILE).is_file():
|
|
|
|
|
try:
|
|
|
|
|
call("GET", f"/repos/{repo}/contents/{RENOVATE_FILE}")
|
|
|
|
|
except urllib.error.HTTPError as error:
|
|
|
|
|
if error.code != 404:
|
|
|
|
|
sys.exit(f"{RENOVATE_FILE}: {error.code} {error.read()[:200]!r}")
|
|
|
|
|
call("POST", f"/repos/{args.repo}/contents/{RENOVATE_FILE}", {
|
|
|
|
|
call("POST", f"/repos/{repo}/contents/{RENOVATE_FILE}", {
|
|
|
|
|
"content": base64.b64encode(RENOVATE_STUB.encode()).decode(),
|
|
|
|
|
"message": "chore(renovate): extend the shared preset",
|
|
|
|
|
})
|
|
|
|
|
print(f"{RENOVATE_FILE}: created")
|
|
|
|
|
print(f"{RENOVATE_FILE}: committed on the server, pull it")
|
|
|
|
|
|
|
|
|
|
org, name = args.repo.split("/", 1)
|
|
|
|
|
teams = call("GET", f"/orgs/{org}/teams?limit=50") or []
|
|
|
|
|
team = next((t for t in teams if t["name"] == "renovate"), None)
|
|
|
|
|
if team is None:
|
|
|
|
@@ -232,27 +359,32 @@ def gitea(args: argparse.Namespace) -> None:
|
|
|
|
|
"can_create_org_repo": False,
|
|
|
|
|
})
|
|
|
|
|
print(f"team renovate in {org}: created")
|
|
|
|
|
call("PUT", f"/teams/{team['id']}/members/renovate")
|
|
|
|
|
print("bot renovate: added to the team")
|
|
|
|
|
members = {m["login"] for m in call("GET", f"/teams/{team['id']}/members?limit=50") or []}
|
|
|
|
|
if "renovate" not in members:
|
|
|
|
|
call("PUT", f"/teams/{team['id']}/members/renovate")
|
|
|
|
|
print("bot renovate: added to the team")
|
|
|
|
|
repos = {r["name"] for r in call("GET", f"/teams/{team['id']}/repos?limit=50") or []}
|
|
|
|
|
if name in repos:
|
|
|
|
|
print(f"team access to {name}: already there")
|
|
|
|
|
else:
|
|
|
|
|
if name not in repos:
|
|
|
|
|
call("PUT", f"/teams/{team['id']}/repos/{org}/{name}")
|
|
|
|
|
print(f"team access to {name}: granted")
|
|
|
|
|
print(f"team renovate: access to {name}")
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
"\nOne thing is still manual, and it does not happen by itself: the repo has\n"
|
|
|
|
|
"to be added to `repositories` in projects/personal/renovate/renovate-config.js\n"
|
|
|
|
|
"of infra/komodo. Extending the preset subscribes nothing - the bot only visits\n"
|
|
|
|
|
"repos named in that list."
|
|
|
|
|
f"\nStill manual: add {repo} to `repositories` in\n"
|
|
|
|
|
"projects/personal/renovate/renovate-config.js of infra/komodo,\n"
|
|
|
|
|
"the bot only visits repos named in that list."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gitea(args: argparse.Namespace) -> None:
|
|
|
|
|
"""Create the Gitea repo if needed, point origin at it and prepare it for Renovate."""
|
|
|
|
|
cwd = Path.cwd()
|
|
|
|
|
call = gitea_api()
|
|
|
|
|
repo = choose_repo(cwd, args.repo, call)
|
|
|
|
|
if repo is None:
|
|
|
|
|
sys.exit("no repo chosen")
|
|
|
|
|
setup_repo(cwd, repo, call)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
parser = argparse.ArgumentParser(prog="templates")
|
|
|
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
@@ -266,6 +398,7 @@ def main() -> None:
|
|
|
|
|
n.add_argument("--frontend", choices=list(FRONTENDS))
|
|
|
|
|
n.add_argument("--serve", choices=SERVE)
|
|
|
|
|
n.add_argument("--mcp", help="comma-separated: context7,svelte; empty for none")
|
|
|
|
|
n.add_argument("--repo", help="org/name on Gitea to create and use as origin; empty for none")
|
|
|
|
|
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")
|
|
|
|
@@ -273,8 +406,8 @@ def main() -> None:
|
|
|
|
|
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="Set a repo up for Renovate: labels and preset stub")
|
|
|
|
|
g.add_argument("repo", help="org/name on Gitea")
|
|
|
|
|
g = sub.add_parser("gitea", help="Create the Gitea repo, set origin, prepare it for Renovate")
|
|
|
|
|
g.add_argument("repo", nargs="?", help="org/name on Gitea, asked when omitted")
|
|
|
|
|
g.set_defaults(func=gitea)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
args.func(args)
|
|
|
|
|