diff --git a/README.md b/README.md index 625ebd9..7cf9368 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ updateproject # every part updateproject backend # one part ``` -`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. Every prompt has a flag for non-interactive use: +`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 newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --mcp context7,svelte --defaults @@ -17,27 +17,33 @@ newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python - `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. -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, so the next part starts from a clean tree again; conflicts stay in those commits for you to resolve. 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. +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. -## Renovate - -A new repo needs the `renovate`, `wait-3d` and `deps-code` labels before the bot -ever runs against it: Renovate fails on a label that does not exist, and until -today these were created by hand for every repo. +## Gitea ```sh +giteaproject # asks for the organization and the name giteaproject ms-agents/my-thing ``` -It creates the labels and, if the repo has no `renovate.jsonc` yet, commits one -that is nothing but an `extends` on the shared preset in `templates/renovate`. -Projects rendered by `newproject` already carry that file from the `infra` -template, so for them the command only does the labels. +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. `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`, and the -`renovate` team of its organization needs write access to it. Miss that step and -the config sits there doing nothing, silently. +`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. diff --git a/templates.py b/templates.py index 5104d73..963f407 100644 --- a/templates.py +++ b/templates.py @@ -103,6 +103,11 @@ def new(args: argparse.Namespace) -> None: copy("caddy", {"project_slug": slug, "routes": routes}) 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 +156,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 +189,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 +206,96 @@ 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 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"{RENOVATE_FILE}: {error.code} {error.read()[:200]!r}") - call("POST", f"/repos/{args.repo}/contents/{RENOVATE_FILE}", { - "content": base64.b64encode(RENOVATE_STUB.encode()).decode(), - "message": "chore(renovate): extend the shared preset", + 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"{RENOVATE_FILE}: created") + print(f"repo {repo}: created") + + url = info["clone_url"] if config().get("remote") == "https" else info["ssh_url"] + 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") + + 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") - 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 +309,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 +348,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 +356,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)