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>
This commit is contained in:
naudachu
2026-08-10 16:38:16 +05:00
parent 257c547e22
commit e629d14585
14 changed files with 1404 additions and 116 deletions
+50 -6
View File
@@ -13,8 +13,11 @@ 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.
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
@@ -317,6 +320,22 @@ def resolve_milestone_id(login, base, title):
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
# --------------------------------------------------------------------------
@@ -326,7 +345,14 @@ def map_path(root):
def load_map(root):
"""{"owner/repo#42": "wire-sqlc-appclick"}"""
"""{"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 {}
@@ -345,9 +371,27 @@ def save_map(root, m):
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 = {}
"""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:
+105 -8
View File
@@ -15,11 +15,13 @@ What crosses the boundary, and what does not:
domain Gitea note
----------------------------------------------------------------------
id (slug) — local only; the tracker never sees it
id (slug) body marker `<!-- tea:id … -->`, first line of
the tracker-side body; stripped out
of the local copy — see below
title title verbatim, both ways
body body verbatim up, verbatim down except
checkbox state — see
merge_checkbox_state
the marker and checkbox state — see
with_id_marker / merge_checkbox_state
state state open/closed, same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
@@ -33,8 +35,14 @@ What crosses the boundary, and what does not:
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
never rewrites what the author wrote. Deliberate — a translator that edits
prose churns the body on every round trip.
The ONE thing this module does add to a body is the id marker, and it does so
because the slug now has to survive a push: `push.py` deletes the local file,
so the tracker has to remember what the issue was called here. See
`with_id_marker`.
"""
import os
import re
import sys
sys.path.insert(0, os.path.normpath(os.path.join(
@@ -97,6 +105,85 @@ def parse_remote_key(key):
return (repo, int(num)) if repo and num.isdigit() else (None, None)
# --------------------------------------------------------------------------
# the id marker: the slug, kept tracker-side
# --------------------------------------------------------------------------
# `push.py` deletes the local file once the tracker has confirmed the write, so
# the slug — the issue's ONLY identity in the domain — cannot live only on this
# machine any more. It rides up in the body as an HTML comment:
#
# <!-- tea:id wire-sqlc-appclick -->
#
# Why the body and not `.remote.json`: the map is a local file, and "the local
# copy is not the record" is the whole point of deleting it. A marker in the
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
# and a second machine — none of which the map does. Why an HTML comment: Gitea
# renders markdown, so it is invisible to a human reader, and it comes back
# verbatim on every API read.
#
# WHERE: the first line of the tracker-side body, followed by one blank line.
# First because it is the one position that does not depend on what sections the
# issue happens to have, and because a human who does look at the raw markdown
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
#
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
# next pull and goes back up with exactly one. There is no code path that adds
# a marker to a body that has not just been stripped.
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
def id_marker(id):
"""The marker line for a slug. One place formats it, one regex reads it."""
return "<!-- tea:id %s -->" % id
def id_in_body(body):
"""The slug a tracker-side body claims, or None.
The FIRST valid marker wins; a second one is ignored here and removed by
`strip_id_marker` on the way in. The captured text must be a slug by the
domain's own rule — a marker holding anything else is not a slug and is
treated as if it were not there, so a mangled comment falls back to the
title instead of naming a file after garbage."""
for line in (body or "").splitlines():
m = _MARKER_LINE.match(line)
if m and issue.SLUG_OK.match(m.group(1)):
return m.group(1)
return None
def strip_id_marker(body):
"""`body` with every marker line removed. Idempotent.
A body that carries no marker is returned byte for byte — the common case
(an issue filed in the web UI) costs nothing and is not reformatted. When a
marker is removed from the top, the blank line it was written with goes with
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
text = body or ""
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
return text
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
return "\n".join(kept).lstrip("\n")
def with_id_marker(body, id):
"""`body` with exactly one marker, as its first line.
Strip-then-prepend, always — that is the guarantee that a body can never end
up with two, however many it arrived with."""
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
# --------------------------------------------------------------------------
# Gitea -> domain
# --------------------------------------------------------------------------
@@ -158,8 +245,14 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non
`local_body` is the body of the copy already in the store, when there is
one. It contributes exactly one thing: its ticked checkboxes survive the
overwrite (merge_checkbox_state). Pass None and the remote body is taken
whole, which is what a first pull does."""
body = merge_checkbox_state((payload.get("body") or "").strip(), local_body)
whole, which is what a first pull does.
The id marker is stripped before anything else looks at the body: it is
transport bookkeeping, and the caller has already read the slug off it
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
lands on disk — sees the body the author wrote."""
body = merge_checkbox_state(
strip_id_marker((payload.get("body") or "").strip()), local_body)
id_for_number = id_for_number or {}
numbers = list(numbers_in_body(body))
@@ -220,9 +313,13 @@ def render_comments(comments):
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
"""Request body for POST /issues or PATCH /issues/{n}.
The body is sent verbatim — see the module docstring on why slugs in
`## Depends on` are not rewritten to `#N`."""
payload = {"title": iss.title, "body": iss.body.strip()}
The prose is sent verbatim — see the module docstring on why slugs in
`## Depends on` are not rewritten to `#N`. The one addition is the id
marker, prepended (never appended) so the tracker remembers the slug after
push has deleted the local file. `from_api` takes it straight back off, so
the body still round-trips byte for byte."""
payload = {"title": iss.title,
"body": with_id_marker(iss.body.strip(), iss.id)}
if label_ids is not None:
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
if iss.assignees:
+34 -9
View File
@@ -3,10 +3,15 @@
pull.py — Gitea issues -> the local store.
Writes flat markdown the domain layer owns and prints a compact index; the raw
API payload never reaches the conversation. An issue already in the store keeps
its slug even when its title changes on the server — identity is the local id,
matched through tmp/issues/.remote.json (and recoverable from the `gitea:`
fields if that file is lost).
API payload never reaches the conversation.
**This is how you get a pushed issue back.** `push.py` deletes the local file
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
it is how the copy comes to exist. It lands under the SAME slug it had before,
even after a rename in the web UI and even on a machine that has never seen the
issue: the slug travels in the body as `<!-- tea:id … -->`, and
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
are consulted in. The marker itself is stripped out of what is written to disk.
Two ways to name what to pull:
@@ -66,17 +71,37 @@ import map as gmap # noqa: E402
def id_for(payload, store_ids, remote_map, repo, root):
"""Existing slug for this remote issue, or a fresh unique one. A retitled
issue keeps the slug it was first pulled under — the map is by number."""
"""The slug this remote issue belongs under. Three sources, in order.
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
that knows about a file sitting on disk right now, so it wins. A
retitled issue keeps the slug it was first pulled under.
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
makes push -> delete -> pull a round trip rather than a rename: the
ledger can be lost (a fresh clone, another machine, a deleted
`.remote.json`) and the tracker still remembers what this issue is called
here — even after the title was changed in the web UI.
3. **The title, slugified.** Issues filed in the web UI have no marker and
have never had a local name; this is where they get one.
A marker is only taken at its word when the slug is free. If a file of that
name is already in the store, or the ledger has it under another number, the
marker is a collision and not an identity — the name is uniquified
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
if got:
return got
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
marked = gmap.id_in_body(payload.get("body") or "")
if marked and marked not in store_ids and marked not in set(remote_map.values()):
return marked
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
taken=store_ids)
def comments_path(root, id):
"""Where an issue's comment thread lives — beside it, under the same slug."""
return os.path.join(root, "%s.comments.md" % id)
"""Where an issue's comment thread lives — beside it, under the same slug.
Named in `_gitea` because push.py has to delete the same file."""
return _gitea.comments_path(root, id)
def sync_comments(login, base, root, id, number, count):
+164 -32
View File
@@ -1,16 +1,36 @@
#!/usr/bin/env python3
"""
push.py — local store -> Gitea.
push.py — local store -> Gitea, and the local copy goes away.
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.
**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
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,
@@ -44,9 +64,11 @@ 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.
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).
"""
@@ -85,27 +107,98 @@ def select(issues, ids, update):
return chosen
def dep_state(iss, issues, pushing):
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 that exists in the store:
Yields (slug, remote_key, in_run) per dependency this run can say anything
about:
remote_key the dependency's `gitea:` value, or None while it is local
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)
if dep is None:
continue # not in the store; validate() already warned
out.append((d, dep.extra.get("gitea") or None, d in pushing))
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
@@ -164,7 +257,9 @@ def main():
# ---- 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`.
# 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:
@@ -178,13 +273,16 @@ def main():
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):
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
if key:
print(" link -> %s (%s)" % (key, slug))
links += 1
@@ -207,13 +305,17 @@ def main():
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 pushing]
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)))
@@ -228,20 +330,32 @@ def main():
_gitea.warn("%s: milestone %r does not exist in %s — not set"
% (id, iss.milestone, repo))
number = gmap.number_of(iss)
if number:
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, number), "PATCH", payload,
payload_name="issue-%s" % id, out_root=root)
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"
if not isinstance(got, dict) or "number" not in got:
_gitea.die("%s: %s failed, unexpected response" % (id, verb))
number = got["number"]
# 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.
@@ -253,18 +367,26 @@ def main():
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())
issue.save(root, iss)
remote_map[gmap.remote_key(repo, number)] = id
# 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 — 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.
# 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) if 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:
@@ -274,7 +396,17 @@ def main():
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))
"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)