#!/usr/bin/env python3 """ _gitea.py — transport. Everything that talks to Gitea, and nothing else. Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's query quirks. It does NOT know what an issue is: no sections, no acceptance criteria, no type taxonomy. Payload shapes come from map.py; the domain model lives one layer further out in skills/issue/scripts/issue.py. Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up from CWD — the same file /tea:auth writes and the tea-guard hook reads. No script here accepts a login argument: the operator's pin is the only identity they will use. No pin -> exit with a pointer to /tea:auth. Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with a local slug. It is transport bookkeeping, not domain data — the domain never reads it, and losing it costs a re-pull, not information. """ import datetime import json import os import subprocess import re import sys import urllib.parse PAYLOAD_DIR = ".payload" REMOTE_MAP = ".remote.json" def die(msg, code=1): sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg)) sys.exit(code) def warn(msg): sys.stderr.write("warning: %s\n" % msg) def now_iso(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # -------------------------------------------------------------------------- # login # -------------------------------------------------------------------------- def find_pin(start_dir=None): """Walk up from start_dir; return the login from the first .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" d = os.path.abspath(start_dir or ".") while True: p = os.path.join(d, ".claude", "settings.local.json") if os.path.isfile(p): try: with open(p) as f: v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") if isinstance(v, str) and v.strip(): return v.strip() except Exception: pass parent = os.path.dirname(d) if parent == d: return None d = parent def require_login(): login = find_pin(os.getcwd()) if not login: die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") return login # -------------------------------------------------------------------------- # api # -------------------------------------------------------------------------- def api(login, endpoint, method="GET", payload=None, payload_name=None, out_root=None, allow_fail=False): """Call `tea api`; return parsed JSON (None on an empty body). payload (a dict) is written to /.payload/.json and passed as -d @file — the file survives the call for retries and debugging. allow_fail returns None instead of exiting when the call fails.""" cmd = ["tea", "api", "--login", login] if method != "GET": cmd += ["-X", method] if payload is not None: pdir = os.path.join(out_root or ".", PAYLOAD_DIR) os.makedirs(pdir, exist_ok=True) path = os.path.join(pdir, "%s.json" % (payload_name or "request")) with open(path, "w") as f: json.dump(payload, f, ensure_ascii=False, indent=2) cmd += ["-d", "@" + path] cmd.append(endpoint) r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: if allow_fail: return None die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip())) body = r.stdout.strip() if not body: return None try: return json.loads(body) except json.JSONDecodeError: if allow_fail: return None die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500])) def paginate(login, endpoint, limit=50, max_pages=40, **kw): """GET a list endpoint page by page; return the concatenated list.""" sep = "&" if "?" in endpoint else "?" out = [] for page in range(1, max_pages + 1): batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw) if not isinstance(batch, list) or not batch: break out.extend(batch) if len(batch) < limit: break return out def repo_base(repo=None): """API prefix. Without --repo, let tea fill {owner}/{repo} from CWD.""" return "repos/%s" % repo if repo else "repos/{owner}/{repo}" def repo_slug(login, repo=None): """owner/repo as a literal string — needed for remote keys, which must not contain tea's {owner}/{repo} placeholder.""" if repo: return repo got = api(login, "repos/{owner}/{repo}", allow_fail=True) if isinstance(got, dict) and got.get("full_name"): return got["full_name"] die("cannot determine owner/repo from the CWD — pass --repo owner/repo") def parse_key(key): """Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL.""" key = key.strip() m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key) if m: return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2)) m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key) if m: return int(m.group(2)), m.group(1) m = re.match(r'^#?(\d+)$', key) if m: return int(m.group(1)), None die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key) # -------------------------------------------------------------------------- # filters # -------------------------------------------------------------------------- def resolve_milestone(login, base, value): """(id, title) for a milestone given by id or title. Exits if unknown. Gitea silently IGNORES an unresolvable `milestones=` filter and returns the whole backlog, so the milestone must be resolved before it is trusted.""" got = paginate(login, "%s/milestones?state=all" % base, limit=100) for m in got or []: if str(m.get("id")) == str(value) or m.get("title") == str(value): return m["id"], m.get("title", "") have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or []) die("no milestone %r in this repo — have: %s" % (value, have or "none")) def matches(payload, milestone_id=None, labels=()): """Client-side re-check of a server-side filter — see resolve_milestone.""" if payload.get("pull_request"): return False if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id: return False names = {l.get("name", "") for l in payload.get("labels") or []} return all(l in names for l in labels) def list_issues(login, base, state="open", labels=(), query=None, milestone=None, limit=100): """Filtered issue payloads. Returns (payloads, milestone_title). One request per page, and the payload already carries the issue bodies — a whole milestone costs one call per 50 issues, not one per issue.""" ms_id, ms_title = (None, None) if milestone is not None: ms_id, ms_title = resolve_milestone(login, base, milestone) params = {"state": state, "type": "issues"} if labels: params["labels"] = ",".join(labels) if query: params["q"] = query if ms_title: params["milestones"] = ms_title endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params)) per_page = min(limit, 50) got = paginate(login, endpoint, limit=per_page, max_pages=max(1, -(-limit // per_page))) got = [p for p in got if matches(p, ms_id, labels)] return got[:limit], ms_title def get_issue(login, base, number): payload = api(login, "%s/issues/%d" % (base, number)) if not isinstance(payload, dict) or "number" not in payload: die("issue #%d not found" % number) return payload def get_comments(login, base, number): return paginate(login, "%s/issues/%d/comments" % (base, number)) def native_deps(login, base, number): """Gitea's own issue-dependency links; empty when unsupported.""" got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True) return [i["number"] for i in got] if isinstance(got, list) else [] def native_dep_pairs(login, base, number): """The same links as {(owner/repo, number)} — what a repeat push compares against so it does not POST a link the tracker already has. A bare number is ambiguous the moment a dependency lives in another repo, and IssueMeta lets it, so the repo travels with it. The pair is a transport fact; formatting it as `owner/repo#42` is map.py's job, not this module's.""" got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True) out = set() for i in got if isinstance(got, list) else []: repo = (i.get("repository") or {}).get("full_name") or "" if "number" in i: out.add((repo, int(i["number"]))) return out def add_dependency(login, base, number, dep_repo, dep_number, out_root=None): """Make issue `number` depend on `dep_repo#dep_number`. True on success. Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1): POST /repos/{owner}/{repo}/issues/{index}/dependencies body: IssueMeta — {"index": , "owner": "", "repo": ""} "Make the issue in the url depend on the issue in the form." The URL names the blocked issue and the body the blocker, which is the same direction native_deps reads back ("all issues that block this issue"). A link that already exists answers 409, so a failure here is reported and not fatal: one missing cross-link must not abort a push that has already created issues. Callers pre-filter with native_dep_pairs.""" owner, _, name = (dep_repo or "").partition("/") if not owner or not name: return False payload = {"index": int(dep_number), "owner": owner, "repo": name} got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload, payload_name="dep-%d-%d" % (number, dep_number), out_root=out_root, allow_fail=True) return got is not None # -------------------------------------------------------------------------- # labels # -------------------------------------------------------------------------- def ensure_labels(login, base, specs, root): """Map label name -> id, creating what the repo is missing. `specs` is {name: {"color", "description", "exclusive"}} handed in by the caller — this module does not know which namespaces are exclusive or what they mean. Cached in /.labels.json; the cache is refreshed from the API before anything is created.""" cache_path = os.path.join(root, ".labels.json") cache = {} if os.path.isfile(cache_path): try: with open(cache_path) as f: cache = json.load(f) except Exception: cache = {} if any(n not in cache for n in specs): cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)} for name, spec in specs.items(): if name in cache: continue payload = dict(spec, name=name) created = api(login, "%s/labels" % base, "POST", payload, payload_name="label-%s" % name.replace("/", "-"), out_root=root) if not created or "id" not in created: die("could not create label %r" % name) cache[name] = created["id"] sys.stderr.write("created label %s%s\n" % (name, " (exclusive)" if spec.get("exclusive") else "")) os.makedirs(root, exist_ok=True) with open(cache_path, "w") as f: json.dump(cache, f, indent=2, sort_keys=True) return {n: cache[n] for n in specs} def resolve_milestone_id(login, base, title): """Milestone id for a title, or None when the repo has no such milestone.""" if not title or title == "none": return None for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []: if m.get("title") == title: return m["id"] return None # -------------------------------------------------------------------------- # id map: remote key <-> local slug # -------------------------------------------------------------------------- def map_path(root): return os.path.join(root, REMOTE_MAP) def load_map(root): """{"owner/repo#42": "wire-sqlc-appclick"}""" p = map_path(root) if not os.path.isfile(p): return {} try: with open(p) as f: got = json.load(f) return got if isinstance(got, dict) else {} except Exception: return {} def save_map(root, m): os.makedirs(root, exist_ok=True) with open(map_path(root), "w") as f: json.dump(m, f, indent=2, sort_keys=True) def rebuild_map(root, issues): """Recover the id map from the `gitea:` fields on disk. The files are the source of truth; .remote.json is only an index over them.""" m = {} for id, iss in issues.items(): key = iss.extra.get("gitea") if key: m[key] = id save_map(root, m) return m