refactor: split issue domain from Gitea transport
An issue was a Gitea row that happened to be cached locally: its identity was the tracker's number (42.md), its dependencies were tracker numbers (depends: [#12]), and a local issue existed only as a draft that push deleted on success. Nothing could be planned or tracked without a tracker. Split into layers, with knowledge flowing one way: skills/issue DOMAIN what an issue is: format, validation, dep graph ^ offline; stdlib imports only, no subprocess | imports skills/sync BRIDGE map.py md <-> Gitea JSON, pure, no I/O _gitea.py login pin, api, pagination, filters skills/use REFERENCE tea CLI docs for non-issue entities skills/issue never imports skills/sync. Delete the sync layer and the domain keeps working. Identity is now a slug derived from the title (wire-sqlc-appclick.md) and is stable across retitles and pushes. Tracker numbers live in a `gitea:` field, never in a file name and never in `depends:`; the pair is indexed in .remote.json, which is a cache over the files, not a second source of truth. Behavior changes: - Pushing is additive. The file is never deleted; it gains gitea:/url:/ synced: and origin: flips from local to gitea. `origin: local` is a durable state, not a pending one. - Pushes go in topological order so dependencies get numbers first. - The dependency graph is computed offline from `depends:` metadata; body prose is passed through unchanged in both directions rather than being rewritten between slugs and #N. - `origin` is domain-owned (whether work exists elsewhere is a fact about the work); the handle and how to reach it stay with sync. Script moves: issue_get.py -> sync/pull.py issue_push.py -> sync/push.py issue_list.py -> sync/remote.py issue_index.py -> issue/issue_index.py _tea.py -> split into issue/issue.py, sync/map.py, sync/_gitea.py New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and sync/comment.py — comment posting was the last issue operation still hand-rolled through raw `tea api`. references/issue-format.md moves to skills/issue/references/format.md; label hex colors move out of it into map.py, since a color is how a tracker paints a chip, not what an issue is. Verified: offline path end to end (new, check, tree, index, push --dry-run) and read-only against Gitea (remote listing, pull with mapping, comment guard). Write paths of push.py and comment.py are not exercised here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
#!/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 <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 []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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
|
||||
Reference in New Issue
Block a user