fix: keep request payloads out of the issue store

`labels.py` handed `_gitea.api` the issue store as a place to put the
request file, and on a checkout without a store that quietly created
`tmp/issues/.payload/`. Bootstrapping a repository's labels touches no
issue at all, so the one rule the store has — nothing materializes it as
a side effect of a write — was broken by an operation that has no
business knowing the store exists.

Where a request body goes was never the caller's decision to make. It is
now the transport's: `tmp/payload/`, resolved from `_gitea.py`'s own
location the way both domains resolve theirs, so every caller — sync and
wiki alike — writes to one directory whatever it was invoked from, and
`out_root` is gone from `api`, `add_dependency` and all six call sites.
The directory is created by the first write of a run and not before: a
`--dry-run` leaves nothing behind. `tmp/` is already gitignored.

The name carries the distinction the old path lost. A store holds the
only copy of something; this holds debris kept for a retry or a
post-mortem, and deleting it costs nothing. A dotdir sitting among an
issue's files claimed otherwise, and `ls tmp/issues` started lying about
what existed.

tests/test_payload_root.py runs the real `labels.py` in a throwaway repo
against a fake `tea` on PATH: no store appears, the payloads land in
tmp/payload/, a dry run writes nothing, and a run from a subdirectory
still resolves to the repo root. Two source checks keep the callers from
drifting apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 17:25:33 +05:00
parent 2ac301550e
commit 596cf853e8
11 changed files with 364 additions and 32 deletions
+11
View File
@@ -298,6 +298,10 @@ decision, not a migration. A color or `exclusive` that drifted is printed, and
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created.
Labels belong to the repository, not to any issue, so this one runs on a
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
and nothing creates it. The request bodies go to `tmp/payload/` (below).
A milestone must already exist in the repo — push attaches, it does not create.
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
@@ -354,6 +358,13 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
first and passed as `-d @file`, then kept for a retry or a look at what actually
went up. One gitignored directory for all of them, chosen by the transport and
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
and it is never `tmp/issues/` — a command that touches no issue must not leave
an issue store behind.
Comments and issues are wrapped by the scripts above. For **other** entities
(pulls, releases, PATCHing something these scripts do not cover), entity
subcommands like `tea pulls create` hang on a large or formatted body — an
+66 -12
View File
@@ -18,6 +18,13 @@ 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`.
Request bodies go to tmp/payload/, which is this module's own scratchpad and
NOT a store: nothing in it is anybody's only copy, and writing one must never
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
touches no issue at all — it used to leave a store behind anyway, because the
request file had nowhere else to live. One directory, every caller, resolved
from this file the way the two domains resolve theirs.
"""
import datetime
import json
@@ -27,9 +34,26 @@ import re
import sys
import urllib.parse
PAYLOAD_DIR = ".payload"
REMOTE_MAP = ".remote.json"
# --------------------------------------------------------------------------
# where request bodies land
# --------------------------------------------------------------------------
# Anchored on THIS FILE, like issue.store_root and page.store_root, so every
# caller — sync, wiki, whatever comes next — writes to one directory whatever
# it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
# inside somebody's store, because a scratchpad that looks like store contents
# is how this went wrong the first time. `tmp/` is already gitignored.
PAYLOAD_PARTS = ("tmp", "payload")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
@@ -44,6 +68,35 @@ def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def payload_root(start=None):
"""Absolute path of the request-body scratchpad.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
location stands — made absolute so an error can name the directory it
really wrote to."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *PAYLOAD_PARTS)
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
PAYLOAD_ROOT = payload_root()
# --------------------------------------------------------------------------
# login
# --------------------------------------------------------------------------
@@ -80,19 +133,21 @@ def require_login():
# --------------------------------------------------------------------------
def api(login, endpoint, method="GET", payload=None, payload_name=None,
out_root=None, allow_fail=False):
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."""
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
-d @file — the file survives the call for retries and debugging. Where
that is, is not the caller's business and never was: the directory is
this layer's scratchpad, and the one time it was a caller's decision it
got pointed at the issue store. 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"))
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
with open(path, "w") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
cmd += ["-d", "@" + path]
@@ -245,7 +300,7 @@ def native_dep_pairs(login, base, number):
return out
def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
def add_dependency(login, base, number, dep_repo, dep_number):
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
@@ -264,8 +319,7 @@ def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
return False
payload = {"index": int(dep_number), "owner": owner, "repo": name}
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
payload_name="dep-%d-%d" % (number, dep_number),
out_root=out_root, allow_fail=True)
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
return got is not None
@@ -297,7 +351,7 @@ def ensure_labels(login, base, specs, root):
continue
payload = dict(spec, name=name)
created = api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"), out_root=root)
payload_name="label-%s" % name.replace("/", "-"))
if not created or "id" not in created:
die("could not create label %r" % name)
cache[name] = created["id"]
+2 -4
View File
@@ -73,13 +73,11 @@ def main():
if args.edit:
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
{"body": body}, payload_name="comment-%d" % args.edit,
out_root=root)
{"body": body}, payload_name="comment-%d" % args.edit)
verb = "edited"
else:
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
{"body": body}, payload_name="comment-%s" % args.id,
out_root=root)
{"body": body}, payload_name="comment-%s" % args.id)
verb = "posted"
if not isinstance(got, dict) or "id" not in got:
_gitea.die("%s failed, unexpected response" % verb)
+7 -4
View File
@@ -32,6 +32,11 @@ created by push as they come up, and deleting or renaming anything at all.
Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
creates it — the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
@@ -185,8 +190,7 @@ def main():
continue
payload = dict(spec, name=name)
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"),
out_root=issue.ISSUE_ROOT)
payload_name="label-%s" % name.replace("/", "-"))
if not new or "id" not in new:
_gitea.die("could not create label %r" % name)
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
@@ -211,8 +215,7 @@ def main():
for field, _is, _want in drift:
patch[field] = spec[field]
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
payload_name="label-%s" % name.replace("/", "-"),
out_root=issue.ISSUE_ROOT)
payload_name="label-%s" % name.replace("/", "-"))
fixed += 1
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
+4 -4
View File
@@ -334,12 +334,12 @@ def main():
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)
payload, payload_name="issue-%s" % id)
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)
payload_name="issue-%s" % id)
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.
@@ -364,7 +364,7 @@ def main():
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)
payload_name="labels-%s" % id)
_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
@@ -392,7 +392,7 @@ def main():
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):
if _gitea.add_dependency(login, base, number, drepo, dnum):
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
else:
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
+2 -1
View File
@@ -11,7 +11,8 @@ ordering, paths, the index — belongs to `/tea:page` and is imported from there
never redefined here.
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login
pin, the same pagination, the same payload files. There is no second transport.
pin, the same pagination, the same payload files in the same `tmp/payload/`.
There is no second transport.
## The wiki is flat, and that is the whole design
+2 -3
View File
@@ -115,13 +115,12 @@ def main():
if verb == "create":
payload = wikimap.new_payload(title, text, a.message)
got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
payload=payload, payload_name="wiki-new",
out_root=space_dir)
payload=payload, payload_name="wiki-new")
else:
payload = wikimap.edit_payload(title, text, a.message)
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
method="PATCH", payload=payload,
payload_name="wiki-edit", out_root=space_dir)
payload_name="wiki-edit")
if not isinstance(got, dict) or not got.get("sub_url"):
_gitea.warn("%s: no page returned; the manifest is unchanged for it"