#!/usr/bin/env python3 """ push.py — local store -> Gitea. Pushing is additive. The local file is never deleted and never moves: it gains `gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`. One issue, two places it is visible — not two kinds of file. A local-only issue is a finished state, not a step on the way to a tracker. push.py every local-only issue, dependencies first push.py wire-sqlc-appclick one issue push.py --update PATCH issues that are already in Gitea push.py --dry-run validate only, no network Before anything is sent, each issue is validated against the canonical format by the domain layer (exactly one type/*, English title with no type prefix, `## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts anyway; say why when you use it. Dependencies are pushed in topological order so a parent is created after the issues it depends on. A dependency that is still local-only is reported, not silently dropped — the body's `## Depends on` prose is sent verbatim either way, so nothing is lost, but the tracker shows no edge for it. The graph goes up with them. Once an issue has its number, every `depends:` entry that also has one becomes a **native Gitea link** — the same `/dependencies` that `pull.py --deps` reads back, so the tracker shows the blocking panel and refuses to close a blocked issue first. Topological order means the blocker already has its number by then; no second pass is needed. `--update` links whatever appeared in `depends:` since the last push. A link the tracker already has is skipped, not re-POSTed. A dependency that stayed local has no number and becomes no link — only the warning above. REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted from `depends:` leaves its Gitea link standing, and nothing here will notice. Unlink it in the web UI, or by hand with `tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`. The `## Depends on` prose itself is never touched — slugs stay slugs and are not rewritten to `#N`, so the body survives a pull -> push round trip byte for byte. The link lives in Gitea's own graph, not in the text. Missing labels are created with the canonical color and, for type/* and severity/*, `exclusive: true` — `tea labels create` cannot set that field. `branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is filled with the current git branch and written back to the file; one that is already set is never touched. Detached HEAD, or no repo at all: no `ref` is sent and a warning says so. Login: the operator's pin from .claude/settings.local.json (see /tea:auth). """ import argparse import os import subprocess import sys _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))] import _gitea # noqa: E402 import issue # noqa: E402 import issue_index # noqa: E402 import map as gmap # noqa: E402 def select(issues, ids, update): """Which issues to send, and refuse the ambiguous combinations.""" if ids: missing = [i for i in ids if i not in issues] if missing: _gitea.die("no such issue(s) in the store: %s" % ", ".join(missing)) chosen = list(ids) else: chosen = sorted(i for i in issues if update or not issues[i].extra.get("gitea")) if not chosen: _gitea.die("nothing to push: every issue in the store is already in Gitea " "(use --update to PATCH them, or issue_new.py to make one)") if not update: already = [i for i in chosen if issues[i].extra.get("gitea")] if already: _gitea.die("already in Gitea: %s — pass --update to PATCH them" % ", ".join(already)) return chosen def dep_state(iss, issues, pushing): """What each `depends:` entry is, as far as linking is concerned. Yields (slug, remote_key, in_run) per dependency that exists in the store: remote_key the dependency's `gitea:` value, or None while it is local in_run this push is about to give it one In the real run remote_key is all that matters — topological order means an in-run blocker has already been stamped by the time its dependent is sent. `--dry-run` has no numbers to stamp, so it leans on in_run to say which links are coming and which cannot exist at all.""" out = [] for d in iss.depends: dep = issues.get(d) if dep is None: continue # not in the store; validate() already warned out.append((d, dep.extra.get("gitea") or None, d in pushing)) return out def git_branch(): """The branch HEAD is on, or None. The only git call these scripts make — read, never write. A detached HEAD prints `HEAD` and outside a repo git exits non-zero; both mean "no branch to name", which is not an error.""" try: r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True) except OSError: return None name = r.stdout.strip() if r.returncode != 0 or not name or name == "HEAD": return None return name def main(): ap = argparse.ArgumentParser(description="Push local issues to Gitea") ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)") ap.add_argument("--update", action="store_true", help="PATCH issues that already carry a gitea: field") ap.add_argument("--dry-run", action="store_true", help="validate only, no network") ap.add_argument("--force", action="store_true", help="push despite format violations") ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: /tmp/issues)") args = ap.parse_args() root = args.out problem = issue.store_error(root) if problem: _gitea.die("%s — create an issue with issue_new.py first" % problem) issues = issue.load_all(root) chosen = select(issues, args.ids, args.update) # ---- validate (domain layer, no network) ----------------------------- known = set(issues) blocked = False for id in chosen: err, warn = issue.validate(issues[id], known_ids=known) for w in warn: _gitea.warn("%s: %s" % (id, w)) for e in err: sys.stderr.write("%s: %s\n" % (id, e)) if err: blocked = True if blocked and not args.force: _gitea.die("format violations (see above); --force overrides") # ---- dependencies first ---------------------------------------------- edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen} order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)] for c in issue.find_cycles(edges): _gitea.warn("dependency cycle: %s" % " -> ".join(c)) # ---- branch: -> Gitea `ref` ------------------------------------------ # Only an empty field is filled: a branch written by hand is the author's # decision and push does not argue with it. Nothing to read (detached HEAD, # no repo) is not an error — the issue goes up without a `ref`. blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)] branch = git_branch() if blank else None if branch: for id in blank: issues[id].extra[gmap.BRANCH_KEY] = branch elif blank: _gitea.warn("no current git branch (detached HEAD, or outside a git repo) " "— no `ref` on: %s" % ", ".join(blank)) pushing = set(order) if args.dry_run: links = 0 for id in order: iss = issues[id] print("ok %s [type/%s] %s (%s)" % (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels")) # Not one request is made here: everything below is read off the # store. `#?` is a number this run has not handed out yet. for slug, key, in_run in dep_state(iss, issues, pushing): if key: print(" link -> %s (%s)" % (key, slug)) links += 1 elif in_run: print(" link -> #? (%s, created by this run)" % slug) links += 1 else: print(" no link: %s is local-only" % slug) print("%d issue(s) would be %s, %d dependency link(s) would be created" % (len(order), "updated" if args.update else "created", links)) return login = _gitea.require_login() base = _gitea.repo_base(args.repo) repo = _gitea.repo_slug(login, args.repo) wanted = sorted({l for id in order for l in issues[id].labels}) label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \ if wanted else {} milestone_ids = {} remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues) for id in order: iss = issues[id] unsynced = [d for d in iss.depends if d in issues and not issues[d].extra.get("gitea") and d not in pushing] if unsynced: _gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea" % (id, ", ".join(unsynced))) ms_id = None if iss.milestone: if iss.milestone not in milestone_ids: milestone_ids[iss.milestone] = _gitea.resolve_milestone_id( login, base, iss.milestone) ms_id = milestone_ids[iss.milestone] if ms_id is None: _gitea.warn("%s: milestone %r does not exist in %s — not set" % (id, iss.milestone, repo)) number = gmap.number_of(iss) if number: payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True) got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH", payload, payload_name="issue-%s" % id, out_root=root) verb = "updated" else: payload = gmap.to_payload(iss, label_ids, ms_id) got = _gitea.api(login, "%s/issues" % base, "POST", payload, payload_name="issue-%s" % id, out_root=root) verb = "created" if not isinstance(got, dict) or "number" not in got: _gitea.die("%s: %s failed, unexpected response" % (id, verb)) number = got["number"] # Gitea occasionally drops labels on create — re-apply rather than # trust the echo. applied = {l.get("name", "") for l in got.get("labels") or []} missing = [l for l in iss.labels if l in label_ids and l not in applied] if missing: _gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT", {"labels": [label_ids[l] for l in iss.labels if l in label_ids]}, payload_name="labels-%s" % id, out_root=root) _gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing))) gmap.apply_remote(iss, got, repo, _gitea.now_iso()) issue.save(root, iss) remote_map[gmap.remote_key(repo, number)] = id print("%s %s #%d %s" % (verb, id, number, got.get("html_url", ""))) # ---- the graph, as Gitea's own links ------------------------------ # Blockers came first in topological order, so each one that is going # to have a number has one already — the store was stamped in place. # The GET is the idempotence check: it costs one request per issue that # has dependencies at all, and it is what makes a repeat push a no-op. wanted_links = [(slug, gmap.parse_remote_key(key)) for slug, key, _ in dep_state(iss, issues, pushing) if key] if wanted_links: have = _gitea.native_dep_pairs(login, base, number) for slug, (drepo, dnum) in wanted_links: if not dnum or (drepo, dnum) in have: continue if _gitea.add_dependency(login, base, number, drepo, dnum, root): print(" depends on %s#%d (%s)" % (drepo, dnum, slug)) else: _gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by " "hand or re-run push" % (id, number, drepo, dnum, slug)) _gitea.save_map(root, remote_map) path, n = issue_index.build(root) print("index: %s — %d issue(s)" % (path, n)) if __name__ == "__main__": main()