Files
marketplace/skills/sync/scripts/_gitea.py
T
naudachu e629d14585 feat: drop the local copy after a successful push
Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.

This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:

  - "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
    by origin:. An origin: local file is the only copy of the work; an
    origin: gitea file is a deletable working copy.
  - "Pushing is additive: the file is never deleted" — it is deleted.
  - "origin: local is a durable state" — complete, but not durable:
    pushing ends it.

Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.

Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.

.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.

Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.

tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:38:16 +05:00

401 lines
16 KiB
Python

#!/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, and the paths of the store-side files this layer writes. All of
it is transport bookkeeping, not domain data — the domain never reads any of
it, and losing the map still costs a re-pull and not information: the slug it
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
pull rebuilds the entry from the tracker. See `rebuild_map`.
"""
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 <out_root>/.payload/<name>.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": <int>, "owner": "<owner>", "repo": "<name>"}
"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 <root>/.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
# --------------------------------------------------------------------------
# store-side files this layer owns
# --------------------------------------------------------------------------
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
# layer puts beside it is named here, in one place, because three commands have
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
# deletes it along with the issue it just sent.
def comments_path(root, id):
"""An issue's comment thread — beside it, under the same slug.
A path, not a concept the domain needs: a thread is pulled from Gitea and
never pushed back, so the domain has no reason to know the file exists."""
return os.path.join(root, "%s.comments.md" % id)
# --------------------------------------------------------------------------
# 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"} — the local slug ledger.
Entries outlive the files they name, and that is now the normal case rather
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
and the entry it leaves behind is what lets the next `pull.py 42` land on
the same slug. Nothing prunes them, because "no file" no longer means "no
such issue". A stale entry costs one json line and is corrected the next
time that number is pulled."""
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):
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
This used to say "the files are the source of truth; .remote.json is only an
index over them", and that stopped being true the day push started deleting
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
read, so the files are now a SUBSET of what the map knows, and a rebuild
from them alone would throw away every entry it cannot see.
So the contradiction is resolved by moving the source of truth, not by
keeping this function honest about files:
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
.remote.json a local number -> slug ledger, a cache of that marker
tmp/issues/*.md whatever happens to be checked out right now
Which makes this a MERGE and never a replacement: it starts from what is
already recorded and adds what the remaining files say. What it cannot
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
not lost either; the next `pull.py <n>` reads the slug off the marker and
writes the entry back."""
m = load_map(root)
for id, iss in issues.items():
key = iss.extra.get("gitea")
if key:
m[key] = id
save_map(root, m)
return m