merge: keep request payloads out of the issue store

This commit was merged in pull request #23.
This commit is contained in:
2026-08-10 13:24:35 +00:00
11 changed files with 382 additions and 38 deletions
+32 -7
View File
@@ -64,8 +64,10 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`) - `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here - `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters, - `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
label ids, the remote-id map label ids, the remote-id map, `tmp/payload/`
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py` - `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
repository; reads the domain taxonomy, never the store
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`), - `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
entirely offline entirely offline
- `references/pages.md` — canonical page-tree format; single source of truth - `references/pages.md` — canonical page-tree format; single source of truth
@@ -104,12 +106,18 @@ stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
packages, so a test that needs the domain module imports it with packages, so a test that needs the domain module imports it with
`sys.path.insert`. `sys.path.insert`.
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a **A test never touches `tmp/issues/`, `tmp/wiki/` or `tmp/payload/`.** Anything
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a that needs a store builds a throwaway repository in a
`.git` marker, a copy of the script layers, fixture issues or artifacts — and `tempfile.TemporaryDirectory()` — a `.git` marker, a copy of the script layers,
runs the real scripts inside it as subprocesses. That is the only way to test fixture issues or artifacts — and runs the real scripts inside it as
behavior that depends on where a script is run from, and it keeps the subprocesses. That is the only way to test behavior that depends on where a
developer's own store out of the blast radius. script is run from, and it keeps the developer's own store out of the blast
radius.
`tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once,
from the module's own location: a test that stubs the transport *below* `api()`
— at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test
patches `PAYLOAD_ROOT` to its own temp directory too.
## Local issue store ## Local issue store
@@ -185,3 +193,20 @@ organized. Same stance as the issue store, resolved the same way from
disagree, because a page tree is worked on locally and an issue is not. disagree, because a page tree is worked on locally and an issue is not.
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through - The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
`_gitea.py`. `_gitea.py`.
## Request payloads
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
one file per named request, kept after the call for a retry or a post-mortem.
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
- One directory for every caller — sync and wiki both — resolved from
`_gitea.py`'s own location, so which command wrote a body does not change
where it landed. `_gitea.api` takes no directory argument; that it once did
is exactly how a label bootstrap came to create `tmp/issues/`.
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
or a run with nothing to send leaves no directory behind.
- **A scratchpad may never sit inside a store.** Store contents are the thing
being tracked; request bodies are debris of the transport. When the two share
a path, an operation that touches no issue at all still materializes the issue
store, and the operator's `ls tmp/issues` starts lying about what exists.
+11
View File
@@ -340,6 +340,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 changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created. `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. 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 `branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
@@ -396,6 +400,13 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else ## 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 Comments and issues are wrapped by the scripts above. For **other** entities
(pulls, releases, PATCHing something these scripts do not cover), entity (pulls, releases, PATCHing something these scripts do not cover), entity
subcommands like `tea pulls create` hang on a large or formatted body — an 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 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 records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
pull rebuilds the entry from the tracker. See `rebuild_map`. 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 datetime
import json import json
@@ -27,7 +34,6 @@ import re
import sys import sys
import urllib.parse import urllib.parse
PAYLOAD_DIR = ".payload"
REMOTE_MAP = ".remote.json" REMOTE_MAP = ".remote.json"
# How far past the ideal page count a `keep`-bounded listing may scan before it # How far past the ideal page count a `keep`-bounded listing may scan before it
@@ -37,6 +43,24 @@ REMOTE_MAP = ".remote.json"
# the whole tracker" on any repo whose filter matches mostly closed issues. # the whole tracker" on any repo whose filter matches mostly closed issues.
PAGE_SLACK = 4 PAGE_SLACK = 4
# --------------------------------------------------------------------------
# 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): def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg)) sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
@@ -51,6 +75,35 @@ def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 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 # login
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -87,19 +140,21 @@ def require_login():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def api(login, endpoint, method="GET", payload=None, payload_name=None, 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). """Call `tea api`; return parsed JSON (None on an empty body).
payload (a dict) is written to <out_root>/.payload/<name>.json and passed payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
as -d @file — the file survives the call for retries and debugging. -d @file — the file survives the call for retries and debugging. Where
allow_fail returns None instead of exiting when the call fails.""" 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] cmd = ["tea", "api", "--login", login]
if method != "GET": if method != "GET":
cmd += ["-X", method] cmd += ["-X", method]
if payload is not None: if payload is not None:
pdir = os.path.join(out_root or ".", PAYLOAD_DIR) os.makedirs(PAYLOAD_ROOT, exist_ok=True)
os.makedirs(pdir, exist_ok=True) path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
path = os.path.join(pdir, "%s.json" % (payload_name or "request"))
with open(path, "w") as f: with open(path, "w") as f:
json.dump(payload, f, ensure_ascii=False, indent=2) json.dump(payload, f, ensure_ascii=False, indent=2)
cmd += ["-d", "@" + path] cmd += ["-d", "@" + path]
@@ -298,7 +353,7 @@ def native_dep_pairs(login, base, number):
return out 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. """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): Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
@@ -317,8 +372,7 @@ def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
return False return False
payload = {"index": int(dep_number), "owner": owner, "repo": name} payload = {"index": int(dep_number), "owner": owner, "repo": name}
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload, got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
payload_name="dep-%d-%d" % (number, dep_number), payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
out_root=out_root, allow_fail=True)
return got is not None return got is not None
@@ -350,7 +404,7 @@ def ensure_labels(login, base, specs, root):
continue continue
payload = dict(spec, name=name) payload = dict(spec, name=name)
created = api(login, "%s/labels" % base, "POST", payload, 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: if not created or "id" not in created:
die("could not create label %r" % name) die("could not create label %r" % name)
cache[name] = created["id"] cache[name] = created["id"]
+2 -4
View File
@@ -73,13 +73,11 @@ def main():
if args.edit: if args.edit:
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH", got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
{"body": body}, payload_name="comment-%d" % args.edit, {"body": body}, payload_name="comment-%d" % args.edit)
out_root=root)
verb = "edited" verb = "edited"
else: else:
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST", got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
{"body": body}, payload_name="comment-%s" % args.id, {"body": body}, payload_name="comment-%s" % args.id)
out_root=root)
verb = "posted" verb = "posted"
if not isinstance(got, dict) or "id" not in got: if not isinstance(got, dict) or "id" not in got:
_gitea.die("%s failed, unexpected response" % verb) _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 Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written. 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). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
""" """
import argparse import argparse
@@ -185,8 +190,7 @@ def main():
continue continue
payload = dict(spec, name=name) payload = dict(spec, name=name)
new = _gitea.api(login, "%s/labels" % base, "POST", payload, new = _gitea.api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"), payload_name="label-%s" % name.replace("/", "-"))
out_root=issue.ISSUE_ROOT)
if not new or "id" not in new: if not new or "id" not in new:
_gitea.die("could not create label %r" % name) _gitea.die("could not create label %r" % name)
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark)) 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: for field, _is, _want in drift:
patch[field] = spec[field] patch[field] = spec[field]
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch, _gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
payload_name="label-%s" % name.replace("/", "-"), payload_name="label-%s" % name.replace("/", "-"))
out_root=issue.ISSUE_ROOT)
fixed += 1 fixed += 1
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown)) print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
+4 -4
View File
@@ -334,12 +334,12 @@ def main():
if sent_number: if sent_number:
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True) payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH", 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" verb = "updated"
else: else:
payload = gmap.to_payload(iss, label_ids, ms_id) payload = gmap.to_payload(iss, label_ids, ms_id)
got = _gitea.api(login, "%s/issues" % base, "POST", payload, got = _gitea.api(login, "%s/issues" % base, "POST", payload,
payload_name="issue-%s" % id, out_root=root) payload_name="issue-%s" % id)
verb = "created" verb = "created"
# The gate. Below this line the local file is going to be deleted, so # 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. # anything short of a confirmed write has to stop the run here.
@@ -364,7 +364,7 @@ def main():
if missing: if missing:
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT", _gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]}, {"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))) _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 # 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: for slug, (drepo, dnum) in wanted_links:
if not dnum or (drepo, dnum) in have: if not dnum or (drepo, dnum) in have:
continue 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)) print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
else: else:
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by " _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. never redefined here.
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login 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 ## The wiki is flat, and that is the whole design
+2 -3
View File
@@ -115,13 +115,12 @@ def main():
if verb == "create": if verb == "create":
payload = wikimap.new_payload(title, text, a.message) payload = wikimap.new_payload(title, text, a.message)
got = _gitea.api(login, "%s/wiki/new" % base, method="POST", got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
payload=payload, payload_name="wiki-new", payload=payload, payload_name="wiki-new")
out_root=space_dir)
else: else:
payload = wikimap.edit_payload(title, text, a.message) payload = wikimap.edit_payload(title, text, a.message)
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]), got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
method="PATCH", payload=payload, 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"): if not isinstance(got, dict) or not got.get("sub_url"):
_gitea.warn("%s: no page returned; the manifest is unchanged for it" _gitea.warn("%s: no page returned; the manifest is unchanged for it"
+7 -1
View File
@@ -124,7 +124,7 @@ class FakeTracker(object):
# -- the seam ---------------------------------------------------------- # -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None, def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False): payload_name=None, allow_fail=False):
self.calls.append((method, endpoint, payload)) self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0] path = endpoint.split("?")[0]
@@ -194,7 +194,13 @@ class StoreTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-drop-") self.root = tempfile.mkdtemp(prefix="tea-drop-")
self.fake = FakeTracker() self.fake = FakeTracker()
# PAYLOAD_ROOT is the repo's own tmp/payload, and a test that stubs the
# transport one layer down (see the non-2xx case) reaches the real
# write. Point it at the fixture: a test writes in its temp directory
# and nowhere else.
for p in (mock.patch.object(_gitea, "api", self.fake.api), for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "PAYLOAD_ROOT",
os.path.join(self.root, "payload")),
mock.patch.object(_gitea, "require_login", lambda: "test-login"), mock.patch.object(_gitea, "require_login", lambda: "test-login"),
mock.patch.object(push, "git_branch", lambda: "test-branch")): mock.patch.object(push, "git_branch", lambda: "test-branch")):
p.start() p.start()
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Where request bodies land, and that writing one never conjures a store.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything. The bug these tests pin down:
`labels.py --bootstrap` on a fresh checkout left `tmp/issues/.payload/` behind,
because the only place `_gitea.api` had to put a request file was whatever root
the caller handed it — and the label bootstrap, which touches no issue at all,
handed it the issue store. A store materialized as a side effect of an
operation that has nothing to do with issues.
Every run here is against a throwaway repository with a FAKE `tea` first on
PATH, so nothing reaches the network and the developer's own store is never in
the blast radius.
"""
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
sys.path.insert(0, SYNC_SCRIPTS)
sys.path.insert(0, ISSUE_SCRIPTS)
import _gitea # noqa: E402
import issue # noqa: E402
# A `tea` that answers without a network: an empty list for every GET (so the
# repository looks like it has no labels yet) and a created object for every
# write. It also records its own argv, which is how a test can tell that the
# payload file the script wrote is the one the call actually referenced.
FAKE_TEA = '''#!%s
import json, os, sys
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
f.write("\\t".join(sys.argv[1:]) + "\\n")
sys.stdout.write(json.dumps({"id": 1, "name": "created", "sub_url": "Page"})
if "-X" in sys.argv else "[]")
'''
class FakeRepo(object):
"""A self-contained repository with no store and no tmp/ at all."""
def __init__(self):
self._tmp = tempfile.TemporaryDirectory()
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
# own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
os.makedirs(self.path("sub", "deeper"))
# the login pin the transport insists on, local to this fixture
os.makedirs(self.path(".claude"))
with open(self.path(".claude", "settings.local.json"), "w") as f:
json.dump({"env": {"GITEA_LOGIN": "fixture/user"}}, f)
self.bin = self.path("fakebin")
os.makedirs(self.bin)
tea = os.path.join(self.bin, "tea")
with open(tea, "w") as f:
f.write(FAKE_TEA % sys.executable)
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def cleanup(self):
self._tmp.cleanup()
def path(self, *parts):
return os.path.join(self.root, *parts)
@property
def store(self):
return self.path("tmp", "issues")
@property
def payloads(self):
return self.path("tmp", "payload")
def script(self, layer, name):
return self.path("skills", layer, "scripts", name)
def run(self, script, *args, **kw):
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
env["PATH"] = self.bin + os.pathsep + env["PATH"]
env["TEA_CALL_LOG"] = self.root
p = subprocess.run([sys.executable, script] + list(args),
cwd=kw.pop("cwd", self.root), env=env,
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def calls(self):
p = os.path.join(self.root, "calls.txt")
if not os.path.isfile(p):
return []
with open(p) as f:
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
# --------------------------------------------------------------------------
# resolution
# --------------------------------------------------------------------------
class TestPayloadRoot(unittest.TestCase):
def test_root_is_absolute_and_repo_anchored(self):
self.assertTrue(os.path.isabs(_gitea.PAYLOAD_ROOT), _gitea.PAYLOAD_ROOT)
self.assertEqual(_gitea.PAYLOAD_ROOT, os.path.join(REPO, "tmp", "payload"))
def test_it_is_not_the_issue_store_and_not_inside_one(self):
"""The acceptance criterion, as a path fact: a request body is not
store content, so it may not live in a store or under one."""
self.assertNotEqual(_gitea.PAYLOAD_ROOT, issue.ISSUE_ROOT)
self.assertFalse(_gitea.PAYLOAD_ROOT.startswith(issue.ISSUE_ROOT + os.sep))
self.assertFalse(issue.ISSUE_ROOT.startswith(_gitea.PAYLOAD_ROOT + os.sep))
def test_the_name_says_what_it_holds(self):
"""Named so the distinction is visible: a top-level directory called
`payload`, not a dotdir hiding among an issue's files."""
self.assertEqual(os.path.basename(_gitea.PAYLOAD_ROOT), "payload")
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
def test_gitignore_covers_it(self):
with open(os.path.join(REPO, ".gitignore")) as f:
ignored = {line.strip() for line in f}
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
self.assertIn("tmp/", ignored,
"the payload directory is not covered by .gitignore")
def test_resolution_is_anchored_on_the_module_not_on_cwd(self):
repo = FakeRepo()
self.addCleanup(repo.cleanup)
self.assertEqual(_gitea.payload_root(repo.path("sub", "deeper")),
repo.payloads)
# --------------------------------------------------------------------------
# the bug: a label bootstrap that materialized the store
# --------------------------------------------------------------------------
class TestLabelsTouchesNoStore(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def bootstrap(self, *args, **kw):
rc, out, err = self.repo.run(self.repo.script("sync", "labels.py"),
"--repo", "fixture/repo", *args, **kw)
self.assertEqual(rc, 0, "labels.py failed:\n%s%s" % (out, err))
return out, err
def test_bootstrap_creates_no_store(self):
"""The reproduction from the report, run for real: no tmp/issues, and
no complaint about one either."""
out, _ = self.bootstrap()
self.assertIn("created", out)
self.assertFalse(os.path.exists(self.repo.store),
"labels.py created the issue store")
def test_bootstrap_writes_its_payloads_to_the_payload_root(self):
self.bootstrap()
self.assertTrue(os.path.isdir(self.repo.payloads),
"no payload directory: %s" % self.repo.payloads)
written = os.listdir(self.repo.payloads)
self.assertIn("label-type-bug.json", written)
for name in written:
self.assertTrue(name.startswith("label-"), name)
# and the file named on the command line is the one that was written
sent = [a[a.index("-d") + 1][1:] for a in self.repo.calls() if "-d" in a]
self.assertTrue(sent)
for path in sent:
self.assertEqual(os.path.dirname(path), self.repo.payloads)
self.assertTrue(os.path.isfile(path), path)
def test_the_payload_is_the_request_body(self):
self.bootstrap()
with open(os.path.join(self.repo.payloads, "label-type-bug.json")) as f:
body = json.load(f)
self.assertEqual(body.get("name"), "type/bug")
self.assertTrue(body.get("color"))
def test_a_dry_run_writes_nothing_at_all(self):
out, _ = self.bootstrap("--dry-run")
self.assertIn("nothing was written", out)
self.assertFalse(os.path.exists(self.repo.path("tmp")),
"a dry run left something behind in tmp/")
def test_the_directory_does_not_follow_cwd(self):
"""Run from a subdirectory: still one payload root, at the repo root.
A cwd-relative directory is how the store ended up with a second copy
of itself, and this one is resolved the same way to avoid the same
class of bug."""
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
self.assertTrue(os.path.isdir(self.repo.payloads))
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")))
self.assertFalse(os.path.exists(self.repo.store))
# --------------------------------------------------------------------------
# one place, every caller
# --------------------------------------------------------------------------
class TestOnePlaceForEveryCaller(unittest.TestCase):
def hits(self, needle, skip_transport=False):
"""Every `layer/script.py:line` mentioning `needle`."""
out = []
for d in (SYNC_SCRIPTS, WIKI_SCRIPTS):
layer = os.path.basename(os.path.dirname(d))
for name in sorted(os.listdir(d)):
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
continue
with open(os.path.join(d, name)) as f:
for n, line in enumerate(f, 1):
if needle in line:
out.append("%s/%s:%d" % (layer, name, n))
return out
def test_no_caller_chooses_where_its_payload_goes(self):
"""Whatever the answer is, it has to be the same for all of them —
payload files scattered across two stores and a wiki space is the
state this replaced."""
self.assertEqual(self.hits("out_root"), [],
"a caller still picks a payload directory of its own")
def test_only_the_transport_names_the_directory(self):
self.assertEqual(self.hits("PAYLOAD", skip_transport=True), [],
"the payload directory is named outside the transport")
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -97,7 +97,7 @@ class FakeGitea(object):
# -- the seam ---------------------------------------------------------- # -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None, def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False): payload_name=None, allow_fail=False):
self.calls.append((method, endpoint, payload)) self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0] path = endpoint.split("?")[0]
@@ -218,7 +218,7 @@ class AddDependencyTest(unittest.TestCase):
return {"number": 102} return {"number": 102}
with mock.patch.object(_gitea, "api", fake_api): with mock.patch.object(_gitea, "api", fake_api):
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None) ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
self.assertTrue(ok) self.assertTrue(ok)
method, endpoint, payload = calls[0] method, endpoint, payload = calls[0]