"""Gitea repositories for workshop projects. The workshop assistant produces things that want version control — firmware, scripts, KiCad files, a config it has been iterating on — and this household already runs Gitea. So a project can have a repository, created on request, recorded against the project. WHAT THIS DOES -------------- Creates repositories, reports what exists, and — the part that matters — **applies branch protection at creation so history cannot be rewritten.** Committing and pushing live in `git_ops.py`; the assistant does both, unattended. The line is not "may it write to git" but "may it *remove* anything": adding to history is allowed because the worst case is a bad commit you revert, while rewriting history has no undo. `protect_branch()` below is what makes that a control rather than a promise — Gitea refuses the force-push regardless of what asked for it, including a human who typed `--force` out of habit. The git working tree is `code/` inside the project workspace, never the workspace root, so no git operation of any kind has a path to the datasheets and photos sitting beside it. THE TOKEN --------- Gitea needs an **API token** with permission to create repositories — Settings → Applications → Generate New Token, scope `write:repository` (plus `write:user` if you want it to create repos under your own account rather than an organisation). That token can create and delete repositories, so: - It lives in `workshop.env`, 600, like every other credential in this project. - **Repository deletion is not implemented here at all**, and neither is any history rewrite. The token can technically do both; nothing in this service gives them a path. When history genuinely has to be scrubbed — a committed token — see `git_ops.scrub_instructions()`, which writes the commands out for you to run by hand. - Prefer a token on a **dedicated Gitea user** ("workshop-bot") with access to one organisation, over one on your own account with access to everything you own. The blast radius of a leaked env file is then one org of generated repos. """ from __future__ import annotations import json import logging import os import re import urllib.error import urllib.request LOG = logging.getLogger("workshop.gitea") GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") # Which account or organisation new repositories belong to. Empty means the token's own # user, which works and is the worse default — see the module docstring. GITEA_OWNER = os.environ.get("GITEA_OWNER", "").strip() GITEA_PRIVATE = os.environ.get("GITEA_PRIVATE_REPOS", "true").strip().lower() != "false" GITEA_TIMEOUT = float(os.environ.get("GITEA_TIMEOUT", "15")) # Gitea's own constraint on repository names, applied here so a bad name is a clear # refusal from this service rather than a 422 from somewhere else. REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$") def configured() -> bool: return bool(GITEA_URL and GITEA_TOKEN) def _request(method: str, path: str, payload: dict | None = None) -> tuple[int, dict]: data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", data=data, method=method) req.add_header("Content-Type", "application/json") # Gitea accepts `Authorization: token `; the bearer form is not universally # honoured across versions, so use the documented one. req.add_header("Authorization", f"token {GITEA_TOKEN}") try: with urllib.request.urlopen(req, timeout=GITEA_TIMEOUT) as resp: body = resp.read() return resp.status, (json.loads(body) if body else {}) except urllib.error.HTTPError as exc: body = exc.read() try: parsed = json.loads(body) if body else {} except ValueError: parsed = {"message": body.decode("utf-8", "replace")[:300]} return exc.code, parsed def create_repo(name: str, description: str = "", private: bool | None = None) -> dict: """Create a repository, or report the one that is already there. **An existing repository of that name is a success, not a conflict.** Asking twice means the second caller wanted a repo to exist, which it does — and returning an error there would make every retry look like a failure while leaving the assistant with no URL to record. """ if not configured(): return { "ok": False, "reason": "not_configured", "message": "GITEA_URL and GITEA_TOKEN are not set — see workshop.env.example.", } name = str(name or "").strip() if not REPO_NAME_RE.match(name): return { "ok": False, "reason": "bad_name", "message": "A repository name is letters, digits, dot, dash or underscore.", } payload = { "name": name, "description": str(description or "")[:255], "private": GITEA_PRIVATE if private is None else bool(private), # An empty repo is the right starting point: the workspace already has the # files, and an auto-generated README is the first merge conflict. "auto_init": False, } path = f"/orgs/{GITEA_OWNER}/repos" if GITEA_OWNER else "/user/repos" status, body = _request("POST", path, payload) if status in (200, 201): LOG.info("workshop: created Gitea repository %s", body.get("full_name") or name) summary = _repo_summary(body) summary["protection"] = protect_branch(body.get("owner", {}).get("login", ""), name) return {"ok": True, "created": True, "repo": summary} if status == 409: existing = get_repo(name) if existing.get("ok"): return {"ok": True, "created": False, "repo": existing["repo"]} LOG.warning("workshop: Gitea repo creation failed (%s): %s", status, body.get("message")) return { "ok": False, "reason": "gitea_error", "status": status, "message": body.get("message") or f"Gitea returned {status}.", } def protect_branch(owner: str, repo: str, branch: str = "") -> dict: """Refuse force-pushes and branch deletion on the default branch, server-side. THIS IS THE ENFORCEMENT. `git_ops.py` refuses to rewrite history, but that is a convention any process holding the same token could ignore. Branch protection is the control: Gitea rejects the push regardless of what asked for it, including a human at a terminal who typed --force out of habit. Applied at creation because retrofitting it means a window where it wasn't on, and that window is exactly when a new repo gets its messy first pushes. Best-effort: a failure here is reported, not raised. A repo without protection is still a usable repo, and refusing to create one would trade a real capability for a guarantee that git_ops.py already provides in the normal case. **The response says which you got** — never assume protection is on because a repo exists. VERIFY against your Gitea: the branch-protection payload's field names have changed across versions (`branch_name` vs `rule_name` in particular), and this is written from the documented shape, not against a live instance. """ if not owner: return {"applied": False, "reason": "owner unknown"} payload = { "rule_name": branch or "main", "branch_name": branch or "main", "enable_push": True, # The two that matter. "enable_force_push": False, "enable_delete": False, } status, body = _request("POST", f"/repos/{owner}/{repo}/branch_protections", payload) if status in (200, 201): return {"applied": True, "branch": payload["rule_name"]} LOG.warning( "workshop: could not apply branch protection to %s/%s (%s): %s — history is protected " "only by this service's own refusal until you set it in Gitea by hand", owner, repo, status, body.get("message"), ) return {"applied": False, "status": status, "message": body.get("message")} def get_repo(name: str) -> dict: if not configured(): return {"ok": False, "reason": "not_configured", "message": "Gitea is not configured."} owner = GITEA_OWNER if not owner: status, user = _request("GET", "/user") if status != 200: return {"ok": False, "reason": "gitea_error", "message": "Could not resolve the token's own user."} owner = user.get("login", "") status, body = _request("GET", f"/repos/{owner}/{name}") if status == 200: return {"ok": True, "repo": _repo_summary(body)} return {"ok": False, "reason": "not_found", "message": f"No repository {owner}/{name}."} def _repo_summary(body: dict) -> dict: """Only the fields anything here needs. Gitea's repo object is ~60 keys, and storing or echoing all of them would make this service's API shape hostage to Gitea's.""" return { "name": body.get("name"), "full_name": body.get("full_name"), "html_url": body.get("html_url"), "clone_url": body.get("clone_url"), "ssh_url": body.get("ssh_url"), "private": body.get("private"), }