merge: keep request payloads out of the issue store
This commit was merged in pull request #23.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
|
||||
runs the real scripts inside it as subprocesses. That is the only way to test
|
||||
behavior that depends on where a script is run from, and it keeps the
|
||||
developer's own store out of the blast radius.
|
||||
**A test never touches `tmp/issues/`, `tmp/wiki/` or `tmp/payload/`.** Anything
|
||||
that needs a store builds a throwaway repository in a
|
||||
`tempfile.TemporaryDirectory()` — a `.git` marker, a copy of the script layers,
|
||||
fixture issues or artifacts — and runs the real scripts inside it as
|
||||
subprocesses. That is the only way to test behavior that depends on where a
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
@@ -396,6 +400,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
|
||||
|
||||
@@ -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,7 +34,6 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
PAYLOAD_DIR = ".payload"
|
||||
REMOTE_MAP = ".remote.json"
|
||||
|
||||
# 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.
|
||||
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):
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -87,19 +140,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]
|
||||
@@ -298,7 +353,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):
|
||||
@@ -317,8 +372,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
|
||||
|
||||
|
||||
@@ -350,7 +404,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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -194,7 +194,13 @@ class StoreTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-drop-")
|
||||
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),
|
||||
mock.patch.object(_gitea, "PAYLOAD_ROOT",
|
||||
os.path.join(self.root, "payload")),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||
mock.patch.object(push, "git_branch", lambda: "test-branch")):
|
||||
p.start()
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user