Compare commits
21
Commits
v0.1.0
...
19f08ea71c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f08ea71c | ||
|
|
095b8eacbd | ||
|
|
aa8ddc99ef | ||
|
|
2b332da0a2 | ||
|
|
3d85e6f29c | ||
|
|
44ce6c04fa | ||
|
|
048f29f21e | ||
|
|
68034cd2f5 | ||
|
|
81d518f2c8 | ||
|
|
2785443794 | ||
|
|
d7e948df33 | ||
|
|
454be36bec | ||
|
|
e0550cb462 | ||
|
|
1f663c8e51 | ||
|
|
7ff97419e3 | ||
|
|
79cb646dd3 | ||
|
|
c31f0b241f | ||
|
|
40103e9bef | ||
|
|
cb30b8c5d8 | ||
|
|
216020f152 | ||
|
|
7a1774a36f |
No files matched your search
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
__pycache__/
|
||||
@@ -1,21 +1,49 @@
|
||||
# newproject
|
||||
|
||||
One command that composes a project from the templates in this organization: `infra` at the root, then `backend-python`, `frontend-svelte` and `caddy` on top, each with its own answers file.
|
||||
Composes a project from the templates in this organization: `infra` at the root, then `backend-python`, `frontend-svelte`, `blank` and `caddy` on top, each with its own answers file in `.copier/`.
|
||||
|
||||
```sh
|
||||
source ~/projects/templates/newproject/shell.sh
|
||||
newproject ~/projects/my-thing
|
||||
updateproject # every part
|
||||
updateproject backend # one part
|
||||
```
|
||||
|
||||
Templates are read from `$TEMPLATES_DIR` (default `~/projects/templates`). Every part updates independently:
|
||||
`newproject` asks for the description, the parts, the backend language, the frontend framework, how the frontend is served and the MCP servers for `.mcp.json`, then hands over to each template's own questions, and finally offers to create the Gitea repo. Every prompt has a flag for non-interactive use:
|
||||
|
||||
```sh
|
||||
copier update -a .copier-answers.backend.yml
|
||||
newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --mcp context7,svelte --defaults
|
||||
```
|
||||
|
||||
Shapes it produces:
|
||||
`other` as the backend or the frontend renders the `blank` template: the part's compose files, Makefile and `.env.example` with no code, for a stack that has no template of its own, next to any other part.
|
||||
|
||||
- infrastructure only: root `docker-compose.yml`, `Makefile`, `.env`, optional `caddy/`
|
||||
- backend with one or many services, each `python -m <module>` from one image
|
||||
- backend and frontend as separate containers behind Caddy
|
||||
- backend serving the frontend's static build from the api
|
||||
- frontend only
|
||||
One part alone is rendered at the root of the project; two parts live in `backend/` and `frontend/`. After rendering, `make env` creates `.env` and every `docker-compose.override.yml` from their examples. `updateproject` needs a clean working tree and commits each part's update separately, refreshing `uv.lock` and `bun.lock` first, so the next part starts from a clean tree again. When copier leaves conflicts it stops before committing and lists the files: resolve, commit, run it again. Templates are cloned from `https://git.kotikot.com/templates` unless `TEMPLATES_DIR` points at a directory with local checkouts; `TEMPLATES_REF=HEAD` renders their working trees instead of the latest tag. Either way the answers in `.copier/` point at Gitea, not at the local checkout, so `updateproject` works on any machine; it always updates from Gitea, so push a template change before updating projects with it.
|
||||
|
||||
## Gitea
|
||||
|
||||
```sh
|
||||
giteaproject # asks for the organization and the name
|
||||
giteaproject ms-agents/my-thing
|
||||
```
|
||||
|
||||
Run in the project directory. It creates the repo if it does not exist (private, `main` as the default branch, the description from `.copier/infra.yml`), adds it as `origin` over ssh, and when the repo is still empty and there are local commits, pushes `main` and `dev` and switches to `dev`. An existing `origin` that points elsewhere is left alone. When the repo already has commits and the project has none yet (a fresh `newproject` for code that lives in Gitea already), the project lands on top of it: the repo's history becomes the local one, the rendered files stay as uncommitted changes against it, and files the templates do not render come back as they are in the repo. Review with `git status` and `git diff`, keep what is yours, commit the rest. `newproject` asks for the organization at the end, or takes `--repo org/name`; without a terminal and without `--repo` it creates nothing.
|
||||
|
||||
The organization defaults to `org` from the config, else to the name of the parent directory when it matches an organization. `~/.config/templates/config.toml`:
|
||||
|
||||
```toml
|
||||
gitea_url = "https://git.kotikot.com"
|
||||
remote = "ssh"
|
||||
private = true
|
||||
org = "ms-agents"
|
||||
```
|
||||
|
||||
The token comes from `GITEA_TOKEN` or `~/.config/gitea/token`.
|
||||
|
||||
The same command prepares the repo for Renovate: the `renovate`, `wait-3d` and `deps-code` labels (Renovate fails on a label that does not exist), write access for the organization's `renovate` team, and, for an existing repo without one, a `renovate.jsonc` committed on the server that extends the shared preset in `templates/renovate`. Projects rendered by `newproject` carry that file from the `infra` template already.
|
||||
|
||||
**Extending the preset does not subscribe the repo to anything.** The bot walks
|
||||
an explicit list, so the repo also has to be added to `repositories` in
|
||||
`projects/personal/renovate/renovate-config.js` of `infra/komodo`. Miss that step
|
||||
and the config sits there doing nothing, silently.
|
||||
|
||||
Requires `uv`; `bun`, `docker` and `pre-commit` are used by the generated projects.
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TEMPLATES="${TEMPLATES_DIR:-$HOME/projects/templates}"
|
||||
dest="${1:?usage: newproject <dest> [copier options]}"
|
||||
shift
|
||||
copier_args=("$@")
|
||||
|
||||
ask() {
|
||||
local prompt="$1" default="$2" answer
|
||||
read -r -p "$prompt [$default]: " answer
|
||||
printf '%s' "${answer:-$default}"
|
||||
}
|
||||
|
||||
yes_no() {
|
||||
local answer
|
||||
answer=$(ask "$1" "$2")
|
||||
[[ "$answer" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
copy() {
|
||||
uvx copier@latest copy --trust "${copier_args[@]}" "$@"
|
||||
}
|
||||
|
||||
project_name=$(ask "Project name" "$(basename "$dest")")
|
||||
default_slug=$(printf '%s' "$project_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g')
|
||||
project_slug=$(ask "Slug" "$default_slug")
|
||||
author_name=$(git config user.name || true)
|
||||
author_email=$(git config user.email || true)
|
||||
|
||||
parts=()
|
||||
yes_no "Backend" y && parts+=(backend)
|
||||
yes_no "Frontend" y && parts+=(frontend)
|
||||
with_caddy=false
|
||||
yes_no "Caddy" y && with_caddy=true
|
||||
|
||||
serve=bun
|
||||
serve_spa=false
|
||||
if [[ " ${parts[*]} " == *" frontend "* ]]; then
|
||||
serve=$(ask "Frontend served by (bun | static | api)" "bun")
|
||||
[[ "$serve" == api ]] && serve_spa=true
|
||||
fi
|
||||
|
||||
routes=()
|
||||
[[ " ${parts[*]} " == *" backend "* ]] && routes+=(api)
|
||||
[[ " ${parts[*]} " == *" frontend "* && "$serve" != api ]] && routes+=(frontend)
|
||||
|
||||
json_list() {
|
||||
local out="[" sep=""
|
||||
for item in "$@"; do out+="$sep\"$item\""; sep=","; done
|
||||
printf '%s]' "$out"
|
||||
}
|
||||
|
||||
mkdir -p "$dest"
|
||||
copy -d "parts=$(json_list "${parts[@]}")" "$TEMPLATES/infra" "$dest"
|
||||
|
||||
if [[ " ${parts[*]} " == *" backend "* ]]; then
|
||||
copy -d "project_name=$project_name" -d "project_slug=$project_slug" \
|
||||
-d "author_name=$author_name" -d "author_email=$author_email" \
|
||||
-d "serve_spa=$serve_spa" "$TEMPLATES/backend-python" "$dest"
|
||||
fi
|
||||
|
||||
if [[ " ${parts[*]} " == *" frontend "* ]]; then
|
||||
copy -d "project_name=$project_name" -d "project_slug=$project_slug" \
|
||||
-d "serve=$serve" "$TEMPLATES/frontend-svelte" "$dest"
|
||||
fi
|
||||
|
||||
if $with_caddy; then
|
||||
copy -d "project_slug=$project_slug" -d "routes=$(json_list "${routes[@]}")" "$TEMPLATES/caddy" "$dest"
|
||||
fi
|
||||
|
||||
(cd "$dest" && make env)
|
||||
@@ -0,0 +1,9 @@
|
||||
if [ -n "${ZSH_VERSION-}" ]; then
|
||||
TEMPLATES_HOME="${${(%):-%x}:A:h}"
|
||||
else
|
||||
TEMPLATES_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
fi
|
||||
|
||||
newproject() { uv run --script "$TEMPLATES_HOME/templates.py" new "$@"; }
|
||||
updateproject() { uv run --script "$TEMPLATES_HOME/templates.py" update "$@"; }
|
||||
giteaproject() { uv run --script "$TEMPLATES_HOME/templates.py" gitea "$@"; }
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
# /// 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
|
||||
|
||||
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}
|
||||
FRONTENDS = {"svelte": "frontend-svelte", "other": BLANK}
|
||||
SERVE = ["bun", "static", "api"]
|
||||
MCP = ["context7", "svelte"]
|
||||
|
||||
|
||||
def template(name: str) -> str:
|
||||
local = Path(SOURCE) / name
|
||||
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()
|
||||
|
||||
|
||||
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))
|
||||
description = args.description if args.description is not None else ask_text("Description", 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 and FRONTENDS[frontend] != BLANK:
|
||||
options = SERVE if backend else SERVE[:-1]
|
||||
serve = args.serve or ask_select("Frontend served by", options, "bun")
|
||||
mcp_default = ["svelte"] if frontend and FRONTENDS[frontend] == "frontend-svelte" else []
|
||||
mcp = args.mcp.split(",") if args.mcp is not None else ask_checkbox("MCP servers", MCP, mcp_default)
|
||||
mcp = [m for m in mcp if m]
|
||||
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, answers_file: str | None = None) -> None:
|
||||
run_copy(template(name), dest, data=data, answers_file=answers_file, unsafe=True,
|
||||
defaults=args.defaults, vcs_ref=REF)
|
||||
|
||||
def copy_part(part: str, name: str, data: dict) -> None:
|
||||
if name == BLANK:
|
||||
copy(name, {**identity, "layout": layout, "part": part}, f".copier/{part}.yml")
|
||||
else:
|
||||
copy(name, {**identity, "layout": layout, **data})
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
identity = {"project_name": name, "project_slug": slug}
|
||||
copy("infra", {"project_name": name, "project_description": description, "layout": "single" if single else "parts",
|
||||
"parts": [] if single else stack, "mcp_servers": mcp})
|
||||
layout = "root" if single else "part"
|
||||
if backend:
|
||||
copy_part("backend", BACKENDS[backend], {"project_description": description, "serve_spa": serve == "api",
|
||||
"author_name": git_config("user.name"),
|
||||
"author_email": git_config("user.email")})
|
||||
if frontend:
|
||||
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:
|
||||
result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=False)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def update_message(answers: Path) -> str:
|
||||
"""Conventional commit for a template update, with the version it moved to."""
|
||||
ref = ""
|
||||
for line in answers.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("_commit:"):
|
||||
ref = line.split(":", 1)[1].strip().strip("'\"")
|
||||
break
|
||||
scope = answers.stem
|
||||
return f"chore({scope}): update template to {ref}" if ref else f"chore({scope}): update template"
|
||||
|
||||
|
||||
def refresh_locks(part: str) -> None:
|
||||
root = Path(part) if Path(part, "Makefile").is_file() else Path()
|
||||
if (root / "uv.lock").is_file():
|
||||
subprocess.run(["uv", "lock", "-q"], cwd=root, check=True)
|
||||
if (root / "bun.lock").is_file():
|
||||
subprocess.run(["bun", "install", "--silent"], cwd=root, check=True)
|
||||
|
||||
|
||||
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)
|
||||
conflicts = subprocess.run(["git", "diff", "--name-only", "--diff-filter=U"],
|
||||
capture_output=True, text=True, check=False).stdout.split()
|
||||
if conflicts:
|
||||
sys.exit(f"{file.stem}: resolve conflicts, then commit and rerun:\n " + "\n ".join(conflicts))
|
||||
refresh_locks(file.stem)
|
||||
if git_dirty():
|
||||
subprocess.run(["git", "add", "-A"], check=True)
|
||||
subprocess.run(["git", "commit", "-qm", update_message(file)], check=True)
|
||||
print(f"committed {file.stem} update")
|
||||
|
||||
|
||||
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",
|
||||
"extends": ["local>templates/renovate"]
|
||||
}
|
||||
"""
|
||||
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_api():
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
token = gitea_token()
|
||||
|
||||
def call(method: str, path: str, body: dict | None = None):
|
||||
request = urllib.request.Request(
|
||||
f"{GITEA}/api/v1{path}",
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
method=method,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
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:
|
||||
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/{repo}/contents/{RENOVATE_FILE}", {
|
||||
"content": base64.b64encode(RENOVATE_STUB.encode()).decode(),
|
||||
"message": "chore(renovate): extend the shared preset",
|
||||
})
|
||||
print(f"{RENOVATE_FILE}: committed on the server, pull it")
|
||||
|
||||
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:
|
||||
team = call("POST", f"/orgs/{org}/teams", {
|
||||
"name": "renovate",
|
||||
"description": "Renovate bot: write on listed repos only, not the whole org",
|
||||
"includes_all_repositories": False,
|
||||
"permission": "none",
|
||||
"units": ["repo.code", "repo.issues", "repo.pulls"],
|
||||
"units_map": {"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"},
|
||||
"can_create_org_repo": False,
|
||||
})
|
||||
print(f"team renovate in {org}: created")
|
||||
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 not in repos:
|
||||
call("PUT", f"/teams/{team['id']}/repos/{org}/{name}")
|
||||
print(f"team renovate: access to {name}")
|
||||
|
||||
print(
|
||||
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)
|
||||
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("--description")
|
||||
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("--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")
|
||||
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 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user