SmartestHome/workshop/gitea.py

210 lines
9.1 KiB
Python

"""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 <t>`; 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"),
}