diff --git a/AGENTS.md b/AGENTS.md index 7a42a2a..abd256f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) - `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here - `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/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`), entirely offline - `references/pages.md` — canonical page-tree format; single source of truth @@ -183,3 +185,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. - The `tea` CLI has no wiki subcommand. `tea api` is the only route, through `_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. diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 25110e1..130202e 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -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 `/tmp/payload/.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 diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py index 7d3783a..5325d1d 100644 --- a/skills/sync/scripts/_gitea.py +++ b/skills/sync/scripts/_gitea.py @@ -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 `` (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 /.payload/.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/.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"] diff --git a/skills/sync/scripts/comment.py b/skills/sync/scripts/comment.py index 62f4f50..c7758b9 100644 --- a/skills/sync/scripts/comment.py +++ b/skills/sync/scripts/comment.py @@ -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) diff --git a/skills/sync/scripts/labels.py b/skills/sync/scripts/labels.py index 6594e1b..9fe3c0d 100644 --- a/skills/sync/scripts/labels.py +++ b/skills/sync/scripts/labels.py @@ -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)) diff --git a/skills/sync/scripts/push.py b/skills/sync/scripts/push.py index 0a41a36..32288a7 100644 --- a/skills/sync/scripts/push.py +++ b/skills/sync/scripts/push.py @@ -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 " diff --git a/skills/wiki/SKILL.md b/skills/wiki/SKILL.md index 31addf4..c358af5 100644 --- a/skills/wiki/SKILL.md +++ b/skills/wiki/SKILL.md @@ -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 diff --git a/skills/wiki/scripts/wiki_push.py b/skills/wiki/scripts/wiki_push.py index 6fb085e..45fa280 100644 --- a/skills/wiki/scripts/wiki_push.py +++ b/skills/wiki/scripts/wiki_push.py @@ -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" diff --git a/tests/test_drop_after_push.py b/tests/test_drop_after_push.py index a65a1af..9038480 100644 --- a/tests/test_drop_after_push.py +++ b/tests/test_drop_after_push.py @@ -124,7 +124,7 @@ class FakeTracker(object): # -- the seam ---------------------------------------------------------- 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)) path = endpoint.split("?")[0] diff --git a/tests/test_payload_root.py b/tests/test_payload_root.py new file mode 100644 index 0000000..1d44134 --- /dev/null +++ b/tests/test_payload_root.py @@ -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() diff --git a/tests/test_push_dependencies.py b/tests/test_push_dependencies.py index 0c3eb04..9fc6de4 100644 --- a/tests/test_push_dependencies.py +++ b/tests/test_push_dependencies.py @@ -97,7 +97,7 @@ class FakeGitea(object): # -- the seam ---------------------------------------------------------- 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)) path = endpoint.split("?")[0] @@ -218,7 +218,7 @@ class AddDependencyTest(unittest.TestCase): return {"number": 102} 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) method, endpoint, payload = calls[0]