a2e9a88186
`depends:` was filled and blockers were pulled only under `--deps`, so the plain `pull.py <n>` — the only way to get a pushed issue back — answered with a file whose graph was empty and an `issue_tree.py` that drew it as a root with no blockers. The edge was not lost, but it was not asked for, and it cannot be recovered locally: `map.from_api` writes slugs into the `## Depends on` prose and never `#N`, so Gitea's native graph is the only source there is. A pull now returns the unit of work — the issue and what blocks it. `--deps` stays accepted and does nothing, so existing calls and /tea:sync's tables keep working; `--no-deps` is the way out and spends no request on either half. The cost is accepted and stated rather than hidden. The native links are now fetched ONCE per issue instead of twice (they both fill `depends:` and steer the walk), and only for an issue that lands in the store — a closed one that filter mode drops no longer drags its blockers in behind it. That makes the number quotable, and pull.py's docstring quotes it: a milestone of 50 open issues costs one list request plus 50, where it used to cost one. In filter mode a blocker no filter selected still lands in the store and still sits outside `--limit`, deliberately, and both are documented; the exception is a closed blocker, dropped like any other closed issue with the edge to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
418 lines
19 KiB
Python
418 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
push.py — local store -> Gitea, and the local copy goes away.
|
|
|
|
**A successful push deletes `tmp/issues/<id>.md` and `<id>.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 <n>` — it
|
|
comes back under the same slug, because the slug travelled up in the body as
|
|
`<!-- tea:id … -->` (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 <id …> 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 every `pull.py` 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: <repo>/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()
|