#!/usr/bin/env python3 """ push.py — local store -> Gitea, and the local copy goes away. **A successful push deletes `tmp/issues/.md` and `.comments.md`.** Once the tracker has the issue, the tracker IS the issue: what is left in the store is only what has not left this machine. Get it back with `pull.py ` — it comes back under the same slug, because the slug travelled up in the body as `` (map.with_id_marker) and is also recorded in `.remote.json`. That is the reversal of "pushing is additive, the file is never deleted"; it is deliberate, and AGENTS.md and references/format.md say so too. ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an issue that has just been sent is no more local than one that was just created. Two rules would put back exactly the question this removes — "is my copy the fresh one?". The deletion is the LAST thing that happens to an issue, and only after: 1. the api call returned (it did not raise, and `tea` exited 0), and 2. the answer is a dict carrying a plausible `number`, and on `--update` the very number that was PATCHed (`confirmed_number`), and 3. `.remote.json` has been written with number -> slug. Network down, non-2xx, a body that does not confirm the write, a mismatched number: the file stays and the run stops. Nothing here removes a file it has not just watched Gitea accept, and nothing removes a file for an issue it did not send — `origin: local` work that has never been pushed is never touched. 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, nothing deleted 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 goes up with the issue; one that is already set is sent as written and never overwritten. Detached HEAD, or no repo at all: no `ref` is sent and a warning says so. It is not written back to the file any more — there is no file to write it back to; it comes down with the next pull. 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 ledger_keys(remote_map, repo=None): """slug -> remote key, the reverse of `.remote.json`. Where a dependency's number comes from once push has deleted its file. The forward map is keyed by number because that is what a pull has in hand; a push has a slug, so it needs the other direction. Same-repo entries win if a slug somehow appears under two keys.""" out = {} for key, slug in sorted(remote_map.items()): if slug not in out or gmap.parse_remote_key(key)[0] == repo: out[slug] = key return out def dep_state(iss, issues, pushing, key_of_id=None): """What each `depends:` entry is, as far as linking is concerned. Yields (slug, remote_key, in_run) per dependency this run can say anything about: remote_key where the dependency lives in Gitea, or None while it is local-only in_run this push is about to give it one A dependency's key is read from its `gitea:` field when the file is still on disk, and from the ledger (`key_of_id`) when it is not — which, since push deletes what it sends, is the normal state of an already-published blocker. Without that fallback the graph would quietly lose an edge every time a blocker was pushed before its dependent: the file is gone, the field goes with it, and the link is never made. A slug that is neither in the store nor in the ledger is dropped; it names nothing this machine has ever seen, and validate() has already warned. 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.""" key_of_id = key_of_id or {} out = [] for d in iss.depends: dep = issues.get(d) key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d) if dep is None and not key: continue out.append((d, key or None, d in pushing)) return out def confirmed_number(got, sent_number=None): """The number Gitea confirmed for a write, or None — the deletion gate. Every local file this script removes is removed because this function returned an int, so it is written to be boring and to say no by default. An answer counts only when it is a dict carrying a positive integer `number`, and, when `sent_number` is given (a PATCH, where we already know which issue we addressed), the same number we sent. `bool` is rejected explicitly: `True` is an `int` in Python and `number: true` is not a confirmation of anything. What this does NOT have to catch, because it never gets here: a non-2xx answer or a `tea` that failed to run at all — `_gitea.api` exits on both, and an exception in the transport propagates. The file survives all three by never reaching the delete.""" if not isinstance(got, dict): return None n = got.get("number") if isinstance(n, bool) or not isinstance(n, int) or n <= 0: return None if sent_number is not None and n != sent_number: return None return n def drop_local(root, id): """Delete the local copy of an issue and its thread; return what went. Deliberately dumb: it takes an id, not a decision. Whether an issue may be dropped is decided by the caller, before this is reached, so the dangerous half of the operation has no branches in it at all. There is exactly one call site. A missing file is not an error — an issue with no comments has no thread.""" gone = [] for p in (issue.path_of(root, id), _gitea.comments_path(root, id)): if os.path.isfile(p): os.remove(p) gone.append(p) return gone 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`. The value is # set on the in-memory issue only; the file it came from is about to be # deleted, and the branch comes back with the next pull. 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 # The ledger costs no request, so a dry run resolves an already-pushed # blocker the same way the real run does. key_of_id = ledger_keys(_gitea.load_map(root), args.repo) 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, key_of_id): 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) key_of_id = ledger_keys(remote_map, repo) for id in order: iss = issues[id] # Local-only means "this machine has never sent it": no `gitea:` on the # file AND no entry in the ledger. A blocker whose file push already # dropped is in the ledger and is not one of these. unsynced = [d for d in iss.depends if d in issues and not issues[d].extra.get("gitea") and d not in key_of_id 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)) sent_number = gmap.number_of(iss) if sent_number: payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True) got = _gitea.api(login, "%s/issues/%d" % (base, sent_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" # The gate. Below this line the local file is going to be deleted, so # anything short of a confirmed write has to stop the run here. number = confirmed_number(got, sent_number) if number is None: _gitea.die("%s: %s failed — the tracker's answer does not confirm the " "write (%.200r). %s is untouched." % (id, verb, got, issue.path_of(root, id))) # The number is confirmed, so the ledger learns it now — before the # label fix-up below, which can still fail, and well before the file is # removed. `.remote.json` is what a later `pull.py N` uses to land on # this slug again; an interrupted run must cost a re-pull, not a slug. remote_map[gmap.remote_key(repo, number)] = id key_of_id[id] = gmap.remote_key(repo, number) _gitea.save_map(root, remote_map) # 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))) # The in-memory issue is stamped even though its file is going: the rest # of this loop reads `gitea:` off it to link dependencies, and a later # issue in topological order asks the same of this one. gmap.apply_remote(iss, got, repo, _gitea.now_iso()) # Where the issue lives now. The number and the URL lead because this # is the receipt: in a moment the local path is gone and this is the # only address the issue has. 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 — stamped on the in-memory issue # above, or read out of the ledger for one whose file an earlier push # already dropped. 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, key_of_id) 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 `pull.py %d` and push it again" % (id, number, drepo, dnum, slug, number)) # ---- and now the local copy goes ---------------------------------- # The last thing that happens to this issue, after the write, the # ledger, and the links. A failure above is a warning and lands here # anyway: the issue IS in Gitea, so keeping a stale file beside it # would put back exactly the two-copies question this removes. for p in drop_local(root, id): print(" dropped %s" % p) print(" pull.py %d to work on it again" % number) _gitea.save_map(root, remote_map) path, n = issue_index.build(root) print("index: %s — %d issue(s)" % (path, n)) if __name__ == "__main__": main()