"""Gitea API operations for the tracker and release workflows (Python stdlib only).""" import argparse import json import os from pathlib import Path import re import subprocess from urllib.error import HTTPError from urllib.parse import quote, urlencode, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener class NoRedirects(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # Never forward an instance token to a redirect destination. return None class GiteaAPI: def __init__(self, server, repository, token): parsed = urlsplit(server) if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("GITEA_SERVER_URL must be an HTTPS instance URL") if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): raise ValueError("GITEA_REPOSITORY must be owner/repository") if not token: raise ValueError("GITEA_TOKEN is required") self.base = server.rstrip("/") + "/api/v1/repos/" + repository self.token = token self.opener = build_opener(NoRedirects()) def request(self, method, path, data=None, content_type="application/json", allow_missing=False): if data is not None and not isinstance(data, bytes): data = json.dumps(data).encode("utf-8") req = Request(self.base + path, data=data, method=method, headers={ "Authorization": "token " + self.token, "Accept": "application/json", "Content-Type": content_type, "User-Agent": "GiteaMailTemplates-actions", }) try: with self.opener.open(req, timeout=120) as response: return json.load(response) except HTTPError as error: status = error.code error.close() if allow_missing and status == 404: return None raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None def git(*args): return subprocess.check_output(["git", *args], text=True).strip() def create_pull_request(api, version): if not re.fullmatch(r"\d+\.\d+\.\d+", version): raise ValueError("Expected a stable X.Y.Z upstream version") branch = "track/gitea-" + version title = f"Track Gitea {version} compatibility" changed = git("diff", "--name-only", "HEAD", "--").splitlines() if any(not name.endswith(".md") for name in changed): raise ValueError("Tracker may commit only Markdown changes") if not changed: print("[PASS] No tracked documentation changes") return # Repeated scheduled runs can find a branch from an earlier pending PR. # Reuse identical content, but never force-push over a changed branch. remote = git("ls-remote", "--heads", "origin", "refs/heads/" + branch) if remote: git("fetch", "origin", "refs/heads/" + branch) if git("diff", "--name-only", "FETCH_HEAD", "--"): raise ValueError(f"Existing {branch} differs; review it before updating the tracking PR") else: git("switch", "-c", branch) git("add", "--", *changed) git("-c", "user.name=release-bot", "-c", "user.email=release-bot@users.noreply.local", "commit", "-m", f"docs: track Gitea {version} pending verification") git("push", "origin", "HEAD:refs/heads/" + branch) page = 1 while True: pulls = api.request("GET", f"/pulls?state=open&base_branch=main&limit=50&page={page}") for pull in pulls: if (pull["head"]["ref"] == branch and pull["base"]["ref"] == "main" and pull["head"]["repo"]["full_name"] == pull["base"]["repo"]["full_name"]): print(f"[PASS] Tracking PR already exists: {pull['html_url']}") return if not pulls: break page += 1 body = f"""Gitea **{version}** is recorded as [PENDING]. Verified/tested versions remain unchanged. - [ ] Review upstream mail templates, mailer data, functions and translation keys. - [ ] Run `go test ./...` and `go run . preview all` from `tools/`. - [ ] Record results in `COMPATIBILITY.md`; update verified status only after testing. - [ ] Release template changes separately with reviewed notes and assets. """ pull = api.request("POST", "/pulls", {"title": title, "head": branch, "base": "main", "body": body}) print(f"[PASS] Created tracking PR: {pull['html_url']}") def publish_release(api, version, root=Path(".")): if not re.fullmatch(r"v\d+\.\d+\.\d+", version): raise ValueError("Expected a stable vX.Y.Z release tag") root = Path(root) lock = json.loads((root / "gitea.lock.json").read_text(encoding="utf-8")) if lock["tag"] != version: raise ValueError("Release tag must match gitea.lock.json") notes = (root / ".github" / "release-notes" / (version + ".md")).read_text(encoding="utf-8") if not notes.strip(): raise ValueError("Release notes are empty") assets = [root / "dist" / ("gitea-mail-templates-" + version + ext) for ext in (".zip", ".tar.gz")] for asset in assets: if asset.is_symlink() or not asset.is_file() or not asset.stat().st_size: raise ValueError(f"Missing or invalid release archive: {asset}") existing = api.request("GET", "/releases/tags/" + quote(version, safe=""), allow_missing=True) if existing is not None: raise ValueError("Release already exists; refusing to replace its notes or assets") release = api.request("POST", "/releases", { "tag_name": version, "name": version, "body": notes, "draft": True, "prerelease": False, }) release_id = int(release["id"]) # Gitea accepts raw attachment data with the filename in the query string. # Publish only after both uploads succeed; failures leave a draft for review. for asset in assets: api.request("POST", f"/releases/{release_id}/assets?" + urlencode({"name": asset.name}), asset.read_bytes(), content_type="application/octet-stream") release = api.request("PATCH", f"/releases/{release_id}", {"draft": False}) print(f"[PASS] Published {release['html_url']}") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("operation", choices=("create-pull-request", "publish-release")) parser.add_argument("--version", required=True) args = parser.parse_args() try: api = GiteaAPI(os.environ.get("GITEA_SERVER_URL", ""), os.environ.get("GITEA_REPOSITORY", ""), os.environ.get("GITEA_TOKEN", "")) if args.operation == "create-pull-request": create_pull_request(api, args.version) else: publish_release(api, args.version) except (ValueError, KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as error: parser.exit(1, f"[FAIL] {error}\n") if __name__ == "__main__": main()