refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Checkbox state survives a pull; everything else in the body does not.
|
||||
|
||||
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
|
||||
is pinned down with plain strings and no store anywhere. The pull tests then
|
||||
prove the rule is actually wired into the write path, with the transport
|
||||
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
|
||||
single function that shells out to `tea`. Nothing here touches a network, and
|
||||
no test may ever be made to.
|
||||
|
||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
|
||||
def body(*criteria, **kw):
|
||||
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
|
||||
summary = kw.get("summary", "Прозаическое описание.")
|
||||
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
|
||||
% (summary, "\n".join(criteria))).strip()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the rule itself — pure, no store, no tracker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MergeCheckboxStateTest(unittest.TestCase):
|
||||
"""`[x]` wins from whichever side has it, for a matching item text."""
|
||||
|
||||
def test_a_local_tick_survives_the_overwrite(self):
|
||||
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_remote_tick_is_kept(self):
|
||||
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
|
||||
body("- [ ] первое", "- [ ] второе"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_both_sides_ticked_is_still_ticked(self):
|
||||
one = body("- [x] первое")
|
||||
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
|
||||
|
||||
def test_the_union_is_taken_item_by_item(self):
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
|
||||
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
|
||||
|
||||
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
|
||||
"""Including its state — both states, in both directions."""
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [x] новое сверху", "- [ ] новое снизу"),
|
||||
body("- [x] что-то совсем другое"))
|
||||
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
|
||||
|
||||
def test_prose_is_not_merged(self):
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [ ] пункт", summary="Новый текст с сервера."),
|
||||
body("- [x] пункт", summary="Старый локальный текст."))
|
||||
self.assertIn("Новый текст с сервера.", got)
|
||||
self.assertNotIn("Старый локальный текст.", got)
|
||||
self.assertIn("- [x] пункт", got)
|
||||
|
||||
def test_a_heading_the_local_copy_added_is_gone(self):
|
||||
remote = body("- [x] пункт")
|
||||
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
|
||||
self.assertEqual(got, remote)
|
||||
|
||||
def test_no_local_copy_returns_the_server_body_untouched(self):
|
||||
remote = body("- [ ] пункт")
|
||||
for local in (None, "", " \n"):
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
|
||||
"local_body=%r rewrote the body" % local)
|
||||
|
||||
def test_nothing_ticked_locally_returns_the_same_object(self):
|
||||
remote = body("- [ ] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
|
||||
|
||||
def test_no_matching_item_returns_the_same_object(self):
|
||||
remote = body("- [ ] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
|
||||
|
||||
def test_a_body_with_no_checkboxes_at_all(self):
|
||||
remote = "## Summary\nодна проза\n"
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
|
||||
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
|
||||
|
||||
def test_exactly_one_character_changes(self):
|
||||
"""Ticking a box must not produce a diff wider than the state."""
|
||||
remote = body("- [ ] пункт", "- [ ] второй")
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
|
||||
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
|
||||
self.assertEqual(len(remote), len(got))
|
||||
self.assertEqual(len(diff), 1)
|
||||
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
|
||||
|
||||
def test_a_rewrapped_item_keeps_its_tick(self):
|
||||
"""`Checkbox.text` joins continuation lines with one space, which is
|
||||
the whole reason matching survives a reflow."""
|
||||
remote = body("- [ ] длинный пункт, который сервер\n"
|
||||
" перенёс на две строки")
|
||||
got = gmap.merge_checkbox_state(
|
||||
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
|
||||
self.assertIn("- [x] длинный пункт", got)
|
||||
|
||||
def test_a_reworded_item_does_not_keep_its_tick(self):
|
||||
"""Different text is a different item. The tick stays with the wording
|
||||
it was put on — this is a match, not a guess."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
|
||||
body("- [x] пункт про SQLC"))
|
||||
self.assertEqual(got, body("- [ ] пункт про sqlc"))
|
||||
|
||||
def test_the_marker_style_does_not_have_to_match(self):
|
||||
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
|
||||
the item is the same item however the two sides chose to render it."""
|
||||
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
|
||||
self.assertEqual(got, body("1. [x] пункт"))
|
||||
|
||||
def test_a_moved_item_keeps_its_tick(self):
|
||||
"""Matching is on text alone; the section is not part of the key. An
|
||||
item promoted out of `## Acceptance criteria` is the same item."""
|
||||
remote = "## Issues\n- [ ] пункт\n"
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||
self.assertEqual(got, "## Issues\n- [x] пункт\n")
|
||||
|
||||
def test_duplicate_text_is_read_as_a_set(self):
|
||||
"""The documented reading: one ticked local item ticks every remote
|
||||
line with that text. Pairing duplicates by order is the alternative,
|
||||
and it is the one that can still drop a tick."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
|
||||
body("- [ ] пункт", "- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
|
||||
|
||||
def test_duplicate_text_never_loses_the_second_tick(self):
|
||||
"""Two local lines, one remote: order-pairing would drop this tick."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
|
||||
body("- [ ] пункт", "- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_an_example_inside_a_code_fence_is_not_ticked(self):
|
||||
"""The domain parser skips fences whole, and so does the merge: a
|
||||
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
|
||||
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
|
||||
"## Acceptance criteria\n- [x] пункт"))
|
||||
self.assertIn("```md\n- [ ] пункт\n```", got)
|
||||
|
||||
def test_an_existing_capital_X_is_left_alone(self):
|
||||
remote = body("- [X] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
|
||||
|
||||
def test_a_capital_X_locally_still_counts_as_ticked(self):
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_unticking_in_the_web_does_not_survive(self):
|
||||
"""The accepted price, pinned so nobody 'fixes' it by accident:
|
||||
unticking is not monotone, so a box unticked upstream comes back.
|
||||
Untick locally and push."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_parsing_is_the_domain_layer_s(self):
|
||||
"""The acceptance criterion, asserted rather than eyeballed: the rule
|
||||
calls into skills/issue and defines no checkbox syntax of its own."""
|
||||
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
|
||||
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
|
||||
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||
self.assertTrue(cb.called)
|
||||
self.assertTrue(sc.called)
|
||||
|
||||
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
|
||||
"""The same criterion from the other side: the bracket markup itself
|
||||
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
|
||||
like is the domain's job, and there is only one copy of it."""
|
||||
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
|
||||
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
|
||||
with open(os.path.join(sync, name)) as f:
|
||||
code = f.read().split('"""')[0::2] # docstrings dropped
|
||||
for chunk in code:
|
||||
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
|
||||
self.assertNotIn(markup, chunk,
|
||||
"%s spells out %r" % (name, markup))
|
||||
|
||||
|
||||
class FromApiTest(unittest.TestCase):
|
||||
"""The seam between the rule and the translation."""
|
||||
|
||||
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
|
||||
"body": body("- [ ] пункт")}
|
||||
|
||||
def test_local_body_is_optional_and_defaults_to_no_merge(self):
|
||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
|
||||
self.assertEqual(iss.body, body("- [ ] пункт"))
|
||||
|
||||
def test_local_body_contributes_its_ticks(self):
|
||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
|
||||
local_body=body("- [x] пункт"))
|
||||
self.assertEqual(iss.body, body("- [x] пункт"))
|
||||
|
||||
def test_an_empty_remote_body_does_not_crash(self):
|
||||
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
|
||||
"an-issue", REPO, local_body=body("- [x] пункт"))
|
||||
self.assertEqual(iss.body, "")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# pull.py — the rule wired into the write path
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeGitea(object):
|
||||
"""A `tea api` that answers issues from memory and remembers the calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.issues = {}
|
||||
|
||||
def add(self, number, title, text, **kw):
|
||||
p = {"number": number, "title": title, "body": text, "state": "open",
|
||||
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||
"updated_at": "2026-08-10T00:00:00Z"}
|
||||
p.update(kw)
|
||||
self.issues[number] = p
|
||||
return p
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None, **kw):
|
||||
self.calls.append((method, endpoint))
|
||||
path, _, query = endpoint.partition("?")
|
||||
params = dict(urllib.parse.parse_qsl(query))
|
||||
|
||||
# Every pull asks for an issue's native links now (dependencies are the
|
||||
# default). Nothing here has any; the answer just has to exist.
|
||||
if path.endswith("/dependencies"):
|
||||
return []
|
||||
|
||||
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
||||
if m and method == "GET":
|
||||
return self.issues.get(int(m.group(1)))
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
if int(params.get("page", 1)) > 1:
|
||||
return []
|
||||
return [self.issues[n] for n in sorted(self.issues)]
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullTestCase(unittest.TestCase):
|
||||
"""A temp store and a fake transport."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||
self.fake = FakeGitea()
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
def write_local(self, id, text, number=42):
|
||||
issue.save(self.root, issue.Issue(
|
||||
id=id, title="An issue", body=text, labels=["type/task"],
|
||||
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
|
||||
"url": "https://git.example/x", "synced": "old"}))
|
||||
|
||||
def run_pull(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
pull.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def stored_body(self, id):
|
||||
return issue.load(self.root, id).body
|
||||
|
||||
def raw(self, id):
|
||||
"""The file on disk, byte for byte — metadata included."""
|
||||
with open(issue.path_of(self.root, id)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class PullMergesTicksTest(PullTestCase):
|
||||
|
||||
def test_a_tick_made_locally_survives_the_pull(self):
|
||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_tick_made_in_the_web_lands_locally(self):
|
||||
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_the_rest_of_the_body_is_still_overwritten(self):
|
||||
self.write_local("an-issue", body("- [x] первое",
|
||||
summary="Локальная правка прозы."))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
|
||||
summary="Серверная проза."))
|
||||
self.run_pull("42")
|
||||
got = self.stored_body("an-issue")
|
||||
self.assertIn("Серверная проза.", got)
|
||||
self.assertNotIn("Локальная правка прозы.", got)
|
||||
self.assertIn("- [x] первое", got)
|
||||
self.assertIn("- [ ] новое с сервера", got)
|
||||
|
||||
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
|
||||
self.write_local("an-issue", body("- [ ] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [ ] первое", "- [x] новое с сервера"))
|
||||
|
||||
def test_filter_mode_merges_too(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||
self.run_pull("--label", "type/task")
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||
|
||||
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
|
||||
"""The merge hangs off the local id, which is resolved from the number
|
||||
— a title change must not orphan the ticks."""
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "Completely different title", body("- [ ] первое"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||
self.assertEqual(issue.load(self.root, "an-issue").title,
|
||||
"Completely different title")
|
||||
|
||||
|
||||
class PullIntoAnEmptyStoreTest(PullTestCase):
|
||||
|
||||
def test_no_local_file_writes_the_server_body_unchanged(self):
|
||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
|
||||
shutil.rmtree(self.root)
|
||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||
_out, err = self.run_pull("42")
|
||||
self.assertIn("created store", err)
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
|
||||
|
||||
|
||||
class CachedIsUnchangedTest(PullTestCase):
|
||||
|
||||
def test_a_skipped_issue_is_neither_read_nor_merged(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
before = self.raw("an-issue")
|
||||
|
||||
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
|
||||
out, _ = self.run_pull("42", "--cached")
|
||||
|
||||
merge.assert_not_called()
|
||||
self.assertIn("(cached)", out)
|
||||
self.assertEqual(self.raw("an-issue"), before)
|
||||
|
||||
def test_without_cached_the_same_issue_is_merged(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
|
||||
class RoundTripTest(PullTestCase):
|
||||
"""Pull twice with no change in between: the second is a no-op."""
|
||||
|
||||
def test_a_repeat_pull_does_not_churn_the_file(self):
|
||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
first = self.raw("an-issue")
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.raw("an-issue"), first)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Checkbox parsing, ticking, and the INDEX progress column.
|
||||
|
||||
Plain stdlib unittest — the scripts under test are stdlib-only by the layering
|
||||
rule, and their tests have no business dragging in a dependency the code they
|
||||
cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not
|
||||
packages, so they go on sys.path the same way the scripts do it to each other.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Nothing here touches tmp/, the network, or the real store: every case builds
|
||||
its own store in a TemporaryDirectory.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts"))
|
||||
|
||||
import issue # noqa: E402
|
||||
import issue_ac # noqa: E402
|
||||
import issue_check # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
# Boxes in two different sections, a wrapped item, a fenced example, and a
|
||||
# plain list item that is not a checkbox at all. Line numbers are 1-based:
|
||||
# the items sit on lines 8, 9, 12, 14 and 15.
|
||||
BODY = """## Summary
|
||||
Что-то про задачу.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Issues
|
||||
- [x] wire-sqlc-appclick — первая часть
|
||||
- [ ] add-pool-cfg — вторая часть
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] в `issue.py` есть функция разбора чекбоксов тела:
|
||||
возвращает пункты с номером строки, состоянием и текстом
|
||||
- [X] чекбоксы ищутся по всему телу
|
||||
- [ ] пример в блоке кода не считается пунктом:
|
||||
|
||||
```markdown
|
||||
- [ ] это разметка из шаблона, а не галочка
|
||||
- [x] и эта тоже
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- не входит в объём: доставка тела в трекер
|
||||
"""
|
||||
|
||||
NO_BOXES = """## Summary
|
||||
Тело без единой галочки.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Notes
|
||||
- обычный пункт списка
|
||||
- ещё один
|
||||
"""
|
||||
|
||||
# Metadata deliberately out of canonical order and missing optional keys, one
|
||||
# item with trailing whitespace: a round-trip through Issue.to_text() would
|
||||
# rewrite all of that, so this fixture catches a ticking path that re-renders
|
||||
# the file instead of patching one character of it.
|
||||
MESSY = """---
|
||||
origin: local
|
||||
labels: [type/task]
|
||||
id: messy-issue
|
||||
state: open
|
||||
---
|
||||
# Messy but valid
|
||||
|
||||
## Summary
|
||||
Тело, которое нельзя перерисовывать.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] первый пункт
|
||||
- [ ] второй пункт
|
||||
- [ ] третий пункт
|
||||
"""
|
||||
|
||||
TASK_BODY = """## Summary
|
||||
Что нужно сделать.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Зачем это нужно.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] ничего ещё не сделано
|
||||
- [ ] и это тоже не сделано
|
||||
"""
|
||||
|
||||
|
||||
def sole_difference(before, after):
|
||||
"""The single character position at which the two strings differ.
|
||||
|
||||
Raises AssertionError when they differ in length or in more than one
|
||||
place — the whole claim of `set_checkbox` is that this never happens."""
|
||||
assert len(before) == len(after), (
|
||||
"length changed: %d -> %d" % (len(before), len(after)))
|
||||
diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
|
||||
assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff)
|
||||
return diff[0]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def store(**files):
|
||||
"""A throwaway issue store: {id: file text}."""
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
for id, text in files.items():
|
||||
with open(os.path.join(root, "%s.md" % id), "w", newline="") as f:
|
||||
f.write(text)
|
||||
yield root
|
||||
|
||||
|
||||
def run(fn, *argv):
|
||||
"""Call a script entry point, returning (exit code or None, stdout)."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = fn(list(argv))
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
class TestParse(unittest.TestCase):
|
||||
|
||||
def test_finds_every_box_in_every_section(self):
|
||||
items = issue.checkboxes(BODY)
|
||||
self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5])
|
||||
self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15])
|
||||
self.assertEqual([c.checked for c in items],
|
||||
[True, False, False, True, False])
|
||||
self.assertEqual([c.section for c in items],
|
||||
["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3)
|
||||
|
||||
def test_boxes_outside_acceptance_criteria_are_items_too(self):
|
||||
# A type/feature keeps its children under `## Issues`; binding the
|
||||
# parser to one heading would lose them.
|
||||
under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"]
|
||||
self.assertEqual(len(under_issues), 2)
|
||||
self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick"))
|
||||
|
||||
def test_continuation_line_is_part_of_the_item(self):
|
||||
item = issue.checkboxes(BODY)[2]
|
||||
self.assertEqual((item.line, item.end_line), (12, 13))
|
||||
self.assertEqual(
|
||||
item.text,
|
||||
"в `issue.py` есть функция разбора чекбоксов тела: "
|
||||
"возвращает пункты с номером строки, состоянием и текстом")
|
||||
|
||||
def test_fenced_example_is_not_an_item(self):
|
||||
texts = [c.text for c in issue.checkboxes(BODY)]
|
||||
self.assertNotIn("это разметка из шаблона, а не галочка", texts)
|
||||
self.assertEqual(len(texts), 5)
|
||||
|
||||
def test_plain_list_item_is_not_a_checkbox(self):
|
||||
self.assertNotIn("не входит в объём: доставка тела в трекер",
|
||||
[c.text for c in issue.checkboxes(BODY)])
|
||||
|
||||
def test_markers_and_nesting(self):
|
||||
text = ("* [ ] star\n"
|
||||
"+ [x] plus\n"
|
||||
"1. [ ] ordered\n"
|
||||
"2) [x] ordered too\n"
|
||||
" - [ ] nested\n"
|
||||
"- [x]no space, not an item\n")
|
||||
items = issue.checkboxes(text)
|
||||
self.assertEqual([c.text for c in items],
|
||||
["star", "plus", "ordered", "ordered too", "nested"])
|
||||
self.assertEqual([c.checked for c in items],
|
||||
[False, True, False, True, False])
|
||||
|
||||
def test_empty_text(self):
|
||||
self.assertEqual(issue.checkboxes(""), [])
|
||||
self.assertEqual(issue.checkboxes(None), [])
|
||||
|
||||
def test_line_numbers_are_relative_to_the_text_given(self):
|
||||
# Same body, prefixed with a metadata block: the offsets move with it,
|
||||
# which is what lets issue_ac.py work on a whole file.
|
||||
head = "---\nid: x\nstate: open\n---\n# Title\n\n"
|
||||
shift = head.count("\n")
|
||||
self.assertEqual([c.line for c in issue.checkboxes(head + BODY)],
|
||||
[c.line + shift for c in issue.checkboxes(BODY)])
|
||||
|
||||
def test_progress(self):
|
||||
self.assertEqual(issue.checkbox_progress(BODY), (2, 5))
|
||||
self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0))
|
||||
|
||||
|
||||
class TestToggle(unittest.TestCase):
|
||||
|
||||
def test_ticking_changes_exactly_one_character(self):
|
||||
item = issue.checkboxes(BODY)[1] # line 9, unchecked
|
||||
after = issue.set_checkbox(BODY, item, True)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual(BODY[at], " ")
|
||||
self.assertEqual(after[at], "x")
|
||||
self.assertEqual(issue.checkbox_progress(after), (3, 5))
|
||||
|
||||
def test_unticking_changes_exactly_one_character(self):
|
||||
item = issue.checkboxes(BODY)[0] # line 8, checked
|
||||
after = issue.set_checkbox(BODY, item, False)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual((BODY[at], after[at]), ("x", " "))
|
||||
|
||||
def test_every_item_toggles_in_isolation(self):
|
||||
for item in issue.checkboxes(BODY):
|
||||
after = issue.set_checkbox(BODY, item, not item.checked)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual(after.splitlines()[item.line - 1].count("["), 1)
|
||||
self.assertLess(at, len(BODY))
|
||||
|
||||
def test_no_op_when_already_in_that_state(self):
|
||||
items = issue.checkboxes(BODY)
|
||||
self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY)
|
||||
self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY)
|
||||
|
||||
def test_capital_x_is_left_alone(self):
|
||||
item = issue.checkboxes(BODY)[3] # `- [X]`
|
||||
self.assertEqual(issue.set_checkbox(BODY, item, True), BODY)
|
||||
|
||||
def test_accepts_a_line_number(self):
|
||||
after = issue.set_checkbox(BODY, 9, True)
|
||||
self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True))
|
||||
|
||||
def test_refuses_a_line_that_is_not_a_checkbox(self):
|
||||
with self.assertRaises(ValueError):
|
||||
issue.set_checkbox(BODY, 1, True)
|
||||
with self.assertRaises(ValueError):
|
||||
issue.set_checkbox(BODY, 9999, True)
|
||||
|
||||
|
||||
class TestScript(unittest.TestCase):
|
||||
|
||||
def test_lists_items_numbered_with_state(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--out", root)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("messy-issue — 0/3", out)
|
||||
self.assertIn("## Acceptance criteria", out)
|
||||
self.assertIn(" 1 [ ] первый пункт", out)
|
||||
self.assertIn(" 3 [ ] третий пункт", out)
|
||||
|
||||
def test_check_by_number(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("checked", out)
|
||||
self.assertIn("1/3", out)
|
||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||
|
||||
def test_check_by_substring(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertTrue(issue.checkboxes(after)[2].checked)
|
||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||
|
||||
def test_uncheck(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("unchecked", out)
|
||||
self.assertEqual(after, MESSY)
|
||||
|
||||
def test_toggling_through_the_script_changes_one_character_of_the_file(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
path = os.path.join(root, "messy-issue.md")
|
||||
with open(path) as f:
|
||||
before = f.read()
|
||||
run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root)
|
||||
with open(path) as f:
|
||||
after = f.read()
|
||||
at = sole_difference(before, after)
|
||||
self.assertEqual((before[at], after[at]), (" ", "x"))
|
||||
# The metadata block was neither reordered nor completed, and the
|
||||
# trailing whitespace on the third item survived.
|
||||
self.assertTrue(after.startswith("---\norigin: local\n"))
|
||||
self.assertIn("- [ ] третий пункт \n", after)
|
||||
|
||||
def test_crlf_line_endings_survive(self):
|
||||
crlf = MESSY.replace("\n", "\r\n")
|
||||
with store(**{"messy-issue": crlf}) as root:
|
||||
path = os.path.join(root, "messy-issue.md")
|
||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||
with open(path, newline="") as f:
|
||||
after = f.read()
|
||||
at = sole_difference(crlf, after)
|
||||
self.assertEqual((crlf[at], after[at]), (" ", "x"))
|
||||
self.assertEqual(after.count("\r\n"), crlf.count("\r\n"))
|
||||
|
||||
def test_ambiguous_substring_is_an_error_listing_the_matches(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
self.assertEqual(f.read(), MESSY) # nothing was picked
|
||||
msg = str(cm.exception)
|
||||
self.assertIn("matches 3 items", msg)
|
||||
for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"):
|
||||
self.assertIn(want, msg)
|
||||
|
||||
def test_substring_that_matches_nothing(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root)
|
||||
self.assertIn("nothing matches", str(cm.exception))
|
||||
|
||||
def test_number_out_of_range(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "9", "--out", root)
|
||||
self.assertIn("no item 9 — the issue has 3", str(cm.exception))
|
||||
|
||||
def test_issue_without_checkboxes(self):
|
||||
with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root:
|
||||
rc, out = run(issue_ac.main, "plain", "--out", root)
|
||||
self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes"))
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "plain", "--check", "1", "--out", root)
|
||||
self.assertIn("has no checkboxes", str(cm.exception))
|
||||
|
||||
def test_unknown_id(self):
|
||||
with store() as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "nope", "--out", root)
|
||||
self.assertIn("no issue 'nope'", str(cm.exception))
|
||||
|
||||
|
||||
class TestIndexProgress(unittest.TestCase):
|
||||
|
||||
def files(self):
|
||||
boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n"
|
||||
"origin: local\n---\n# Boxed\n\n" + BODY)
|
||||
plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n"
|
||||
"origin: local\n---\n# Plain\n\n" + NO_BOXES)
|
||||
return {"boxed": boxed, "plain": plain}
|
||||
|
||||
def index(self, root):
|
||||
issue_index.build(root)
|
||||
with open(os.path.join(root, "INDEX.md")) as f:
|
||||
return f.read()
|
||||
|
||||
def row(self, text, id):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("| [%s]" % id):
|
||||
return [c.strip() for c in line.split("|")]
|
||||
self.fail("no row for %r in INDEX.md" % id)
|
||||
|
||||
def test_column_exists_and_counts_the_body(self):
|
||||
with store(**self.files()) as root:
|
||||
text = self.index(root)
|
||||
self.assertIn("| id | state | progress | type |", text)
|
||||
self.assertEqual(self.row(text, "boxed")[3], "2/5")
|
||||
|
||||
def test_blank_for_an_issue_without_checkboxes(self):
|
||||
with store(**self.files()) as root:
|
||||
text = self.index(root)
|
||||
self.assertEqual(self.row(text, "plain")[3], "")
|
||||
|
||||
def test_recomputed_on_the_fly_not_stored(self):
|
||||
with store(**self.files()) as root:
|
||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5")
|
||||
run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root)
|
||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5")
|
||||
# No metadata field anywhere holds it.
|
||||
with open(os.path.join(root, "boxed.md")) as f:
|
||||
head = f.read().split("---")[1]
|
||||
self.assertNotIn("3/5", head)
|
||||
self.assertNotIn("progress", head)
|
||||
|
||||
|
||||
class TestCheckIgnoresUntickedBoxes(unittest.TestCase):
|
||||
"""An unticked box is work not done yet, not a malformed issue."""
|
||||
|
||||
def test_validate_reports_nothing(self):
|
||||
iss = issue.Issue(id="unticked-issue", title="Do the thing",
|
||||
labels=["type/task"], body=TASK_BODY)
|
||||
err, warn = issue.validate(iss, known_ids={"unticked-issue"})
|
||||
self.assertEqual(err, [])
|
||||
self.assertEqual(warn, [])
|
||||
|
||||
def test_issue_check_exits_clean(self):
|
||||
text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n"
|
||||
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n"
|
||||
"---\n# Do the thing\n\n" + TASK_BODY)
|
||||
argv = sys.argv
|
||||
with store(**{"unticked-issue": text}) as root:
|
||||
sys.argv = ["issue_check.py", "--out", root]
|
||||
try:
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = issue_check.main()
|
||||
finally:
|
||||
sys.argv = argv
|
||||
out = buf.getvalue()
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("ok unticked-issue", out)
|
||||
self.assertNotIn("ERROR", out)
|
||||
self.assertNotIn("warn ", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,641 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
close.py — the state changes in Gitea, and the local file follows it or nothing
|
||||
happens at all.
|
||||
|
||||
Two halves, and the second is the one that matters:
|
||||
|
||||
1. **It closes.** A slug, a number, several of either in one run, and
|
||||
`--reopen` going the other way. What goes out is a PATCH carrying `state`
|
||||
and nothing else; what comes back is written into `state:` on the local
|
||||
file, and the index is rebuilt so the store's own table agrees.
|
||||
|
||||
2. **It changes nothing local unless the tracker confirmed it.** A `tea` that
|
||||
exited non-zero, an answer with no number, an answer for another issue, an
|
||||
answer that still says `open`, an `origin: local` issue, a `--dry-run`: in
|
||||
every one of those the file on disk is byte for byte what it was. A bug here
|
||||
makes the store lie about the tracker, so each path is asserted on its own.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as `test_drop_after_push.py` does,
|
||||
with the same deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||
and lets the real `_gitea.api` run, so "tea exited 1" is proved end to end.
|
||||
|
||||
Nothing here touches a network, and nothing here touches the developer's store:
|
||||
every test builds its own in a `tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import close # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, for state writes only.
|
||||
|
||||
It keeps a `state` per number and flips it on a PATCH, which is the whole
|
||||
contract close.py has with the far side."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.states = {} # number -> "open" / "closed"
|
||||
self.raise_on_write = None # an exception instance to raise
|
||||
self.answer_override = None # what a write answers instead
|
||||
|
||||
def payload_of(self, number):
|
||||
return {"number": number, "state": self.states[number],
|
||||
"title": "A thing", "updated_at": "2026-08-11T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number)}
|
||||
|
||||
def writes(self):
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
if self.raise_on_write is not None:
|
||||
raise self.raise_on_write
|
||||
self.states.setdefault(number, "open")
|
||||
if "state" in (payload or {}):
|
||||
self.states[number] = payload["state"]
|
||||
if self.answer_override is not None:
|
||||
return self.answer_override
|
||||
return self.payload_of(number)
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
n = int(path.rsplit("/", 1)[1])
|
||||
return self.payload_of(n) if n in self.states else None
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store and a fake tracker."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory(prefix="tea-close-")
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = tmp.name
|
||||
self.fake = FakeTracker()
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def synced(self, id="a-thing", number=101, state="open"):
|
||||
"""An issue that is in the tracker and on disk, the way a pull leaves
|
||||
it: `origin: gitea`, a `gitea:` field, and a ledger entry."""
|
||||
key = gmap.remote_key(REPO, number)
|
||||
iss = issue.Issue(id=id, title="A thing", body=BODY, state=state,
|
||||
labels=["type/task"], origin=gmap.ORIGIN,
|
||||
extra={"gitea": key, "url": "https://git.example/x",
|
||||
"synced": "2026-08-10T00:00:00Z"})
|
||||
issue.save(self.root, iss)
|
||||
m = _gitea.load_map(self.root)
|
||||
m[key] = id
|
||||
_gitea.save_map(self.root, m)
|
||||
self.fake.states[number] = state
|
||||
return iss
|
||||
|
||||
def local_only(self, id="local-thing"):
|
||||
"""An issue that has never left this machine."""
|
||||
iss = issue.Issue(id=id, title="Local thing", body=BODY,
|
||||
labels=["type/task"])
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def dropped(self, id="gone-thing", number=205, state="open"):
|
||||
"""Pushed, and its file went with the push: ledger only."""
|
||||
m = _gitea.load_map(self.root)
|
||||
m[gmap.remote_key(REPO, number)] = id
|
||||
_gitea.save_map(self.root, m)
|
||||
self.fake.states[number] = state
|
||||
return number
|
||||
|
||||
# -- runner ------------------------------------------------------------
|
||||
|
||||
def run_close(self, *argv):
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = ["close.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
close.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def state_on_disk(self, id):
|
||||
return issue.load(self.root, id).state
|
||||
|
||||
def raw(self, id):
|
||||
with open(issue.path_of(self.root, id)) as f:
|
||||
return f.read()
|
||||
|
||||
def assertUnchanged(self, id, before, why=""):
|
||||
self.assertEqual(self.raw(id), before,
|
||||
"%s.md was rewritten%s" % (id, why and " — " + why))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it closes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ClosesTest(StoreTestCase):
|
||||
|
||||
def test_a_slug_closes_the_issue_it_names(self):
|
||||
self.synced("a-thing", 101)
|
||||
out, _ = self.run_close("a-thing")
|
||||
self.assertEqual(self.fake.states[101], "closed")
|
||||
self.assertIn("closed a-thing #101", out)
|
||||
|
||||
def test_the_local_state_follows(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
|
||||
def test_only_the_state_is_sent(self):
|
||||
"""Closing is not an edit: no title, no body, no labels ride along."""
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
writes = self.fake.writes()
|
||||
self.assertEqual(len(writes), 1)
|
||||
method, endpoint, payload = writes[0]
|
||||
self.assertEqual((method, endpoint), ("PATCH", "%s/issues/101" % BASE))
|
||||
self.assertEqual(payload, {"state": "closed"})
|
||||
|
||||
def test_a_number_closes_it_too(self):
|
||||
"""The normal case for a pushed issue — the file is long gone."""
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("101")
|
||||
self.assertEqual(self.fake.states[101], "closed")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
|
||||
def test_every_key_form_is_accepted(self):
|
||||
forms = {110: "110", 111: "#111", 112: "%s#112" % REPO,
|
||||
113: "https://git.example/%s/issues/113" % REPO}
|
||||
for n in forms:
|
||||
self.fake.states[n] = "open"
|
||||
for n, arg in forms.items():
|
||||
with self.subTest(arg=arg):
|
||||
self.run_close(arg)
|
||||
self.assertEqual(self.fake.states[n], "closed")
|
||||
|
||||
def test_several_ids_in_one_run(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.synced("b-thing", 102)
|
||||
self.run_close("a-thing", "102")
|
||||
self.assertEqual(self.fake.states, {101: "closed", 102: "closed"})
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
self.assertEqual(self.state_on_disk("b-thing"), "closed")
|
||||
|
||||
def test_the_same_issue_named_twice_is_written_once(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing", "#101")
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
|
||||
def test_the_index_is_rebuilt(self):
|
||||
self.synced("a-thing", 101)
|
||||
out, _ = self.run_close("a-thing")
|
||||
self.assertIn("index:", out)
|
||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||
self.assertIn("closed", f.read())
|
||||
|
||||
def test_the_body_survives_untouched(self):
|
||||
"""One metadata field changes; the prose and the ticks do not."""
|
||||
self.synced("a-thing", 101)
|
||||
before = issue.load(self.root, "a-thing").body
|
||||
self.run_close("a-thing")
|
||||
self.assertEqual(issue.load(self.root, "a-thing").body, before)
|
||||
|
||||
def test_synced_is_refreshed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
iss = issue.load(self.root, "a-thing")
|
||||
self.assertNotEqual(iss.extra.get("synced"), "2026-08-10T00:00:00Z")
|
||||
self.assertEqual(iss.extra.get("remote-updated"), "2026-08-11T00:00:00Z")
|
||||
|
||||
def test_an_issue_whose_file_was_dropped_still_closes(self):
|
||||
"""No local copy at all: the ledger names it, the tracker takes it, and
|
||||
nothing is written locally."""
|
||||
self.dropped("gone-thing", 205)
|
||||
out, _ = self.run_close("gone-thing")
|
||||
self.assertEqual(self.fake.states[205], "closed")
|
||||
self.assertIn("no local copy", out)
|
||||
self.assertNotIn("index:", out)
|
||||
|
||||
def test_a_number_nobody_here_knows_closes_without_a_slug(self):
|
||||
self.fake.states[777] = "open"
|
||||
out, _ = self.run_close("777")
|
||||
self.assertEqual(self.fake.states[777], "closed")
|
||||
self.assertIn("#777", out)
|
||||
|
||||
|
||||
class ReopensTest(StoreTestCase):
|
||||
|
||||
def test_reopen_sends_open(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
out, _ = self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.fake.writes()[0][2], {"state": "open"})
|
||||
self.assertIn("reopened a-thing #101", out)
|
||||
|
||||
def test_reopen_writes_the_local_state_back(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
def test_close_then_reopen_is_a_round_trip(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.fake.states[101], "open")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it refuses
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LocalOnlyTest(StoreTestCase):
|
||||
"""An `origin: local` issue is not in the tracker, so it cannot be closed
|
||||
there — and the local field is not quietly edited instead."""
|
||||
|
||||
def test_it_exits(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
|
||||
def test_the_error_names_the_id_and_says_it_is_not_in_the_tracker(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
err = self.err.getvalue()
|
||||
self.assertIn("local-thing", err)
|
||||
self.assertIn("not in the tracker", err)
|
||||
|
||||
def test_nothing_is_sent(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_the_file_is_untouched(self):
|
||||
self.local_only("local-thing")
|
||||
before = self.raw("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
self.assertUnchanged("local-thing", before)
|
||||
|
||||
def test_a_bad_id_stops_the_whole_run_before_anything_is_sent(self):
|
||||
"""Resolution happens up front, so a typo in the second id does not
|
||||
leave the first one closed."""
|
||||
self.synced("a-thing", 101)
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing", "local-thing")
|
||||
self.assertEqual(self.fake.states[101], "open")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_an_unknown_slug_exits(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("no-such-thing")
|
||||
self.assertIn("no-such-thing", self.err.getvalue())
|
||||
|
||||
|
||||
class DryRunTest(StoreTestCase):
|
||||
|
||||
def test_not_one_request_is_made(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_the_file_is_untouched(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
self.assertUnchanged("a-thing", before, "--dry-run must write nothing")
|
||||
|
||||
def test_it_says_what_would_be_closed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.synced("b-thing", 102)
|
||||
out, _ = self.run_close("--dry-run", "a-thing", "102")
|
||||
self.assertIn("would close a-thing #101", out)
|
||||
self.assertIn("would close b-thing #102", out)
|
||||
self.assertIn("2 issue(s) would be closed", out)
|
||||
|
||||
def test_it_says_reopen_under_reopen(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
out, _ = self.run_close("--dry-run", "--reopen", "a-thing")
|
||||
self.assertIn("would reopen a-thing #101", out)
|
||||
self.assertIn("would be reopened", out)
|
||||
|
||||
def test_it_needs_no_login(self):
|
||||
"""A dry run must work before /tea:auth has ever been run."""
|
||||
self.synced("a-thing", 101)
|
||||
with mock.patch.object(_gitea, "require_login",
|
||||
lambda: self.fail("dry run asked for a login")):
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
|
||||
def test_a_local_only_issue_is_still_refused(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("--dry-run", "local-thing")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the tracker said no
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TrackerFailureTest(StoreTestCase):
|
||||
"""The criterion that matters most: a write that was not confirmed leaves
|
||||
the local file exactly as it was."""
|
||||
|
||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||
or a 500 actually takes, and it ends in `die()`."""
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
return types.SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="422 Unprocessable Entity: issue is blocked")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
|
||||
self.assertUnchanged("a-thing", before, "tea exited non-zero")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
def test_a_transport_exception_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.raise_on_write = OSError("tea: command not found")
|
||||
with self.assertRaises(OSError):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before, "the transport raised")
|
||||
|
||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"ok": True, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_answer_for_another_issue_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"number": 999, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_answer_that_did_not_change_the_state_leaves_the_file(self):
|
||||
"""A 200 that still says `open` is not a close."""
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"number": 101, "state": "open"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_empty_answer_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = None
|
||||
real_api = self.fake.api
|
||||
self.fake.api = lambda *a, **kw: (real_api(*a, **kw), None)[1]
|
||||
with mock.patch.object(_gitea, "api", self.fake.api), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_the_error_says_nothing_local_changed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.fake.answer_override = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertIn("Nothing local was changed", self.err.getvalue())
|
||||
|
||||
def test_a_failure_partway_through_keeps_the_rest(self):
|
||||
"""Two issues, the second one is not confirmed. The first is
|
||||
legitimately closed; the second's file still says open."""
|
||||
self.synced("aaa-thing", 101)
|
||||
self.synced("zzz-thing", 102)
|
||||
before = self.raw("zzz-thing")
|
||||
|
||||
real = self.fake.api
|
||||
seen = []
|
||||
|
||||
def once(login, endpoint, method="GET", payload=None, **kw):
|
||||
got = real(login, endpoint, method, payload, **kw)
|
||||
if method != "GET":
|
||||
seen.append(endpoint)
|
||||
return {"nope": True} if len(seen) > 1 else got
|
||||
|
||||
with mock.patch.object(_gitea, "api", once), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("aaa-thing", "zzz-thing")
|
||||
|
||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||
self.assertUnchanged("zzz-thing", before, "its write was not confirmed")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the pure parts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ConfirmedTest(unittest.TestCase):
|
||||
"""The gate itself. Everything below it rewrites a file."""
|
||||
|
||||
def test_a_matching_close_is_confirmed(self):
|
||||
self.assertTrue(close.confirmed({"number": 42, "state": "closed"}, 42, "closed"))
|
||||
|
||||
def test_a_mismatched_number_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 43, "state": "closed"}, 42, "closed"))
|
||||
|
||||
def test_the_wrong_state_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 42, "state": "open"}, 42, "closed"))
|
||||
|
||||
def test_a_missing_state_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 42}, 42, "closed"))
|
||||
|
||||
def test_none_and_lists_are_not(self):
|
||||
self.assertFalse(close.confirmed(None, 42, "closed"))
|
||||
self.assertFalse(close.confirmed([{"number": 42, "state": "closed"}], 42, "closed"))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
self.assertFalse(close.confirmed({"number": True, "state": "closed"}, 1, "closed"))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": "42", "state": "closed"}, 42, "closed"))
|
||||
|
||||
|
||||
class KeyFormTest(unittest.TestCase):
|
||||
"""A slug and a key are two vocabularies that must not collide."""
|
||||
|
||||
def test_keys_are_keys(self):
|
||||
for k in ("42", "#42", "owner/repo#42",
|
||||
"https://git.example/owner/repo/issues/42"):
|
||||
self.assertTrue(close.looks_like_key(k), k)
|
||||
|
||||
def test_slugs_are_not_keys(self):
|
||||
for s in ("a-thing", "wire-sqlc-appclick", "close-issues-through-a-script"):
|
||||
self.assertFalse(close.looks_like_key(s), s)
|
||||
|
||||
|
||||
class LedgerPairsTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.m = {"%s#7" % REPO: "a-thing", "other/repo#7": "b-thing",
|
||||
"not-a-key": "c-thing"}
|
||||
|
||||
def test_it_filters_by_repo(self):
|
||||
self.assertEqual(close.ledger_pairs(self.m, REPO), [(REPO, 7, "a-thing")])
|
||||
|
||||
def test_without_a_repo_it_keeps_everything_parseable(self):
|
||||
got = close.ledger_pairs(self.m)
|
||||
self.assertEqual(sorted(s for _r, _n, s in got), ["a-thing", "b-thing"])
|
||||
|
||||
def test_an_ambiguous_number_exits(self):
|
||||
pairs = close.ledger_pairs(self.m)
|
||||
with self.assertRaises(SystemExit):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
close.resolve("7", {}, pairs)
|
||||
|
||||
|
||||
class AmbiguityTest(StoreTestCase):
|
||||
"""Two repos, one number, no --repo: settle it rather than guess."""
|
||||
|
||||
def test_the_error_points_at_repo(self):
|
||||
_gitea.save_map(self.root, {"%s#7" % REPO: "a-thing",
|
||||
"other/repo#7": "b-thing"})
|
||||
err = io.StringIO()
|
||||
args = ["close.py", "--out", self.root, "7"]
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(err), \
|
||||
self.assertRaises(SystemExit):
|
||||
close.main()
|
||||
self.assertIn("--repo", err.getvalue())
|
||||
|
||||
|
||||
class RepoOfTheKeyTest(StoreTestCase):
|
||||
"""A key that names its own repo is sent there, not to whatever repo the
|
||||
CWD happens to be — otherwise `#42` closes somebody else's issue."""
|
||||
|
||||
def run_bare(self, *argv):
|
||||
"""No `--repo`, so the ids have to say where they live."""
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = ["close.py", "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
close.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
def test_a_foreign_key_goes_to_its_own_repo(self):
|
||||
self.run_bare("other/repo#42")
|
||||
self.assertEqual(self.fake.writes()[0][1], "repos/other/repo/issues/42")
|
||||
|
||||
def test_a_slug_goes_to_the_repo_its_gitea_field_names(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_bare("a-thing")
|
||||
self.assertEqual(self.fake.writes()[0][1], "%s/issues/101" % BASE)
|
||||
|
||||
def test_two_repos_in_one_run_is_a_question_not_a_guess(self):
|
||||
self.synced("a-thing", 101)
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_bare("a-thing", "other/repo#42")
|
||||
self.assertIn("one repo", self.err.getvalue())
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_an_explicit_repo_settles_it(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing", "other/repo#42")
|
||||
self.assertEqual({c[1] for c in self.fake.writes()},
|
||||
{"%s/issues/101" % BASE, "%s/issues/42" % BASE})
|
||||
|
||||
|
||||
class NoStoreTest(StoreTestCase):
|
||||
"""A number needs no local file, and a store that is not there is not an
|
||||
error — closing an issue whose copy push dropped is the normal case."""
|
||||
|
||||
def test_a_number_closes_with_no_store_at_all(self):
|
||||
missing = os.path.join(self.root, "nowhere")
|
||||
self.fake.states[303] = "open"
|
||||
args = ["close.py", "--repo", REPO, "--out", missing, "303"]
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
close.main()
|
||||
self.assertEqual(self.fake.states[303], "closed")
|
||||
self.assertFalse(os.path.isdir(missing), "no store was conjured")
|
||||
|
||||
|
||||
class PayloadFileTest(StoreTestCase):
|
||||
"""The request body goes to the transport's own scratchpad.
|
||||
|
||||
Not to a directory this script picks: `close.py` names the payload and
|
||||
nothing else, the way every other caller does. Where PAYLOAD_ROOT lands is
|
||||
_gitea's business, and test_payload_root.py is where that is tested."""
|
||||
|
||||
def test_the_payload_lands_in_the_transports_scratchpad(self):
|
||||
self.synced("a-thing", 101)
|
||||
payloads = os.path.join(self.root, "payload")
|
||||
with mock.patch.object(_gitea, "PAYLOAD_ROOT", payloads), \
|
||||
mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(
|
||||
_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=lambda cmd, **kw: types.SimpleNamespace(
|
||||
returncode=0, stderr="",
|
||||
stdout=json.dumps({"number": 101, "state": "closed"})))):
|
||||
self.run_close("a-thing")
|
||||
p = os.path.join(payloads, "state-101.json")
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
with open(p) as f:
|
||||
self.assertEqual(json.load(f), {"state": "closed"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The container <-> child edge points ONE way: container -> child.
|
||||
|
||||
A `type/feature` is closed when its children are closed, and that is a
|
||||
dependency relation, so the container lists its children in `depends:`. A child
|
||||
belongs to a feature, which is a membership relation, and membership has no
|
||||
place in a dependency graph — so a child never names its container back. These
|
||||
tests pin that direction down in all three places it shows up: the validator,
|
||||
the desync warning, and the drawn tree.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
|
||||
not packages, so they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
import issue # noqa: E402
|
||||
import issue_check # noqa: E402
|
||||
import issue_new # noqa: E402
|
||||
import issue_tree # noqa: E402
|
||||
|
||||
|
||||
def run(module, argv):
|
||||
"""Call a script's main() with argv, returning (exit_code, stdout).
|
||||
|
||||
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
|
||||
in the store yet, which is fine and not what these tests are about."""
|
||||
buf = io.StringIO()
|
||||
old = sys.argv
|
||||
sys.argv = [module.__name__ + ".py"] + argv
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
|
||||
code = module.main()
|
||||
except SystemExit as e: # argparse / sys.exit("msg")
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
finally:
|
||||
sys.argv = old
|
||||
return (code or 0), buf.getvalue()
|
||||
|
||||
|
||||
def drawn(tree_output):
|
||||
"""The rows inside the tree's code fence, header and blanks dropped."""
|
||||
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
"""A scratch store per test. Never touches tmp/issues/."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = self._tmp.name
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def new(self, type, id, title, depends=()):
|
||||
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
|
||||
for d in depends:
|
||||
argv += ["--depends", d]
|
||||
code, _ = run(issue_new, argv)
|
||||
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
|
||||
|
||||
def edit(self, id, old, new):
|
||||
p = issue.path_of(self.root, id)
|
||||
with open(p) as f:
|
||||
text = f.read()
|
||||
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
|
||||
with open(p, "w") as f:
|
||||
f.write(text.replace(old, new))
|
||||
|
||||
def fill_issues_section(self, id, *children):
|
||||
"""Replace the type/feature template's `## Issues` placeholder."""
|
||||
self.edit(id,
|
||||
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
|
||||
"".join("- [ ] %s — часть\n" % c for c in children))
|
||||
|
||||
def container_and_child(self):
|
||||
"""The canonical shape from references/format.md: the container names
|
||||
the child in `depends:` AND in `## Issues`; the child names nobody."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("feature", "feat-x", "Container x", depends=["child-y"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
|
||||
|
||||
class CanonicalContainerIsClean(StoreCase):
|
||||
"""A container from the template plus a child per format.md: green."""
|
||||
|
||||
def test_check_is_silent_and_exits_zero(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertNotIn("ERROR", out)
|
||||
self.assertNotIn("warn", out)
|
||||
self.assertIn("ok feat-x", out)
|
||||
self.assertIn("ok child-y", out)
|
||||
|
||||
def test_validate_reports_nothing_for_either_issue(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
for id in ("feat-x", "child-y"):
|
||||
err, warn = issue.validate(issues[id], known_ids=set(issues))
|
||||
self.assertEqual((err, warn), ([], []), id)
|
||||
|
||||
def test_the_child_does_not_depend_on_its_container(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
self.assertEqual(issues["feat-x"].depends, ["child-y"])
|
||||
self.assertEqual(issues["child-y"].depends, [])
|
||||
|
||||
|
||||
class OnlyOneDirectionIsLegal(StoreCase):
|
||||
"""format.md and the validator agree on container -> child, and the
|
||||
reverse edge is an error rather than a matter of taste."""
|
||||
|
||||
def test_the_reverse_edge_is_a_cycle(self):
|
||||
self.container_and_child()
|
||||
self.edit("child-y", "depends: []", "depends: [feat-x]")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 1, out)
|
||||
self.assertIn("ERROR cycle:", out)
|
||||
self.assertIn("feat-x", out)
|
||||
self.assertIn("child-y", out)
|
||||
|
||||
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
|
||||
"""The shape format.md used to document: the child depends on the
|
||||
container and the container's depends: is empty. It no longer matches
|
||||
what the container's own `## Issues` says, so the check complains."""
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y", depends=["feat-x"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
_, out = run(issue_check, ["--out", self.root])
|
||||
self.assertIn("warn feat-x:", out)
|
||||
|
||||
def test_issues_section_is_an_edge_source_pointing_down(self):
|
||||
body = "## Issues\n- [ ] child-y — часть\n"
|
||||
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
|
||||
|
||||
|
||||
class WarningNamesItsOwnSection(StoreCase):
|
||||
"""The desync warning quotes the section the reference came from, not
|
||||
`## Depends on` unconditionally — a container has no such section."""
|
||||
|
||||
def test_container_warning_says_issues(self):
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.fill_issues_section("feat-x", "child-y") # but not depends:
|
||||
issues = issue.load_all(self.root)
|
||||
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
|
||||
self.assertEqual(err, [])
|
||||
self.assertEqual(
|
||||
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
|
||||
self.assertNotIn("## Depends on", "\n".join(warn))
|
||||
body = issue.load(self.root, "feat-x").body
|
||||
self.assertNotIn("## Depends on", body,
|
||||
"the warning must not name a section that is not in the file")
|
||||
|
||||
def test_plain_issue_warning_still_says_depends_on(self):
|
||||
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
|
||||
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(
|
||||
warn,
|
||||
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
|
||||
|
||||
def test_each_reference_is_named_with_its_own_section(self):
|
||||
body = ("## Depends on\n- migrate-schema\n\n"
|
||||
"## Issues\n- [ ] child-y — часть\n")
|
||||
self.assertEqual(
|
||||
issue.body_dep_ref_sections(body),
|
||||
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
|
||||
|
||||
def test_body_dep_refs_still_returns_bare_strings(self):
|
||||
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
|
||||
body = "## Depends on\n- migrate-schema\n- #42\n"
|
||||
refs = issue.body_dep_refs(body)
|
||||
self.assertEqual(refs, ["migrate-schema", "#42"])
|
||||
self.assertTrue(all(isinstance(r, str) for r in refs))
|
||||
|
||||
def test_tracker_numbers_never_warn(self):
|
||||
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(warn, [])
|
||||
|
||||
|
||||
class TreePutsTheContainerOnTop(StoreCase):
|
||||
|
||||
def test_container_is_the_root_and_children_hang_below(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
rows = drawn(out)
|
||||
self.assertEqual(len(rows), 2, out)
|
||||
self.assertTrue(rows[0].startswith("feat-x "), out)
|
||||
self.assertTrue(rows[1].startswith("└── child-y "), out)
|
||||
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
|
||||
|
||||
def test_the_container_is_the_only_root(self):
|
||||
self.container_and_child()
|
||||
_, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertIn("# Dependency tree — feat-x", out)
|
||||
|
||||
def test_two_children_hang_off_one_container(self):
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("task", "child-z", "Child z")
|
||||
self.new("feature", "feat-x", "Container x",
|
||||
depends=["child-y", "child-z"])
|
||||
self.fill_issues_section("feat-x", "child-y", "child-z")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
_, tree = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(tree.count("feat-x ["), 1,
|
||||
"the container must not repeat once per child")
|
||||
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
|
||||
["feat-x", "├──", "└──"], tree)
|
||||
self.assertIn("├── child-y ", tree)
|
||||
self.assertIn("└── child-z ", tree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,811 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The local copy is dropped after a successful push, and pulled back on demand.
|
||||
|
||||
Two halves, and the second one is the one that matters:
|
||||
|
||||
1. **It deletes.** A confirmed create or PATCH removes `tmp/issues/<id>.md` and
|
||||
`<id>.comments.md`, prints where the issue lives now, and leaves the ledger
|
||||
behind so the slug can be found again. A pull puts the same file back —
|
||||
same slug, same `depends:`, same body — including after a rename in Gitea
|
||||
and on a machine that never had the file.
|
||||
|
||||
2. **It does not delete anything else, ever.** A transport that raised, a `tea`
|
||||
that exited non-zero, an answer without a number, an answer for the wrong
|
||||
issue, an `origin: local` issue nobody pushed: the file is still on disk.
|
||||
A bug here destroys work, so every one of those paths is asserted
|
||||
separately, and the assertion is always the same — `os.path.isfile`.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as `test_push_dependencies.py` does,
|
||||
with one deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||
instead and lets the REAL `_gitea.api` run, so "tea exited 1" is proved end to
|
||||
end rather than assumed.
|
||||
|
||||
Nothing here touches a network, and nothing here touches the developer's store:
|
||||
every test builds its own in a `tempfile.mkdtemp()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
import push # noqa: E402
|
||||
|
||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
LABELS = {"type/task": 901, "type/bug": 902}
|
||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
BODY_WITH_DEPS = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Depends on
|
||||
- first-thing — ставит фундамент, без него второй не собрать
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# a tracker that can be both pushed to and pulled from
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, for push AND pull.
|
||||
|
||||
It keeps bodies the way Gitea does — verbatim, marker and all — which is
|
||||
what makes the round-trip tests real: the slug that comes back is the one
|
||||
that was actually stored on the far side, not one the test handed over."""
|
||||
|
||||
def __init__(self, next_number=101):
|
||||
self.calls = []
|
||||
self.next_number = next_number
|
||||
self.issues = {} # number -> payload
|
||||
self.deps = {} # number -> {(repo, number)}
|
||||
# Failure injection, one write at a time.
|
||||
self.raise_on_write = None # an exception instance to raise
|
||||
self.answer_override = None # what a write answers instead
|
||||
|
||||
# -- state -------------------------------------------------------------
|
||||
|
||||
def store(self, number, title, body, **kw):
|
||||
p = {"number": number, "title": title, "body": body, "state": "open",
|
||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||
"milestone": None, "ref": "test-branch",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
"repository": {"full_name": REPO}}
|
||||
p.update(kw)
|
||||
self.issues[number] = p
|
||||
return p
|
||||
|
||||
def body_of(self, number):
|
||||
return self.issues[number]["body"]
|
||||
|
||||
def rename(self, number, title):
|
||||
self.issues[number]["title"] = title
|
||||
|
||||
def writes(self):
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if path == "%s/labels" % BASE and method == "GET":
|
||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
number = int(path.split("/issues/")[1].split("/")[0])
|
||||
if method == "GET":
|
||||
return [dict(self.issues[n], repository={"full_name": r})
|
||||
for r, n in sorted(self.deps.get(number, set()))
|
||||
if n in self.issues]
|
||||
if method == "POST":
|
||||
self.deps.setdefault(number, set()).add(
|
||||
("%s/%s" % (payload["owner"], payload["repo"]),
|
||||
int(payload["index"])))
|
||||
return {"number": number}
|
||||
|
||||
if path == "%s/issues" % BASE and method == "POST":
|
||||
return self._write(
|
||||
lambda: self.store(self._next(), payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
labels=self._labels(payload),
|
||||
ref=payload.get("ref", "")))
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
return self._write(
|
||||
lambda: self.store(number, payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
labels=self._labels(payload),
|
||||
ref=payload.get("ref", "")))
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def _next(self):
|
||||
n = self.next_number
|
||||
self.next_number += 1
|
||||
return n
|
||||
|
||||
def _labels(self, payload):
|
||||
return [{"name": LABEL_NAMES[i]} for i in (payload or {}).get("labels") or []
|
||||
if i in LABEL_NAMES]
|
||||
|
||||
def _write(self, do):
|
||||
"""Every create and update goes through here, so a test can make one
|
||||
fail without knowing which verb it was."""
|
||||
if self.raise_on_write is not None:
|
||||
raise self.raise_on_write
|
||||
got = do()
|
||||
if self.answer_override is not None:
|
||||
return self.answer_override
|
||||
return got
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, and no git."""
|
||||
|
||||
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()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def write_issue(self, id, title, body=BODY, depends=(), origin=issue.LOCAL,
|
||||
extra=None):
|
||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||
depends=list(depends), origin=origin,
|
||||
extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def write_comments(self, id, text="## comment 1 — someone — 2026-08-10\n\nтекст\n"):
|
||||
p = _gitea.comments_path(self.root, id)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
return p
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def run_push(self, *argv):
|
||||
return self._run(push, "push.py", argv)
|
||||
|
||||
def run_pull(self, *argv):
|
||||
return self._run(pull, "pull.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
# Kept on self so a test that expects SystemExit can still read what
|
||||
# went to stderr — the run never returns in that case.
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
mod.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def assertOnDisk(self, id, why=""):
|
||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
||||
|
||||
def assertGone(self, id):
|
||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md is still on disk" % id)
|
||||
|
||||
def ledger(self):
|
||||
return _gitea.load_map(self.root)
|
||||
|
||||
def number_of(self, id):
|
||||
for key, slug in self.ledger().items():
|
||||
if slug == id:
|
||||
return gmap.parse_remote_key(key)[1]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it deletes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DropsAfterCreateTest(StoreTestCase):
|
||||
|
||||
def test_the_issue_file_is_gone(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
self.assertGone("a-thing")
|
||||
|
||||
def test_the_comment_thread_goes_with_it(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
cpath = self.write_comments("a-thing")
|
||||
self.run_push()
|
||||
self.assertFalse(os.path.isfile(cpath), "the thread outlived the issue")
|
||||
|
||||
def test_a_missing_thread_is_not_an_error(self):
|
||||
"""Most issues have no comments file. Dropping must not care."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
out, _ = self.run_push()
|
||||
self.assertIn("dropped", out)
|
||||
|
||||
def test_the_output_names_the_number_and_the_url(self):
|
||||
"""The local path is gone, so this line is the only address left."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
out, _ = self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIn("#%d" % n, out)
|
||||
self.assertIn("https://git.example/%s/issues/%d" % (REPO, n), out)
|
||||
self.assertIn("pull.py %d" % n, out)
|
||||
|
||||
def test_the_ledger_outlives_the_file(self):
|
||||
"""`.remote.json` does not become garbage when the files go — it
|
||||
becomes the only local record of which slug this number is."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIsNotNone(n)
|
||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||
|
||||
def test_the_ledger_is_written_before_the_file_is_removed(self):
|
||||
"""Ordering, asserted rather than trusted: if the two were swapped, an
|
||||
interrupted run would cost the slug and not just a re-pull."""
|
||||
seen = {}
|
||||
real_drop = push.drop_local
|
||||
|
||||
def spy(root, id):
|
||||
seen["ledger"] = json.load(open(_gitea.map_path(root)))
|
||||
return real_drop(root, id)
|
||||
|
||||
self.write_issue("a-thing", "A thing")
|
||||
with mock.patch.object(push, "drop_local", spy):
|
||||
self.run_push()
|
||||
self.assertIn("a-thing", (seen.get("ledger") or {}).values())
|
||||
|
||||
|
||||
class DropsAfterUpdateTest(StoreTestCase):
|
||||
"""One rule, no exception: `--update` deletes too."""
|
||||
|
||||
def pushed_then_pulled(self, id="a-thing", body=BODY):
|
||||
self.write_issue(id, "A thing", body=body)
|
||||
self.run_push()
|
||||
self.run_pull(str(self.number_of(id)))
|
||||
self.assertOnDisk(id, "the pull should have put it back")
|
||||
return id
|
||||
|
||||
def test_patch_deletes_the_file_too(self):
|
||||
id = self.pushed_then_pulled()
|
||||
out, _ = self.run_push("--update", id)
|
||||
self.assertIn("updated", out)
|
||||
self.assertGone(id)
|
||||
|
||||
def test_patch_deletes_the_thread_too(self):
|
||||
id = self.pushed_then_pulled()
|
||||
cpath = self.write_comments(id)
|
||||
self.run_push("--update", id)
|
||||
self.assertFalse(os.path.isfile(cpath))
|
||||
|
||||
def test_the_patch_really_went_out(self):
|
||||
id = self.pushed_then_pulled()
|
||||
self.run_push("--update", id)
|
||||
self.assertTrue([c for c in self.fake.calls if c[0] == "PATCH"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it deletes nothing else
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NeverPushedIsNeverDroppedTest(StoreTestCase):
|
||||
|
||||
def test_a_local_issue_nobody_selected_stays(self):
|
||||
self.write_issue("pushed-thing", "Pushed thing")
|
||||
self.write_issue("kept-thing", "Kept thing")
|
||||
self.run_push("pushed-thing")
|
||||
self.assertGone("pushed-thing")
|
||||
self.assertOnDisk("kept-thing", "it was never pushed")
|
||||
|
||||
def test_a_local_only_dependency_stays(self):
|
||||
"""It is read (for the warning) but never sent, so never dropped."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
_, err = self.run_push("second-thing")
|
||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||
self.assertOnDisk("first-thing", "it was never sent")
|
||||
|
||||
def test_dry_run_deletes_nothing(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push("--dry-run")
|
||||
self.assertOnDisk("a-thing", "--dry-run must not write or delete")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_a_format_violation_stops_before_anything_is_sent(self):
|
||||
"""No type/* label: validation fails, nothing is sent, nothing goes."""
|
||||
issue.save(self.root, issue.Issue(id="bad-thing", title="Bad thing",
|
||||
body=BODY, labels=[]))
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push("bad-thing")
|
||||
self.assertOnDisk("bad-thing")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
|
||||
class SurvivesEveryFailureTest(StoreTestCase):
|
||||
"""The criterion that matters most. Each path is asserted on its own."""
|
||||
|
||||
def test_a_transport_exception_leaves_the_file(self):
|
||||
"""`tea` could not be run at all — the exception propagates out of the
|
||||
push and the delete is never reached."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.raise_on_write = OSError("tea: command not found")
|
||||
with self.assertRaises(OSError):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing", "the transport raised")
|
||||
self.assertEqual(self.ledger(), {})
|
||||
|
||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1.
|
||||
|
||||
Stubbed one layer lower than every other test here on purpose: this is
|
||||
the path a 422 or a 500 actually takes, and it ends in `die()`."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
creating = "-X" in cmd and cmd[cmd.index("-X") + 1] == "POST"
|
||||
if creating:
|
||||
return types.SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="422 Unprocessable Entity: validation failed")
|
||||
if cmd[-1].split("?")[0].endswith("/labels"):
|
||||
return types.SimpleNamespace(
|
||||
returncode=0, stderr="",
|
||||
stdout=json.dumps([{"name": n, "id": i}
|
||||
for n, i in LABELS.items()]))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
|
||||
self.assertOnDisk("a-thing", "tea exited non-zero")
|
||||
|
||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = {"ok": True, "message": "created"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing", "the answer carried no number")
|
||||
|
||||
def test_an_answer_that_is_not_an_object_leaves_the_file(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = ["something", "else"]
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
def test_an_empty_answer_leaves_the_file(self):
|
||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = None
|
||||
real_write = self.fake._write
|
||||
self.fake._write = lambda do: (real_write(do), None)[1]
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
def test_a_patch_answering_for_another_issue_leaves_the_file(self):
|
||||
"""The mismatched-body case: we PATCHed #101 and #999 answered."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
self.fake.answer_override = {"number": 999, "html_url": "https://x"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertOnDisk("a-thing", "the tracker answered for a different issue")
|
||||
|
||||
def test_the_error_says_the_file_is_untouched(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertIn("untouched", self.err.getvalue())
|
||||
|
||||
def test_a_failure_partway_through_keeps_what_has_not_been_sent(self):
|
||||
"""Two issues, the second one fails. The first is legitimately gone —
|
||||
Gitea confirmed it — and the second is still here."""
|
||||
self.write_issue("aaa-thing", "Aaa thing")
|
||||
self.write_issue("zzz-thing", "Zzz thing")
|
||||
|
||||
real_write = self.fake._write
|
||||
seen = []
|
||||
|
||||
def once(do):
|
||||
seen.append(1)
|
||||
if len(seen) > 1:
|
||||
return {"nope": True}
|
||||
return real_write(do)
|
||||
|
||||
self.fake._write = once
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
|
||||
self.assertGone("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing", "its write never succeeded")
|
||||
# And the one that did go up is in the ledger, so it is findable.
|
||||
self.assertEqual(list(self.ledger().values()), ["aaa-thing"])
|
||||
|
||||
|
||||
class ConfirmedNumberTest(unittest.TestCase):
|
||||
"""The gate itself. Everything below it deletes a file."""
|
||||
|
||||
def test_a_plain_create_is_confirmed(self):
|
||||
self.assertEqual(push.confirmed_number({"number": 42}), 42)
|
||||
|
||||
def test_a_matching_patch_is_confirmed(self):
|
||||
self.assertEqual(push.confirmed_number({"number": 42}, 42), 42)
|
||||
|
||||
def test_a_mismatched_patch_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": 43}, 42))
|
||||
|
||||
def test_none_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number(None))
|
||||
|
||||
def test_a_list_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number([{"number": 42}]))
|
||||
|
||||
def test_a_missing_number_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"html_url": "https://x"}))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": "42"}))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
"""`True` is an `int` in Python; `number: true` confirms nothing."""
|
||||
self.assertIsNone(push.confirmed_number({"number": True}))
|
||||
|
||||
def test_zero_and_negatives_are_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": 0}))
|
||||
self.assertIsNone(push.confirmed_number({"number": -1}))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the id marker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class IdMarkerTest(unittest.TestCase):
|
||||
"""map.py, pure — no store, no tracker."""
|
||||
|
||||
def test_the_marker_is_the_first_line(self):
|
||||
got = gmap.with_id_marker("## Summary\nтекст", "a-thing")
|
||||
self.assertEqual(got.splitlines()[0], "<!-- tea:id a-thing -->")
|
||||
self.assertEqual(got.splitlines()[1], "")
|
||||
|
||||
def test_strip_is_the_exact_inverse(self):
|
||||
for body in ("## Summary\nтекст", "", "one line",
|
||||
"## Summary\n\n- [ ] пункт\n\n## Spec\nnone"):
|
||||
self.assertEqual(gmap.strip_id_marker(gmap.with_id_marker(body, "x")),
|
||||
body)
|
||||
|
||||
def test_a_body_with_no_marker_comes_back_byte_for_byte(self):
|
||||
body = "## Summary\n\n весь текст \n\n\n"
|
||||
self.assertEqual(gmap.strip_id_marker(body), body)
|
||||
|
||||
def test_marking_twice_still_leaves_one(self):
|
||||
once = gmap.with_id_marker("текст", "a-thing")
|
||||
twice = gmap.with_id_marker(once, "a-thing")
|
||||
self.assertEqual(once, twice)
|
||||
self.assertEqual(twice.count("tea:id"), 1)
|
||||
|
||||
def test_remarking_under_a_new_slug_replaces_rather_than_adds(self):
|
||||
got = gmap.with_id_marker(gmap.with_id_marker("текст", "old"), "new")
|
||||
self.assertEqual(got.count("tea:id"), 1)
|
||||
self.assertEqual(gmap.id_in_body(got), "new")
|
||||
|
||||
def test_every_marker_is_removed_not_just_the_first(self):
|
||||
"""A body hand-edited in the web UI could hold two. It comes back with
|
||||
none, and the next push writes exactly one."""
|
||||
mangled = ("<!-- tea:id one -->\n\nтекст\n\n<!-- tea:id two -->\nещё")
|
||||
self.assertEqual(gmap.strip_id_marker(mangled), "текст\n\nещё")
|
||||
self.assertEqual(gmap.with_id_marker(mangled, "one").count("tea:id"), 1)
|
||||
|
||||
def test_id_in_body_reads_the_first_marker(self):
|
||||
self.assertEqual(gmap.id_in_body("<!-- tea:id one -->\n\nx"), "one")
|
||||
self.assertIsNone(gmap.id_in_body("## Summary\nтекст"))
|
||||
self.assertIsNone(gmap.id_in_body(""))
|
||||
|
||||
def test_a_marker_that_is_not_a_slug_is_ignored(self):
|
||||
"""Better to fall back to the title than to name a file after junk."""
|
||||
for junk in ("Not A Slug", "../etc/passwd", "-leading", "два-слова"):
|
||||
self.assertIsNone(gmap.id_in_body("<!-- tea:id %s -->\n\nx" % junk))
|
||||
|
||||
def test_the_marker_tolerates_spacing(self):
|
||||
self.assertEqual(gmap.id_in_body("<!--tea:id a-thing-->"), "a-thing")
|
||||
self.assertEqual(gmap.id_in_body(" <!-- tea:id a-thing --> "),
|
||||
"a-thing")
|
||||
|
||||
def test_a_marker_inside_prose_is_not_one(self):
|
||||
"""Only a line that is nothing but the marker counts."""
|
||||
self.assertIsNone(gmap.id_in_body("см. <!-- tea:id a-thing --> выше"))
|
||||
|
||||
def test_to_payload_marks_and_from_api_unmarks(self):
|
||||
iss = issue.Issue(id="a-thing", title="A thing", body="## Summary\nтекст")
|
||||
sent = gmap.to_payload(iss)["body"]
|
||||
self.assertTrue(sent.startswith("<!-- tea:id a-thing -->"))
|
||||
back, _ = gmap.from_api({"number": 1, "title": "A thing", "body": sent},
|
||||
"a-thing", REPO)
|
||||
self.assertEqual(back.body, "## Summary\nтекст")
|
||||
|
||||
|
||||
class MarkerStaysOffDiskTest(StoreTestCase):
|
||||
|
||||
def test_the_local_file_never_holds_a_marker(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIn("tea:id a-thing", self.fake.body_of(n))
|
||||
|
||||
self.run_pull(str(n))
|
||||
with open(issue.path_of(self.root, "a-thing")) as f:
|
||||
self.assertNotIn("tea:id", f.read())
|
||||
|
||||
def test_repeated_round_trips_do_not_accumulate_markers(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
for _ in range(3):
|
||||
self.run_pull(str(n))
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertEqual(self.fake.body_of(n).count("tea:id"), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the round trip
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class RoundTripTest(StoreTestCase):
|
||||
"""push -> the file is gone -> pull -> the same file is back."""
|
||||
|
||||
def two_issues(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
|
||||
def snapshot(self, id):
|
||||
iss = issue.load(self.root, id)
|
||||
return (iss.id, iss.title, iss.body, sorted(iss.depends),
|
||||
sorted(iss.labels), iss.state)
|
||||
|
||||
def test_the_file_comes_back_identical(self):
|
||||
self.two_issues()
|
||||
before = self.snapshot("second-thing")
|
||||
self.run_push()
|
||||
self.assertGone("second-thing")
|
||||
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertEqual(self.snapshot("second-thing"), before)
|
||||
|
||||
def test_depends_survives_the_round_trip(self):
|
||||
"""The edge lives in Gitea's own graph while the files do not exist —
|
||||
push wrote it, `pull --deps` reads it back, and the ledger turns the
|
||||
number back into the slug it had here."""
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.assertGone("first-thing")
|
||||
self.assertGone("second-thing")
|
||||
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||
["first-thing"])
|
||||
|
||||
def test_the_prose_dependency_is_still_the_authors_words(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertIn("- first-thing — ставит фундамент",
|
||||
issue.load(self.root, "second-thing").body)
|
||||
|
||||
def test_a_rename_in_gitea_does_not_change_the_slug(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
|
||||
self.fake.rename(n, "Completely different title now")
|
||||
self.run_pull(str(n))
|
||||
|
||||
self.assertOnDisk("a-thing")
|
||||
self.assertFalse(os.path.isfile(
|
||||
issue.path_of(self.root, "completely-different-title-now")))
|
||||
self.assertEqual(issue.load(self.root, "a-thing").title,
|
||||
"Completely different title now")
|
||||
|
||||
def test_the_slug_survives_a_rename_with_the_ledger_thrown_away(self):
|
||||
"""The case `.remote.json` cannot cover: a fresh clone, or another
|
||||
machine. The marker is the only thing left, and it is enough."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
|
||||
self.fake.rename(n, "Completely different title now")
|
||||
os.remove(_gitea.map_path(self.root))
|
||||
|
||||
self.run_pull(str(n))
|
||||
self.assertOnDisk("a-thing")
|
||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||
|
||||
def test_depends_survives_a_lost_ledger_when_both_come_back(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
os.remove(_gitea.map_path(self.root))
|
||||
|
||||
self.run_pull(str(first), str(second), "--deps")
|
||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||
["first-thing"])
|
||||
|
||||
def test_an_issue_filed_in_the_web_ui_still_gets_a_slug(self):
|
||||
"""No marker, no ledger entry — the title is the fallback, as before."""
|
||||
self.fake.store(500, "Filed in the web ui", "## Summary\nтекст")
|
||||
self.run_pull("500")
|
||||
self.assertOnDisk("filed-in-the-web-ui")
|
||||
|
||||
def test_a_marker_colliding_with_a_local_issue_does_not_overwrite_it(self):
|
||||
"""A slug is only taken at its word when it is free."""
|
||||
self.write_issue("a-thing", "A thing", body="## Summary\nмоя локальная")
|
||||
self.fake.store(500, "Something else",
|
||||
gmap.with_id_marker("## Summary\nчужая", "a-thing"))
|
||||
self.run_pull("500")
|
||||
|
||||
self.assertIn("моя локальная", issue.load(self.root, "a-thing").body)
|
||||
self.assertIn("чужая", issue.load(self.root, "a-thing-2").body)
|
||||
|
||||
def test_the_branch_ref_comes_back_with_the_issue(self):
|
||||
"""`branch:` is not written back to a file that is being deleted; it
|
||||
goes up in the payload and comes down again on the next pull."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
self.assertEqual(issue.load(self.root, "a-thing").extra.get("branch"),
|
||||
"test-branch")
|
||||
|
||||
def test_pushing_the_pulled_copy_back_is_a_no_op_on_the_body(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
before = self.fake.body_of(n)
|
||||
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertEqual(self.fake.body_of(n), before)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the ledger
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class StoreListingTest(StoreTestCase):
|
||||
"""The store layout the drop depends on."""
|
||||
|
||||
def test_a_comment_thread_is_not_an_issue(self):
|
||||
"""`<id>.comments.md` sits in the store beside the issue. A slug has no
|
||||
dot in it, so it is not a slug and not a unit of work — otherwise a bare
|
||||
`push.py` files the comment thread as an issue of its own."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.write_comments("a-thing")
|
||||
self.assertEqual(issue.all_ids(self.root), ["a-thing"])
|
||||
|
||||
def test_a_bare_push_with_threads_in_the_store_still_works(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.write_comments("a-thing")
|
||||
self.run_push()
|
||||
self.assertGone("a-thing")
|
||||
|
||||
|
||||
class LedgerTest(StoreTestCase):
|
||||
"""`.remote.json` after the files it used to index are gone."""
|
||||
|
||||
def test_rebuild_keeps_entries_whose_files_no_longer_exist(self):
|
||||
"""It used to reconstruct the map from the files and save the result,
|
||||
which would now silently drop every pushed issue."""
|
||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 7): "gone-thing"})
|
||||
self.write_issue("here-thing", "Here thing", origin="gitea",
|
||||
extra={"gitea": gmap.remote_key(REPO, 8)})
|
||||
|
||||
got = _gitea.rebuild_map(self.root, issue.load_all(self.root))
|
||||
self.assertEqual(got, {gmap.remote_key(REPO, 7): "gone-thing",
|
||||
gmap.remote_key(REPO, 8): "here-thing"})
|
||||
self.assertEqual(_gitea.load_map(self.root), got)
|
||||
|
||||
def test_a_second_push_reuses_the_ledger_not_the_files(self):
|
||||
"""Two pushes, no pull in between for the blocker: its file is gone, so
|
||||
its number can only come from the ledger — and the link is still made."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.run_push("first-thing")
|
||||
self.assertGone("first-thing")
|
||||
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
out, err = self.run_push("second-thing")
|
||||
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||
self.assertNotIn("local-only", err)
|
||||
|
||||
def test_the_dry_run_resolves_a_dropped_blocker_from_the_ledger(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.run_push("first-thing")
|
||||
first = self.number_of("first-thing")
|
||||
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
out, _ = self.run_push("--dry-run", "second-thing")
|
||||
self.assertIn("link -> %s#%d (first-thing)" % (REPO, first), out)
|
||||
|
||||
def test_ledger_keys_prefers_the_current_repo(self):
|
||||
m = {"other/repo#7": "a-thing", "%s#9" % REPO: "a-thing"}
|
||||
self.assertEqual(push.ledger_keys(m, REPO), {"a-thing": "%s#9" % REPO})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Closed issues leave the store, and nothing else does.
|
||||
|
||||
Two halves, and the second one is the one that matters:
|
||||
|
||||
1. **It evicts.** A closed issue whose `origin:` names a tracker is removed from
|
||||
`tmp/issues/` — the issue file and every sidecar under its slug — by one
|
||||
command, and `INDEX.md` is rebuilt so the directory and its table agree.
|
||||
`skills/sync/scripts/evict.py` does the same after refreshing `state:` from
|
||||
Gitea, so an issue closed in the web UI goes without a pull first.
|
||||
|
||||
2. **It evicts nothing else, ever.** `origin: local` is the only copy of the
|
||||
work there is: it stays in every state, including when it is closed and
|
||||
including when it is named on the command line. An open issue stays. A dry
|
||||
run stays. And a tracker call that fails leaves the whole store on disk —
|
||||
every candidate, not just the ones whose answers had not arrived yet.
|
||||
|
||||
A bug in the second half destroys work, so each path is asserted separately and
|
||||
the assertion is always the same — `os.path.isfile`.
|
||||
|
||||
Nothing here touches a network (the sync half stubs `_gitea.api`, and one test
|
||||
stubs `_gitea.subprocess` so a non-zero `tea` is proved end to end) and nothing
|
||||
here touches the developer's store: every test builds its own under
|
||||
`tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import evict # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_evict # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] сделано
|
||||
"""
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store, and fixtures for the three kinds of file that live in it."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-evict-")
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
self.numbers = {}
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def local(self, id, state="open"):
|
||||
"""An issue that exists nowhere but here."""
|
||||
return self._write(id, state=state, origin=issue.LOCAL)
|
||||
|
||||
def synced(self, id, state="open", number=None):
|
||||
"""A working copy of something the tracker already has."""
|
||||
n = number if number is not None else 100 + len(self.numbers)
|
||||
self.numbers[id] = n
|
||||
return self._write(id, state=state, origin=gmap.ORIGIN,
|
||||
extra={"gitea": gmap.remote_key(REPO, n),
|
||||
"url": "https://git.example/%s/issues/%d" % (REPO, n),
|
||||
"synced": "2026-08-10T00:00:00Z"})
|
||||
|
||||
def _write(self, id, state, origin, extra=None):
|
||||
iss = issue.Issue(id=id, title=id.replace("-", " ").capitalize(),
|
||||
body=BODY, labels=["type/task"], state=state,
|
||||
origin=origin, extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def comments(self, id):
|
||||
p = _gitea.comments_path(self.root, id)
|
||||
with open(p, "w") as f:
|
||||
f.write("## comment 1 — someone — 2026-08-10\n\nтекст\n")
|
||||
return p
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def run_evict(self, *argv):
|
||||
return self._run(issue_evict, "issue_evict.py", argv)
|
||||
|
||||
def run_sync_evict(self, *argv):
|
||||
return self._run(evict, "evict.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
mod.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def assertOnDisk(self, id, why=""):
|
||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
||||
|
||||
def assertGone(self, id):
|
||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md is still on disk" % id)
|
||||
|
||||
def index(self):
|
||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: what belongs to a slug
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class SlugFilesTest(StoreTestCase):
|
||||
"""`issue.slug_files` — how the domain removes an issue completely without
|
||||
knowing what a comment thread is."""
|
||||
|
||||
def test_the_issue_file_comes_first(self):
|
||||
self.synced("a-thing")
|
||||
p = self.comments("a-thing")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing"), p])
|
||||
|
||||
def test_an_issue_with_no_sidecars_is_one_file(self):
|
||||
self.synced("a-thing")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing")])
|
||||
|
||||
def test_a_longer_slug_is_not_a_sidecar(self):
|
||||
"""`a-thing-2` is another issue, not a companion of `a-thing`."""
|
||||
self.synced("a-thing")
|
||||
self.synced("a-thing-2")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing")])
|
||||
|
||||
def test_a_missing_store_is_empty_not_an_error(self):
|
||||
self.assertEqual(issue.slug_files(os.path.join(self.root, "nope"), "x"), [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: it evicts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class EvictsClosedTest(StoreTestCase):
|
||||
|
||||
def test_a_closed_synced_issue_goes(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
self.run_evict()
|
||||
self.assertGone("old-thing")
|
||||
|
||||
def test_the_comment_thread_goes_with_it(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
self.run_evict()
|
||||
self.assertFalse(os.path.isfile(p), "the thread outlived the issue")
|
||||
|
||||
def test_the_store_of_open_and_closed_keeps_exactly_the_open_and_the_local(self):
|
||||
"""The acceptance criterion, whole: a store of both kinds, one run, and
|
||||
what is left is the open issues and the local ones."""
|
||||
self.synced("open-synced")
|
||||
self.synced("closed-synced", state="closed")
|
||||
self.local("open-local")
|
||||
self.local("closed-local", state="closed")
|
||||
|
||||
self.run_evict()
|
||||
|
||||
self.assertEqual(issue.all_ids(self.root),
|
||||
["closed-local", "open-local", "open-synced"])
|
||||
|
||||
def test_the_output_names_every_file_removed(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("evicted", out)
|
||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||
self.assertIn(p, out)
|
||||
|
||||
def test_the_index_is_rebuilt_to_match_the_directory(self):
|
||||
"""`INDEX.md` and the directory agree afterwards — nothing to fix up."""
|
||||
self.synced("old-thing", state="closed")
|
||||
self.synced("live-thing")
|
||||
self.run_evict()
|
||||
index = self.index()
|
||||
self.assertIn("live-thing", index)
|
||||
self.assertNotIn("old-thing", index)
|
||||
|
||||
def test_only_the_named_issue_is_evicted(self):
|
||||
self.synced("first-old", state="closed")
|
||||
self.synced("second-old", state="closed")
|
||||
self.run_evict("first-old")
|
||||
self.assertGone("first-old")
|
||||
self.assertOnDisk("second-old", "it was not named")
|
||||
|
||||
def test_the_ledger_is_not_pruned(self):
|
||||
"""`.remote.json` is the number -> slug ledger, not an index over the
|
||||
files: an evicted issue is exactly as findable as a pushed one."""
|
||||
self.synced("old-thing", state="closed")
|
||||
key = gmap.remote_key(REPO, self.numbers["old-thing"])
|
||||
_gitea.save_map(self.root, {key: "old-thing"})
|
||||
self.run_evict()
|
||||
self.assertEqual(_gitea.load_map(self.root), {key: "old-thing"})
|
||||
|
||||
|
||||
class ClassifyTest(unittest.TestCase):
|
||||
"""The decision itself, pure. Everything below it deletes a file."""
|
||||
|
||||
def issues(self, **kinds):
|
||||
return {id: issue.Issue(id=id, state=state, origin=origin)
|
||||
for id, (state, origin) in kinds.items()}
|
||||
|
||||
def test_closed_and_synced_is_evicted(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", "gitea")))
|
||||
self.assertEqual(got, (["a"], [], []))
|
||||
|
||||
def test_closed_and_local_is_protected(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)))
|
||||
self.assertEqual(got, ([], ["a"], []))
|
||||
|
||||
def test_open_is_left_alone_whatever_its_origin(self):
|
||||
got = issue_evict.classify(self.issues(a=("open", "gitea"),
|
||||
b=("open", issue.LOCAL)))
|
||||
self.assertEqual(got, ([], [], ["a", "b"]))
|
||||
|
||||
def test_naming_a_local_issue_does_not_make_it_evictable(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)), ["a"])
|
||||
self.assertEqual(got, ([], ["a"], []))
|
||||
|
||||
def test_ids_restrict_the_question(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", "gitea"),
|
||||
b=("closed", "gitea")), ["b"])
|
||||
self.assertEqual(got, (["b"], [], []))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: it evicts nothing else
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LocalIsNeverEvictedTest(StoreTestCase):
|
||||
"""The criterion that matters most: `origin: local` IS the work."""
|
||||
|
||||
def test_a_closed_local_issue_stays(self):
|
||||
self.local("closed-local", state="closed")
|
||||
self.run_evict()
|
||||
self.assertOnDisk("closed-local", "origin: local is the only copy")
|
||||
|
||||
def test_a_closed_local_issue_named_explicitly_still_stays(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_evict("closed-local")
|
||||
self.assertOnDisk("closed-local", "naming it does not make deleting it safe")
|
||||
self.assertIn("kept", out)
|
||||
|
||||
def test_the_receipt_says_why_it_was_kept(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("origin: local", out)
|
||||
self.assertIn("this file IS the issue", out)
|
||||
|
||||
def test_its_sidecars_stay_too(self):
|
||||
self.local("closed-local", state="closed")
|
||||
p = self.comments("closed-local")
|
||||
self.run_evict()
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
|
||||
|
||||
class DryRunTouchesNothingTest(StoreTestCase):
|
||||
|
||||
def test_nothing_is_deleted(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
self.run_evict("--dry-run")
|
||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
|
||||
def test_it_prints_what_would_go(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
out, _ = self.run_evict("--dry-run")
|
||||
self.assertIn("would evict", out)
|
||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||
self.assertIn(p, out)
|
||||
self.assertIn("nothing was touched", out)
|
||||
|
||||
def test_the_index_is_not_written(self):
|
||||
"""`INDEX.md` is a write like any other — a dry run makes none."""
|
||||
self.synced("old-thing", state="closed")
|
||||
self.run_evict("--dry-run")
|
||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||
|
||||
|
||||
class NoOpRunsWriteNothingTest(StoreTestCase):
|
||||
|
||||
def test_a_store_with_nothing_to_evict_is_not_rewritten(self):
|
||||
self.synced("live-thing")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("0 issue(s) evicted", out)
|
||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||
|
||||
def test_an_unknown_id_stops_the_run(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_evict("no-such-thing")
|
||||
self.assertOnDisk("old-thing", "the run stopped before anything went")
|
||||
|
||||
def test_a_missing_store_is_an_error_and_not_a_directory_to_create(self):
|
||||
missing = os.path.join(self.root, "nope")
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
argv = ["issue_evict.py", "--out", missing]
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err), \
|
||||
self.assertRaises(SystemExit):
|
||||
issue_evict.main()
|
||||
self.assertFalse(os.path.isdir(missing))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the bridge: the state comes from the tracker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory. GET on an issue, and nothing else."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.states = {} # number -> "open" | "closed"
|
||||
self.answer_override = {} # number -> whatever it should answer instead
|
||||
self.raise_on = None # number -> exception to raise instead
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
out_root=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint))
|
||||
number = int(endpoint.rstrip("/").rsplit("/", 1)[1])
|
||||
if self.raise_on == number:
|
||||
raise OSError("tea: command not found")
|
||||
if number in self.answer_override:
|
||||
return self.answer_override[number]
|
||||
return {"number": number, "state": self.states.get(number, "open"),
|
||||
"title": "Whatever", "body": "текст"}
|
||||
|
||||
|
||||
class SyncEvictTestCase(StoreTestCase):
|
||||
|
||||
def setUp(self):
|
||||
StoreTestCase.setUp(self)
|
||||
self.fake = FakeTracker()
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def close_in_gitea(self, id):
|
||||
self.fake.states[self.numbers[id]] = "closed"
|
||||
|
||||
def state_on_disk(self, id):
|
||||
return issue.load(self.root, id).state
|
||||
|
||||
|
||||
class TrackerStateWinsTest(SyncEvictTestCase):
|
||||
|
||||
def test_an_issue_closed_upstream_is_evicted_without_a_pull_first(self):
|
||||
"""The observed workflow, in one command: the file still says `open`."""
|
||||
self.synced("old-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
self.run_sync_evict()
|
||||
self.assertGone("old-thing")
|
||||
|
||||
def test_an_issue_still_open_upstream_stays(self):
|
||||
self.synced("live-thing", state="open")
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("live-thing", "Gitea says it is open")
|
||||
|
||||
def test_a_stale_closed_file_is_corrected_and_kept(self):
|
||||
"""Reopened in the web UI: the local `state:` stops lying, and the file
|
||||
is not evicted on the strength of what it used to say."""
|
||||
self.synced("back-thing", state="closed")
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("back-thing", "Gitea says it is open again")
|
||||
self.assertEqual(self.state_on_disk("back-thing"), "open")
|
||||
|
||||
def test_a_local_issue_is_never_asked_about(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_sync_evict()
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
self.assertOnDisk("closed-local")
|
||||
|
||||
def test_an_issue_with_no_handle_is_reported_and_kept(self):
|
||||
"""`origin: gitea` and nothing to reach it by: a guess would delete a
|
||||
file nobody can get back."""
|
||||
issue.save(self.root, issue.Issue(id="orphan-thing", title="Orphan thing",
|
||||
body=BODY, labels=["type/task"],
|
||||
state="closed", origin=gmap.ORIGIN))
|
||||
_, err = self.run_sync_evict()
|
||||
self.assertIn("orphan-thing", err)
|
||||
self.assertOnDisk("orphan-thing", "it could not be verified")
|
||||
|
||||
def test_the_index_matches_the_directory_afterwards(self):
|
||||
self.synced("old-thing", state="open")
|
||||
self.synced("live-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
self.run_sync_evict()
|
||||
self.assertNotIn("old-thing", self.index())
|
||||
self.assertIn("live-thing", self.index())
|
||||
|
||||
def test_dry_run_asks_but_neither_writes_nor_deletes(self):
|
||||
self.synced("old-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
out, _ = self.run_sync_evict("--dry-run")
|
||||
self.assertTrue(self.fake.calls, "it should still have asked")
|
||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||
self.assertEqual(self.state_on_disk("old-thing"), "open",
|
||||
"--dry-run must not write the refreshed state either")
|
||||
self.assertIn("would evict", out)
|
||||
|
||||
|
||||
class SurvivesEveryTrackerFailureTest(SyncEvictTestCase):
|
||||
"""A failed call evicts nothing — including the candidates whose answers had
|
||||
already arrived."""
|
||||
|
||||
def two_closed(self):
|
||||
self.synced("aaa-thing", state="closed", number=11)
|
||||
self.synced("zzz-thing", state="closed", number=12)
|
||||
self.close_in_gitea("aaa-thing")
|
||||
self.close_in_gitea("zzz-thing")
|
||||
|
||||
def test_a_transport_exception_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.raise_on = 12
|
||||
with self.assertRaises(OSError):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing", "its answer arrived, but the run failed")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_a_non_2xx_answer_evicts_nothing(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||
or a 500 actually takes, and it ends in `die()`."""
|
||||
self.two_closed()
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
return types.SimpleNamespace(returncode=1, stdout="",
|
||||
stderr="500 Internal Server Error")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
|
||||
self.assertOnDisk("aaa-thing", "tea exited non-zero")
|
||||
self.assertOnDisk("zzz-thing", "tea exited non-zero")
|
||||
|
||||
def test_an_answer_for_another_issue_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"number": 999, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing", "the tracker answered for a different issue")
|
||||
|
||||
def test_an_answer_without_a_state_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"number": 12}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_an_empty_answer_evicts_nothing(self):
|
||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = None
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_the_error_says_nothing_was_evicted(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertIn("Nothing was evicted", self.err.getvalue())
|
||||
|
||||
def test_no_state_is_written_back_before_the_failure_either(self):
|
||||
"""The write-back happens after every answer is in, so a run that dies
|
||||
leaves the files exactly as it found them."""
|
||||
self.synced("aaa-thing", state="closed", number=11)
|
||||
self.synced("zzz-thing", state="closed", number=12)
|
||||
self.fake.states[11] = "open" # would be corrected on a good run
|
||||
self.fake.answer_override[12] = {"nope": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||
|
||||
|
||||
class ConfirmedStateTest(unittest.TestCase):
|
||||
"""The gate itself, in the shape of `push.confirmed_number`."""
|
||||
|
||||
def test_a_matching_answer_is_confirmed(self):
|
||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "closed"}, 42),
|
||||
"closed")
|
||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "open"}, 42),
|
||||
"open")
|
||||
|
||||
def test_another_issue_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 43, "state": "closed"}, 42))
|
||||
|
||||
def test_none_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state(None, 42))
|
||||
|
||||
def test_a_list_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state([{"number": 42, "state": "closed"}], 42))
|
||||
|
||||
def test_a_missing_state_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 42}, 42))
|
||||
|
||||
def test_an_unknown_state_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 42, "state": "merged"}, 42))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": "42", "state": "closed"}, 42))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": True, "state": "closed"}, 1))
|
||||
|
||||
|
||||
class CandidatesTest(StoreTestCase):
|
||||
"""Who the tracker is asked about at all."""
|
||||
|
||||
def test_a_synced_issue_is_asked_about_in_its_own_repo(self):
|
||||
self.synced("a-thing", number=7)
|
||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||
self.assertEqual(checkable, [("a-thing", REPO, 7)])
|
||||
self.assertEqual(unverifiable, [])
|
||||
|
||||
def test_a_local_issue_is_in_neither_list(self):
|
||||
self.local("local-thing", state="closed")
|
||||
self.assertEqual(evict.candidates(issue.load_all(self.root)), ([], []))
|
||||
|
||||
def test_a_handle_that_cannot_be_parsed_is_unverifiable(self):
|
||||
issue.save(self.root, issue.Issue(id="bad-thing", origin=gmap.ORIGIN,
|
||||
extra={"gitea": "not-a-key"}))
|
||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||
self.assertEqual(checkable, [])
|
||||
self.assertEqual([id for id, _ in unverifiable], ["bad-thing"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
What the guard guards: `tea` the command, not `tea` the word.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
The bug these tests hold down: the guard asked whether the string contained
|
||||
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
|
||||
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
|
||||
and `echo tea` were all refused, with a message telling the operator to add
|
||||
`--login` to `git commit`. The advice could not be followed — the only way
|
||||
past was to reword the sentence.
|
||||
|
||||
Two lines are held at once here, and neither may move without the other: the
|
||||
four false positives pass, and every shape that really runs the CLI — after
|
||||
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
|
||||
still blocked or still rewritten. A test that only proved the first would be
|
||||
satisfied by deleting the guard.
|
||||
|
||||
No network and no `tea` binary: the hook is pure decision-making, so the
|
||||
fixture is a directory with a pin in it and a JSON payload on stdin.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
|
||||
|
||||
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
|
||||
import pin # noqa: E402
|
||||
|
||||
LOGIN = "fixture/user"
|
||||
|
||||
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
|
||||
|
||||
|
||||
class GuardCase(unittest.TestCase):
|
||||
"""One temp project with one pinned login; the hook run as the harness
|
||||
runs it."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
path = pin.settings_path(self.root)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
|
||||
|
||||
def run_guard(self, cmd):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None)
|
||||
env[pin.PROJECT_DIR_ENV] = self.root
|
||||
p = subprocess.run([sys.executable, GUARD],
|
||||
input=json.dumps({"tool_input": {"command": cmd},
|
||||
"cwd": self.root}),
|
||||
cwd=self.root, env=env,
|
||||
capture_output=True, text=True)
|
||||
return p
|
||||
|
||||
def verdict(self, cmd):
|
||||
p = self.run_guard(cmd)
|
||||
if p.returncode == 2:
|
||||
return BLOCK, p.stderr
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
if not p.stdout.strip():
|
||||
return ALLOW, ""
|
||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||
return REWRITE, got
|
||||
|
||||
def assertVerdict(self, cmd, expected):
|
||||
kind, detail = self.verdict(cmd)
|
||||
self.assertEqual(kind, expected,
|
||||
"%r → %s (%s)" % (cmd, kind, detail.strip()))
|
||||
return detail
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the four false positives, verbatim from the report
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestProseAboutTheCliRuns(GuardCase):
|
||||
|
||||
def test_an_issue_title_may_name_the_command(self):
|
||||
self.assertVerdict(
|
||||
'python3 skills/issue/scripts/issue_new.py --type bug '
|
||||
'--title "Warn that tea pulls create needs the repo checkout" '
|
||||
'--label comp/use --severity low', ALLOW)
|
||||
|
||||
def test_a_commit_message_may_quote_a_raw_call(self):
|
||||
self.assertVerdict(
|
||||
"git add -A && git commit -F- <<'EOF'\n"
|
||||
"feat: close issues through a script\n"
|
||||
"\n"
|
||||
"Единственным способом сменить state был сырой вызов\n"
|
||||
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
|
||||
"EOF", ALLOW)
|
||||
|
||||
def test_a_one_line_commit_message_may_too(self):
|
||||
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
|
||||
|
||||
def test_searching_the_repository_for_the_word(self):
|
||||
for cmd in ('grep -rn " tea " docs/',
|
||||
'grep -rn "tea api" skills/',
|
||||
'echo tea'):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
|
||||
"""`echo tea` was the smallest case in the report; these are the same
|
||||
shape with the word in other argument positions."""
|
||||
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# and the real thing is still guarded
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestRealInvocationsStayGuarded(GuardCase):
|
||||
|
||||
def test_a_bare_call_without_a_login_is_blocked(self):
|
||||
detail = self.assertVerdict("tea issues list", BLOCK)
|
||||
self.assertIn("--login", detail)
|
||||
|
||||
def test_the_placeholder_is_rewritten_to_the_pin(self):
|
||||
got = self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
|
||||
self.assertIn(LOGIN, got)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_a_login_named_by_hand_is_blocked(self):
|
||||
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
|
||||
self.assertIn("do not name the login", detail)
|
||||
|
||||
def test_another_variable_is_not_the_placeholder(self):
|
||||
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
|
||||
|
||||
def test_compound_commands_are_read_segment_by_segment(self):
|
||||
for cmd in ('cd /tmp && tea issues list',
|
||||
'echo x | tea api -X GET repos/x/y',
|
||||
'( tea issues list )',
|
||||
'cd /tmp; tea issues list',
|
||||
'FOO=1 tea issues list',
|
||||
'sudo tea issues list',
|
||||
'xargs tea issues list'):
|
||||
self.assertVerdict(cmd, BLOCK)
|
||||
|
||||
def test_substitutions_are_read_too(self):
|
||||
for cmd in ('echo $(tea whoami)',
|
||||
'x=$(tea whoami)',
|
||||
'echo `tea whoami`'):
|
||||
self.assertVerdict(cmd, BLOCK)
|
||||
|
||||
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
|
||||
"""The two halves of the bug in one line: the guard must ignore the
|
||||
argument and still catch the call."""
|
||||
self.assertVerdict(
|
||||
'git commit -m "route it through tea api" && tea issues list',
|
||||
BLOCK)
|
||||
|
||||
def test_an_absolute_path_to_the_binary_is_the_binary(self):
|
||||
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
|
||||
|
||||
def test_every_call_in_the_line_is_rewritten(self):
|
||||
"""A half-rewritten line leaves the second call with an unset variable
|
||||
and therefore no login at all."""
|
||||
got = self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" && '
|
||||
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
|
||||
self.assertEqual(got.count(LOGIN), 2)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
|
||||
self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
|
||||
|
||||
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
|
||||
"""`tea logins list` is allowed because it uses no identity. Quoting
|
||||
that phrase must not turn the call beside it into a whitelisted one."""
|
||||
self.assertVerdict(
|
||||
'echo "run tea logins list first" && tea issues list', BLOCK)
|
||||
|
||||
|
||||
class TestTheWhitelistStillApplies(GuardCase):
|
||||
|
||||
def test_login_enumeration_needs_no_pin(self):
|
||||
for cmd in ("tea logins list", "tea logins ls",
|
||||
"tea --version", "tea --help"):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
|
||||
self.assertVerdict("tea logins list && tea issues list", BLOCK)
|
||||
|
||||
|
||||
class TestUnparseableLinesFailClosed(GuardCase):
|
||||
"""An unbalanced quote means the shell's reading and ours may differ. The
|
||||
old substring test decides — it over-matches, and over-matching blocks."""
|
||||
|
||||
def test_an_unterminated_quote_around_a_call_still_blocks(self):
|
||||
self.assertVerdict('tea issues list --state "open', BLOCK)
|
||||
|
||||
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
|
||||
self.assertVerdict('echo "unterminated', ALLOW)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Where the login pin is found, and that a git worktree is not a dead zone.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything, and not one real network call: every
|
||||
run here is against a throwaway repository with a FAKE `tea` first on PATH.
|
||||
|
||||
The bug: the pin was searched for by walking up from CWD only. A worktree is a
|
||||
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
|
||||
so it lives in the main checkout and nowhere else — the whole sync layer died
|
||||
inside any worktree with "no login pinned", while `tea` in the same directory
|
||||
worked, because the tea-guard hook had a second, different copy of the search.
|
||||
|
||||
So these tests hold two lines at once: the pin is reachable from a worktree,
|
||||
and the hook and the scripts get their answer from the same function.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
HOOKS = os.path.join(REPO, "hooks")
|
||||
|
||||
sys.path.insert(0, AUTH_SCRIPTS)
|
||||
import pin # noqa: E402
|
||||
|
||||
HAVE_GIT = shutil.which("git") is not None
|
||||
|
||||
LOGIN = "fixture/user"
|
||||
ENV_KEY = pin.ENV_KEY
|
||||
|
||||
# A `tea` that answers without a network: an empty list for every GET, a
|
||||
# created object for every write. It records its own argv, which is how a test
|
||||
# reads back the login the call actually ran under.
|
||||
FAKE_TEA = '''#!%s
|
||||
import json, os, sys
|
||||
argv = sys.argv[1:]
|
||||
with open(os.environ["TEA_CALL_LOG"], "a") as f:
|
||||
f.write("\\t".join(argv) + "\\n")
|
||||
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
|
||||
"html_url": "https://example.invalid/issues/101",
|
||||
"labels": []})
|
||||
if "-X" in argv else "[]")
|
||||
'''
|
||||
|
||||
ISSUE = """\
|
||||
---
|
||||
id: pinned-work
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: []
|
||||
origin: local
|
||||
---
|
||||
# Pinned work
|
||||
|
||||
## Summary
|
||||
Issue фикстуры, живёт в сторе worktree.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы push.py было что отправить.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
|
||||
def write(path, text):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
class Worktree(object):
|
||||
"""A repository with a pin, and a linked worktree beside it.
|
||||
|
||||
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
|
||||
the entire shape of the bug. The pin is written after the clone is
|
||||
committed and is covered by .gitignore, so it exists in the main checkout
|
||||
only — exactly as `/tea:auth` leaves it."""
|
||||
|
||||
def __init__(self, pinned=LOGIN):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
|
||||
# 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)
|
||||
self.main = os.path.join(self.root, "main")
|
||||
self.tree = os.path.join(self.root, "worktrees", "feature")
|
||||
self.calls = os.path.join(self.root, "calls.txt")
|
||||
|
||||
skip = shutil.ignore_patterns("__pycache__")
|
||||
for layer in ("auth", "issue", "sync"):
|
||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||
os.path.join(self.main, "skills", layer, "scripts"),
|
||||
ignore=skip)
|
||||
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
|
||||
write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n")
|
||||
|
||||
self.bin = os.path.join(self.root, "fakebin")
|
||||
os.makedirs(self.bin)
|
||||
tea = os.path.join(self.bin, "tea")
|
||||
write(tea, FAKE_TEA % sys.executable)
|
||||
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
self.git("init", cwd=self.main)
|
||||
self.git("add", "-A", cwd=self.main)
|
||||
self.git("commit", "-m", "fixture", cwd=self.main)
|
||||
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
|
||||
|
||||
if pinned:
|
||||
write(os.path.join(self.main, ".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: pinned}}))
|
||||
|
||||
def cleanup(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def env(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||
# The start of the search order, cleared: this fixture is about the
|
||||
# steps *after* it, and the developer's own project must not answer.
|
||||
env.pop(pin.PROJECT_DIR_ENV, None)
|
||||
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
||||
env["TEA_CALL_LOG"] = self.calls
|
||||
env["HOME"] = self.root # keep the developer's git config out
|
||||
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
env["GIT_CONFIG_GLOBAL"] = os.devnull
|
||||
return env
|
||||
|
||||
def git(self, *args, **kw):
|
||||
cmd = ["git", "-c", "user.email=fixture@example.invalid",
|
||||
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
|
||||
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
|
||||
return p.stdout.strip()
|
||||
|
||||
def script(self, layer, name):
|
||||
"""A script as the WORKTREE sees it — the copy the operator would run."""
|
||||
return os.path.join(self.tree, "skills", layer, "scripts", name)
|
||||
|
||||
def run(self, script, *args, **kw):
|
||||
p = subprocess.run([sys.executable, script] + list(args),
|
||||
cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
def tea_calls(self):
|
||||
if not os.path.isfile(self.calls):
|
||||
return []
|
||||
with open(self.calls) as f:
|
||||
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
||||
|
||||
def logins_used(self):
|
||||
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the search itself
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSearch(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.root, *parts)
|
||||
|
||||
def pin_at(self, root, login=LOGIN):
|
||||
write(os.path.join(root, ".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: login}}))
|
||||
|
||||
def test_the_parent_chain_is_searched(self):
|
||||
self.pin_at(self.root)
|
||||
os.makedirs(self.path("a", "b"))
|
||||
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
|
||||
|
||||
def test_no_pin_is_no_pin(self):
|
||||
os.makedirs(self.path("a"))
|
||||
self.assertEqual(pin.search(self.path("a")), (None, None))
|
||||
|
||||
def test_an_unreadable_pin_is_not_a_login(self):
|
||||
write(self.path(".claude", "settings.local.json"), "{ not json")
|
||||
self.assertEqual(pin.search(self.root), (None, None))
|
||||
|
||||
def test_an_empty_pin_is_not_a_login(self):
|
||||
write(self.path(".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: " "}}))
|
||||
self.assertEqual(pin.search(self.root), (None, None))
|
||||
|
||||
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
|
||||
"""The hop, built by hand from the two files git writes — no git
|
||||
needed to state what the layout means."""
|
||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(tree)
|
||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.pin_at(main)
|
||||
|
||||
self.assertEqual(pin.main_worktree(tree), main)
|
||||
login, src = pin.search(tree)
|
||||
self.assertEqual(login, LOGIN)
|
||||
self.assertEqual(src, pin.settings_path(main))
|
||||
|
||||
def test_an_ordinary_clone_is_not_a_worktree(self):
|
||||
os.makedirs(self.path("clone", ".git"))
|
||||
self.assertIsNone(pin.main_worktree(self.path("clone")))
|
||||
|
||||
def test_a_submodule_pointer_is_not_a_worktree(self):
|
||||
"""`.git` is a file there too, but it points into .git/modules/… and
|
||||
the tree it belongs to is already on the parent chain."""
|
||||
sub = self.path("super", "lib")
|
||||
gitdir = self.path("super", ".git", "modules", "lib")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(sub)
|
||||
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.assertIsNone(pin.main_worktree(sub))
|
||||
|
||||
def test_the_chain_wins_over_the_hop(self):
|
||||
"""The worktree branch may only find a pin the walk up would have
|
||||
missed entirely — it never overrides a nearer one."""
|
||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(tree)
|
||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.pin_at(main, "main/login")
|
||||
self.pin_at(tree, "worktree/login")
|
||||
self.assertEqual(pin.search(tree)[0], "worktree/login")
|
||||
|
||||
def test_start_dirs_are_ordered_and_deduplicated(self):
|
||||
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
|
||||
self.assertEqual(pin.start_dirs(self.path("h")),
|
||||
[self.path("p"), self.path("h"),
|
||||
os.path.abspath(os.getcwd())])
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# a script run from a worktree
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||
class TestScriptsInAWorktree(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.wt = Worktree()
|
||||
self.addCleanup(self.wt.cleanup)
|
||||
|
||||
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
|
||||
"""The acceptance criterion, run for real: cwd inside the worktree,
|
||||
the pin in the main checkout, and the call goes out under it."""
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo", "--state", "all")
|
||||
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
|
||||
self.assertNotIn("no login pinned", err)
|
||||
self.assertEqual(self.wt.logins_used(), [LOGIN])
|
||||
|
||||
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
|
||||
"""Nothing here writes a settings file, and the worktree is the last
|
||||
place one should appear: it is deleted with the worktree."""
|
||||
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
|
||||
"a second settings.local.json appeared in the worktree")
|
||||
|
||||
def test_with_no_pin_anywhere_it_still_says_so(self):
|
||||
wt = Worktree(pinned=None)
|
||||
self.addCleanup(wt.cleanup)
|
||||
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("no login pinned", err)
|
||||
self.assertEqual(wt.logins_used(), [])
|
||||
|
||||
def test_the_scripts_own_directory_is_not_a_pin_source(self):
|
||||
"""Run the worktree's script from a directory that is in no pinned
|
||||
tree. The script sits inside a repository that has a pin — and it must
|
||||
still refuse, because the pin belongs to the project being worked on,
|
||||
not to the installation."""
|
||||
outside = os.path.join(self.wt.root, "outside")
|
||||
os.makedirs(outside)
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo", cwd=outside)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("no login pinned", err)
|
||||
|
||||
def test_push_from_a_worktree_sends_the_worktree_branch(self):
|
||||
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
|
||||
worktree's scripts with cwd in the main checkout — sent the main
|
||||
checkout's branch, which is the one field `branch:` exists for."""
|
||||
write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE)
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
|
||||
"pinned-work", "--repo", "fixture/repo")
|
||||
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
|
||||
self.assertIn("created pinned-work #101", out)
|
||||
|
||||
with open(os.path.join(self.wt.tree, "tmp", "payload",
|
||||
"issue-pinned-work.json")) as f:
|
||||
payload = json.load(f)
|
||||
self.assertEqual(payload.get("ref"), "feature")
|
||||
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
|
||||
self.assertNotEqual(
|
||||
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
|
||||
"feature", "the fixture's two trees are on the same branch")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# one order, one copy of it
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.wt = Worktree()
|
||||
self.addCleanup(self.wt.cleanup)
|
||||
|
||||
def guard(self, cwd):
|
||||
"""The hook, as the harness calls it: payload on stdin, decision on
|
||||
stdout."""
|
||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||
"cwd": cwd}
|
||||
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
|
||||
"tea-guard.sh")],
|
||||
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
|
||||
capture_output=True, text=True)
|
||||
return p
|
||||
|
||||
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
|
||||
p = self.guard(self.wt.tree)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||
self.assertIn(LOGIN, got)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
|
||||
"""The regression that started this: in one directory the hook
|
||||
resolved the login and every script said there was none."""
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo")
|
||||
self.assertEqual(rc, 0, err)
|
||||
script_login = self.wt.logins_used()[0]
|
||||
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
|
||||
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
|
||||
self.assertEqual(hook_login, script_login)
|
||||
|
||||
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
|
||||
wt = Worktree(pinned=None)
|
||||
self.addCleanup(wt.cleanup)
|
||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||
"cwd": wt.tree}
|
||||
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
|
||||
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 2)
|
||||
self.assertIn("no login is pinned", p.stderr)
|
||||
|
||||
|
||||
class TestNobodyKeepsASecondCopy(unittest.TestCase):
|
||||
"""Mechanical: the search order is written in pin.py, and the two callers
|
||||
spell neither the path nor the walk."""
|
||||
|
||||
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
|
||||
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
|
||||
|
||||
def source(self, path):
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_the_path_is_spelled_once(self):
|
||||
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
|
||||
for path in self.CALLERS:
|
||||
body = self.source(path)
|
||||
for literal in ('".claude"', "'.claude'"):
|
||||
self.assertNotIn(literal, body,
|
||||
"%s builds the settings path itself" % path)
|
||||
|
||||
def test_both_callers_go_through_the_module(self):
|
||||
for path in self.CALLERS:
|
||||
self.assertIn("import pin", self.source(path),
|
||||
"%s does not resolve the pin through pin.py" % path)
|
||||
|
||||
def test_the_domain_layer_never_learns_what_a_login_is(self):
|
||||
"""The layer rule, unchanged by this: the identity module is imported
|
||||
by the bridge and by the hook, never by a domain."""
|
||||
for layer in ("issue", "page"):
|
||||
d = os.path.join(REPO, "skills", layer, "scripts")
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
body = self.source(os.path.join(d, name))
|
||||
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
|
||||
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/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")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "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"})
|
||||
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)
|
||||
# the transport resolves the login pin through skills/auth/scripts
|
||||
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "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):
|
||||
"""The rule is `tmp/` is ignored, not which file says so: this plugin
|
||||
lives under `plugins/` in a marketplace repo, and git reads every
|
||||
.gitignore on the way up. So walk up the same way git does."""
|
||||
ignored = set()
|
||||
d = REPO
|
||||
while True:
|
||||
p = os.path.join(d, ".gitignore")
|
||||
if os.path.isfile(p):
|
||||
with open(p) as f:
|
||||
ignored |= {line.strip() for line in f}
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d or os.path.isdir(os.path.join(d, ".git")):
|
||||
break
|
||||
d = parent
|
||||
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 = []
|
||||
d = SYNC_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 the stores of whichever command wrote
|
||||
them 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()
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A pull returns the unit of work: the issue AND what blocks it.
|
||||
|
||||
`--deps` used to be opt-in, so `pull.py 42` wrote a file with an empty
|
||||
`depends:` and `issue_tree.py` drew it as a root with no blockers. The edge was
|
||||
not lost — it lives in Gitea's native dependency graph — but it was not asked
|
||||
for, and the body cannot supply it: `map.from_api` writes slugs into the
|
||||
`## Depends on` prose and never `#N`. Following the graph is now the default.
|
||||
|
||||
What is asserted here:
|
||||
|
||||
1. **The default fills the graph.** A bare `pull.py <n>` fills `depends:` and
|
||||
pulls the blocker too, down to `--depth`.
|
||||
2. **`--no-deps` is the way out, and it is free.** No `depends:`, no recursion,
|
||||
and not one request beyond the issue itself.
|
||||
3. **`--deps` still works and means nothing.** Calls written against the old
|
||||
default keep running and get what they always got.
|
||||
4. **The cost is one request per stored issue.** The native links are fetched
|
||||
once and used twice — for `depends:` and for the walk. Never twice.
|
||||
5. **Filter mode follows blockers out of the selection, deliberately.** A
|
||||
blocker no filter selected still lands in the store and does not spend
|
||||
`--limit`; a closed one is dropped like any other closed issue, and so is
|
||||
the edge to it. An issue the filter dropped costs no link request at all.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as the other suites do it, and the
|
||||
stub records every call so "how many requests" is an observation. No network,
|
||||
and no test touches the developer's store: each builds its own in a
|
||||
`tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import pull # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
def payload(number, title, state="open"):
|
||||
return {"number": number, "title": title, "body": BODY, "state": state,
|
||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||
"milestone": None, "ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, with a native dependency graph.
|
||||
|
||||
`listed` is what the list endpoint serves — the filter's selection. `extra`
|
||||
exists and is fetchable by number but is in no selection, which is how a
|
||||
blocker outside the filter is modelled. `deps` maps a blocked issue's number
|
||||
to the numbers that block it, the direction `GET …/dependencies` reads.
|
||||
"""
|
||||
|
||||
def __init__(self, listed=(), extra=(), deps=None):
|
||||
self.listed = list(listed)
|
||||
self.issues = {p["number"]: p for p in list(listed) + list(extra)}
|
||||
self.deps = {int(k): list(v) for k, v in (deps or {}).items()}
|
||||
self.calls = [] # (method, path), in request order
|
||||
|
||||
# -- what the tests read off it ----------------------------------------
|
||||
|
||||
def paths(self, suffix):
|
||||
return [p for m, p in self.calls if p.endswith(suffix)]
|
||||
|
||||
def issue_gets(self):
|
||||
"""`GET …/issues/<n>` — one issue fetched by number."""
|
||||
return [p for m, p in self.calls
|
||||
if m == "GET" and p.startswith("%s/issues/" % BASE)
|
||||
and p.rsplit("/", 1)[1].isdigit()]
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
path, _, qs = endpoint.partition("?")
|
||||
q = urllib.parse.parse_qs(qs)
|
||||
self.calls.append((method, path))
|
||||
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||
return self.listed[(page - 1) * per:(page - 1) * per + per]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies") and method == "GET":
|
||||
n = int(path.split("/issues/")[1].split("/")[0])
|
||||
return [self.issues[b] for b in self.deps.get(n, []) if b in self.issues]
|
||||
|
||||
if path.startswith("%s/issues/" % BASE) and method == "GET":
|
||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullDepsTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, no git and no network."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-deps-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||
os.makedirs(self.root)
|
||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def serve(self, listed=(), extra=(), deps=None):
|
||||
self.fake = FakeTracker(listed, extra, deps)
|
||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return self.fake
|
||||
|
||||
def blocked_pair(self):
|
||||
"""#10 "Second thing" is blocked by #7 "First thing"."""
|
||||
return self.serve(listed=[payload(10, "Second thing"),
|
||||
payload(7, "First thing")],
|
||||
deps={10: [7]})
|
||||
|
||||
def run_pull(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
pull.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def stored(self):
|
||||
return sorted(issue.all_ids(self.root))
|
||||
|
||||
def depends_of(self, id):
|
||||
return issue.load(self.root, id).depends
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. the default fills the graph
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DepsAreTheDefaultTest(PullDepsTestCase):
|
||||
|
||||
def test_a_bare_pull_fills_depends(self):
|
||||
"""The acceptance criterion, and the whole point: no flag, and the file
|
||||
knows what blocks it."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||
|
||||
def test_a_bare_pull_stores_the_blocker(self):
|
||||
"""`depends:` pointing at a file that is not there would be worse than
|
||||
an empty one — the blocker comes with it."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertIn("first-thing", self.stored())
|
||||
|
||||
def test_the_walk_is_recursive(self):
|
||||
"""A blocker's blocker is context too, down to --depth (default 3)."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||
self.run_pull("1")
|
||||
self.assertEqual(self.stored(), ["thing-1", "thing-2", "thing-3", "thing-4"],
|
||||
"the default depth of 3 was not what was walked")
|
||||
|
||||
def test_depth_bounds_the_walk(self):
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||
self.run_pull("1", "--depth", "1")
|
||||
self.assertEqual(self.stored(), ["thing-1", "thing-2"])
|
||||
|
||||
def test_the_graph_hint_is_printed_when_there_is_a_graph(self):
|
||||
self.blocked_pair()
|
||||
out, _ = self.run_pull("10")
|
||||
self.assertIn("issue_tree.py", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. --no-deps is the way out, and it is free
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NoDepsOptsOutTest(PullDepsTestCase):
|
||||
|
||||
def test_no_deps_leaves_depends_empty(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.depends_of("second-thing"), [])
|
||||
|
||||
def test_no_deps_does_not_pull_the_blocker(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.stored(), ["second-thing"])
|
||||
|
||||
def test_no_deps_spends_no_extra_request(self):
|
||||
"""The other half of the criterion: not the links, not the blocker.
|
||||
One issue asked for, one request made."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.fake.paths("/dependencies"), [])
|
||||
self.assertEqual(self.fake.issue_gets(), ["%s/issues/10" % BASE])
|
||||
|
||||
def test_no_deps_prints_no_graph_hint(self):
|
||||
self.blocked_pair()
|
||||
out, _ = self.run_pull("10", "--no-deps")
|
||||
self.assertNotIn("issue_tree.py", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. --deps is still accepted, and means nothing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DepsFlagIsANoOpTest(PullDepsTestCase):
|
||||
|
||||
def test_the_flag_is_still_accepted(self):
|
||||
"""Existing calls and the /tea:sync command tables must not break."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--deps")
|
||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||
|
||||
def test_it_changes_nothing_about_the_run(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--deps")
|
||||
with_flag = (self.stored(), self.depends_of("second-thing"),
|
||||
list(self.fake.calls))
|
||||
|
||||
self.setUp()
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual((self.stored(), self.depends_of("second-thing"),
|
||||
list(self.fake.calls)), with_flag)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. one request per stored issue
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TheCostIsOneRequestPerIssueTest(PullDepsTestCase):
|
||||
|
||||
def test_the_links_are_fetched_once_per_issue(self):
|
||||
"""They fill `depends:` AND steer the walk; fetching them twice is
|
||||
double the price the docstring quotes."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/10/dependencies" % BASE,
|
||||
"%s/issues/7/dependencies" % BASE])
|
||||
|
||||
def test_a_bulk_pull_costs_one_per_issue(self):
|
||||
"""The number the docstring quotes: one list request, then one link
|
||||
request per issue that lands in the store."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 21)])
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(len(self.fake.paths("/dependencies")), 20)
|
||||
self.assertEqual(len(self.fake.paths("/issues")), 1)
|
||||
|
||||
def test_a_cached_issue_costs_its_links_and_nothing_else(self):
|
||||
"""--cached stops the body and the thread, not the graph: a cached
|
||||
issue's blockers can be missing from disk even when it is not."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps") # only #10 on disk
|
||||
self.fake.calls = []
|
||||
self.run_pull("10", "--cached")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/10/dependencies" % BASE,
|
||||
"%s/issues/7/dependencies" % BASE])
|
||||
self.assertIn("first-thing", self.stored())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. filter mode follows blockers out of the selection
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FilterModeFollowsOutwardTest(PullDepsTestCase):
|
||||
|
||||
def test_a_blocker_outside_the_filter_lands_in_the_store(self):
|
||||
"""Documented as deliberate: a blocker is followed because a stored
|
||||
issue named it, not because the filter selected it."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Outside thing")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(self.stored(), ["outside-thing", "selected-thing"])
|
||||
self.assertEqual(self.depends_of("selected-thing"), ["outside-thing"])
|
||||
|
||||
def test_a_blocker_does_not_spend_the_limit(self):
|
||||
"""--limit counts the selection's writes; the graph is not part of the
|
||||
selection, so the store can legitimately hold more than N."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 5)],
|
||||
extra=[payload(100 + n, "Blocker %d" % n) for n in range(1, 5)],
|
||||
deps={n: [100 + n] for n in range(1, 5)})
|
||||
self.run_pull("-q", "x", "--limit", "2")
|
||||
self.assertEqual(self.stored(),
|
||||
["blocker-1", "blocker-2", "thing-1", "thing-2"])
|
||||
|
||||
def test_a_closed_blocker_is_dropped_with_the_edge_to_it(self):
|
||||
"""The documented exception. Closed is not a unit of work, so filter
|
||||
mode drops it like any other closed issue — and `depends:` must not be
|
||||
left pointing at a file that is not there."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Closed blocker", state="closed")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(self.stored(), ["selected-thing"])
|
||||
self.assertEqual(self.depends_of("selected-thing"), [])
|
||||
|
||||
def test_a_closed_blocker_is_stored_in_key_mode(self):
|
||||
"""An address is not a bulk read: `pull.py 1` has no closed rule."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Closed blocker", state="closed")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("1")
|
||||
self.assertEqual(self.stored(), ["closed-blocker", "selected-thing"])
|
||||
|
||||
def test_a_dropped_closed_issue_costs_no_link_request(self):
|
||||
"""Nothing was stored for it, so there is no unit of work to complete
|
||||
— and its own blockers are not dragged in behind it."""
|
||||
self.serve(listed=[payload(1, "Closed thing", state="closed"),
|
||||
payload(2, "Open thing")],
|
||||
extra=[payload(50, "Blocker of the closed one")],
|
||||
deps={1: [50]})
|
||||
self.run_pull("-q", "x", "--state", "all")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/2/dependencies" % BASE])
|
||||
self.assertEqual(self.stored(), ["open-thing"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
`pull.py --limit N` bounds the WRITE, not the selection.
|
||||
|
||||
The bug this file exists to keep dead: the limit used to cut the list of
|
||||
payloads before pull.py dropped the closed ones, so a milestone whose first
|
||||
issues are closed spent the budget on issues that never reached disk —
|
||||
`--limit 20` wrote twelve, and the docstring promised twenty.
|
||||
|
||||
What is asserted, in the order the fix has to hold it:
|
||||
|
||||
1. **The count is of files.** N issues under the filter that would be stored →
|
||||
exactly N files, however many closed ones were enumerated on the way.
|
||||
2. **Pagination serves the budget.** More pages are requested while the budget
|
||||
is unfilled, and the page after the one that fills it is never requested.
|
||||
3. **The scan is bounded.** A filter that matches almost only closed issues
|
||||
stops after `_gitea.PAGE_SLACK` times the ideal page count, says so, and
|
||||
returns short — it does not walk the tracker.
|
||||
4. **`remote.py` is unchanged.** Its `--limit` still caps the listing, closed
|
||||
issues included, because it writes nothing there is a limit for.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, the way the other suites do it, and
|
||||
the stub serves `page=` / `limit=` itself so the request pattern is a real
|
||||
observation and not an assumption. No network, and no test writes to the
|
||||
developer's store: each one builds its own in a `tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
import remote # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
def payload(number, state="open", title=None, comments=0):
|
||||
return {"number": number, "title": title or "Issue number %d" % number,
|
||||
"body": BODY, "state": state, "comments": comments,
|
||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||
"ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
|
||||
def alternating(count, first="closed"):
|
||||
"""`count` issues, every other one closed. The shape of the bug report:
|
||||
closed issues sitting in front of the open ones, in page order."""
|
||||
other = "open" if first == "closed" else "closed"
|
||||
return [payload(n, first if n % 2 else other) for n in range(1, count + 1)]
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from a list, with real pagination.
|
||||
|
||||
It slices on the `page=` and `limit=` it was given rather than ignoring
|
||||
them, so "which pages were requested" is something the test can read off
|
||||
`self.list_pages` instead of inferring."""
|
||||
|
||||
def __init__(self, payloads):
|
||||
self.payloads = list(payloads)
|
||||
self.list_pages = [] # (page, per_page), in request order
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
path, _, qs = endpoint.partition("?")
|
||||
q = urllib.parse.parse_qs(qs)
|
||||
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||
self.list_pages.append((page, per))
|
||||
return self.payloads[(page - 1) * per:(page - 1) * per + per]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
return []
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
n = int(path.rsplit("/", 1)[1])
|
||||
for p in self.payloads:
|
||||
if p["number"] == n:
|
||||
return p
|
||||
return None
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullLimitTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, no git and no network."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-limit-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||
os.makedirs(self.root)
|
||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def serve(self, payloads):
|
||||
self.fake = FakeTracker(payloads)
|
||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return self.fake
|
||||
|
||||
def run_pull(self, *argv):
|
||||
return self._run(pull, "pull.py", argv)
|
||||
|
||||
def run_remote(self, *argv):
|
||||
return self._run(remote, "remote.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
mod.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def stored(self):
|
||||
return sorted(issue.all_ids(self.root))
|
||||
|
||||
def assertStoredCount(self, n, why=""):
|
||||
got = self.stored()
|
||||
self.assertEqual(len(got), n, "%d issue(s) in the store, wanted %d%s: %s"
|
||||
% (len(got), n, why and " — " + why, got))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. the count is of files
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LimitCountsWritesTest(PullLimitTestCase):
|
||||
|
||||
def test_closed_issues_do_not_spend_the_budget(self):
|
||||
"""The regression. Half the selection is closed and stands in front of
|
||||
the open ones; the limit still buys ten files."""
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
|
||||
def test_only_open_issues_landed(self):
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
for id in self.stored():
|
||||
self.assertEqual(issue.load(self.root, id).state, "open")
|
||||
|
||||
def test_the_dropped_ones_are_still_reported(self):
|
||||
"""Enumerated-and-dropped is not silence: the closed ones seen on the
|
||||
pages that were fetched are counted on stderr."""
|
||||
self.serve(alternating(40))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertIn("closed issue(s) enumerated, not stored", err)
|
||||
|
||||
def test_a_closed_issue_already_in_the_store_spends_it(self):
|
||||
"""It is refreshed rather than dropped — that is a write, so it counts.
|
||||
The limit is on what the store holds when the run ends, and this issue
|
||||
is in it."""
|
||||
kept = issue.Issue(id="already-here", title="Already here", body=BODY,
|
||||
labels=["type/task"], origin="gitea",
|
||||
extra={"gitea": gmap.remote_key(REPO, 1)})
|
||||
issue.save(self.root, kept)
|
||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 1): "already-here"})
|
||||
|
||||
self.serve(alternating(40)) # #1 is closed, and is on disk
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual(issue.load(self.root, "already-here").state, "closed",
|
||||
"a stored issue must learn it was closed")
|
||||
|
||||
def test_state_closed_writes_closed_ones(self):
|
||||
"""Nothing above may leak into the mode where closed IS the selection."""
|
||||
self.serve([payload(n, "closed") for n in range(1, 21)])
|
||||
self.run_pull("-q", "x", "--state", "closed", "--limit", "6")
|
||||
self.assertStoredCount(6)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. pagination serves the budget
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PaginationFollowsTheBudgetTest(PullLimitTestCase):
|
||||
|
||||
def test_more_pages_are_fetched_until_the_budget_is_full(self):
|
||||
"""One page of ten holds five open issues, so ten files cost two."""
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual([p for p, _ in self.fake.list_pages], [1, 2])
|
||||
|
||||
def test_the_page_after_the_last_needed_one_is_never_requested(self):
|
||||
"""The budget fills inside page 2; page 3 exists and must not be asked
|
||||
for. Bounding the write must not become fetching the whole repo."""
|
||||
self.serve(alternating(200))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertEqual(len(self.fake.list_pages), 2,
|
||||
"extra pages requested: %r" % (self.fake.list_pages,))
|
||||
|
||||
def test_an_unfiltered_selection_still_costs_one_page(self):
|
||||
"""Nothing is dropped, so nothing changes: the old arithmetic holds."""
|
||||
self.serve([payload(n) for n in range(1, 60)])
|
||||
self.run_pull("-q", "x", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
def test_running_out_of_pages_gives_a_short_answer(self):
|
||||
"""Six issues, three of them open, `--limit 10`: three files, no crash,
|
||||
and no page beyond the last."""
|
||||
self.serve(alternating(6))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(3)
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. the scan is bounded
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ScanIsBoundedTest(PullLimitTestCase):
|
||||
|
||||
def test_a_selection_of_only_closed_issues_stops_at_the_page_budget(self):
|
||||
self.serve([payload(n, "closed") for n in range(1, 501)])
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(0)
|
||||
self.assertEqual(len(self.fake.list_pages), _gitea.PAGE_SLACK,
|
||||
"the scan walked past its budget: %r" % (self.fake.list_pages,))
|
||||
self.assertIn("short of --limit", err)
|
||||
|
||||
def test_a_full_budget_does_not_warn(self):
|
||||
"""The warning means "there may be more"; it must not fire on a run
|
||||
that got everything it asked for."""
|
||||
self.serve(alternating(40))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertNotIn("short of --limit", err)
|
||||
|
||||
def test_a_selection_that_ran_out_does_not_warn(self):
|
||||
"""Six issues in the repo and the server said so — that is an answer,
|
||||
not a truncation."""
|
||||
self.serve(alternating(6))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertNotIn("short of --limit", err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. remote.py is the deliberate exception
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class RemoteListingIsUnchangedTest(PullLimitTestCase):
|
||||
|
||||
def test_the_listing_limit_still_counts_lines_not_writes(self):
|
||||
"""remote.py writes nothing, so there is no write to bound: ten lines
|
||||
out, closed ones among them, one request."""
|
||||
self.serve(alternating(40))
|
||||
out, _ = self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||
numbered = [l for l in out.splitlines() if l.startswith("#")]
|
||||
self.assertEqual(len(numbered), 10)
|
||||
self.assertTrue(any("closed" in l for l in numbered),
|
||||
"a listing that hides closed issues is not a listing")
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
def test_it_leaves_the_store_alone(self):
|
||||
self.serve(alternating(40))
|
||||
self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(0, "discovery wrote to the store")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the transport on its own
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ListIssuesKeepTest(PullLimitTestCase):
|
||||
"""`_gitea.list_issues` without a caller in front of it — the counting rule
|
||||
is the transport's, and it is testable without a store."""
|
||||
|
||||
def list(self, payloads, **kw):
|
||||
self.serve(payloads)
|
||||
return _gitea.list_issues("test-login", BASE, state="all", **kw)
|
||||
|
||||
def test_without_keep_the_limit_caps_the_selection(self):
|
||||
got, _ = self.list(alternating(40), limit=10)
|
||||
self.assertEqual(len(got), 10)
|
||||
|
||||
def test_with_keep_the_limit_caps_the_kept(self):
|
||||
got, _ = self.list(alternating(40), limit=10,
|
||||
keep=lambda p: p["state"] == "open")
|
||||
self.assertEqual(len([p for p in got if p["state"] == "open"]), 10)
|
||||
|
||||
def test_the_rejected_ones_come_back_too(self):
|
||||
"""They were enumerated. The caller reports them; the transport does
|
||||
not get to throw away what it did not count."""
|
||||
got, _ = self.list(alternating(40), limit=10,
|
||||
keep=lambda p: p["state"] == "open")
|
||||
self.assertTrue([p for p in got if p["state"] == "closed"])
|
||||
|
||||
def test_a_limit_below_one_is_refused(self):
|
||||
"""The page arithmetic divides by the page size, and a limit of zero
|
||||
used to make that a traceback. It is a usage error, so it reads like
|
||||
one."""
|
||||
with self.assertRaises(SystemExit):
|
||||
self.list(alternating(4), limit=0)
|
||||
|
||||
def test_pull_requests_never_count(self):
|
||||
"""`matches` drops them, so they cannot spend the budget either."""
|
||||
mixed = []
|
||||
for n in range(1, 41):
|
||||
p = payload(n)
|
||||
if n % 2:
|
||||
p["pull_request"] = {"merged": False}
|
||||
mixed.append(p)
|
||||
got, _ = self.list(mixed, limit=10, keep=lambda p: True)
|
||||
self.assertEqual(len(got), 10)
|
||||
self.assertFalse([p for p in got if p.get("pull_request")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Native Gitea dependency links, written by push.py.
|
||||
|
||||
The transport is stubbed at exactly one seam — `_gitea.api`, the single
|
||||
function that shells out to `tea` — so everything above it runs for real:
|
||||
argument parsing, validation, topological order, the id map, map.py's payload
|
||||
shapes and _gitea's own endpoint/body construction. Nothing here touches a
|
||||
network, and no test may ever be made to.
|
||||
|
||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import push # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903,
|
||||
"comp/sync": 904}
|
||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Depends on
|
||||
- first-thing — ставит фундамент, без него второй не собрать
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
BODY_NO_DEPS = """## Summary
|
||||
Прозаическое описание.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
class FakeGitea(object):
|
||||
"""A `tea api` that answers from memory and remembers what it was asked.
|
||||
|
||||
Dependency links are kept the way Gitea keeps them: per blocked issue, a
|
||||
set of (repo, number) blockers. That is what makes the idempotence test
|
||||
meaningful — the second push sees the link the first one made."""
|
||||
|
||||
def __init__(self, next_number=101):
|
||||
self.calls = [] # (method, endpoint, payload)
|
||||
self.next_number = next_number
|
||||
self.deps = {} # number -> {(repo, number)}
|
||||
self.titles = {} # number -> title
|
||||
self.fail_dependency_post = False
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def writes(self):
|
||||
"""Every non-GET call. `--dry-run` must produce an empty list."""
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
def dep_posts(self):
|
||||
return [c for c in self.calls
|
||||
if c[0] == "POST" and c[1].endswith("/dependencies")]
|
||||
|
||||
def issue_payload(self, number, labels=()):
|
||||
return {"number": number,
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"title": self.titles.get(number, ""),
|
||||
"labels": [{"name": LABEL_NAMES[i]} for i in labels
|
||||
if i in LABEL_NAMES],
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if path == "%s/labels" % BASE and method == "GET":
|
||||
# Every label the run could ask for, so nothing is ever created.
|
||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||
|
||||
if path == "%s/issues" % BASE and method == "POST":
|
||||
number = self.next_number
|
||||
self.next_number += 1
|
||||
self.titles[number] = (payload or {}).get("title", "")
|
||||
# Echo the labels back, or push re-applies them with a PUT.
|
||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
number = int(path.split("/issues/")[1].split("/")[0])
|
||||
if method == "GET":
|
||||
return [dict(self.issue_payload(n), repository={"full_name": r})
|
||||
for r, n in sorted(self.deps.get(number, set()))]
|
||||
if method == "POST":
|
||||
if self.fail_dependency_post:
|
||||
return None
|
||||
key = ("%s/%s" % (payload["owner"], payload["repo"]),
|
||||
int(payload["index"]))
|
||||
self.deps.setdefault(number, set()).add(key)
|
||||
return self.issue_payload(number)
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
self.titles[number] = (payload or {}).get("title", self.titles.get(number, ""))
|
||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PushTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake transport, and no git."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||
self.fake = FakeGitea()
|
||||
patches = [
|
||||
mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||
# push reads the current branch from git; a temp store has none and
|
||||
# the runner's branch would leak into the payload.
|
||||
mock.patch.object(push, "git_branch", lambda: "test-branch"),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
|
||||
origin=issue.LOCAL):
|
||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||
depends=list(depends), origin=origin,
|
||||
extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def repull(self, id, body=BODY_NO_DEPS, depends=()):
|
||||
"""Put a pushed issue back the way `pull.py` would.
|
||||
|
||||
Push deletes the file, so anything that pushes the same issue twice has
|
||||
to fetch it in between — which is the workflow, not a test artifact.
|
||||
The slug and the number come from the ledger, exactly as `pull.id_for`
|
||||
would resolve them."""
|
||||
number = self.number_of(id)
|
||||
self.assertIsNotNone(number, "%s was never pushed" % id)
|
||||
return self.write_issue(id, self.fake.titles[number], body=body,
|
||||
depends=depends, origin="gitea",
|
||||
extra={"gitea": "%s#%d" % (REPO, number)})
|
||||
|
||||
def two_issues(self):
|
||||
"""first-thing, and second-thing which depends on it."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||
depends=["first-thing"])
|
||||
|
||||
def run_push(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
push.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def number_of(self, id):
|
||||
"""The number an id was pushed under, or None.
|
||||
|
||||
Read off `.remote.json` rather than the issue file: a successful push
|
||||
deletes the file, and the ledger is what is left behind."""
|
||||
for key, got in _gitea.load_map(self.root).items():
|
||||
if got == id:
|
||||
return gmap.parse_remote_key(key)[1]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _gitea: the POST body, and the pre-check that reads links back
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class AddDependencyTest(unittest.TestCase):
|
||||
|
||||
def test_post_body_is_issue_meta(self):
|
||||
"""POST /issues/{index}/dependencies with IssueMeta for the BLOCKER.
|
||||
|
||||
Confirmed against the instance's swagger.v1.json (Gitea 1.26.1):
|
||||
"Make the issue in the url depend on the issue in the form." """
|
||||
calls = []
|
||||
|
||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||
calls.append((method, endpoint, payload))
|
||||
return {"number": 102}
|
||||
|
||||
with mock.patch.object(_gitea, "api", fake_api):
|
||||
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
|
||||
|
||||
self.assertTrue(ok)
|
||||
method, endpoint, payload = calls[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE)
|
||||
self.assertEqual(payload, {"index": 101, "owner": "claude-skills",
|
||||
"repo": "tea"})
|
||||
|
||||
def test_blocker_may_live_in_another_repo(self):
|
||||
"""IssueMeta carries owner/repo precisely so it can."""
|
||||
seen = {}
|
||||
|
||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||
seen.update(payload or {})
|
||||
return {"number": 1}
|
||||
|
||||
with mock.patch.object(_gitea, "api", fake_api):
|
||||
_gitea.add_dependency("l", BASE, 102, "other-org/infra", 7)
|
||||
self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"})
|
||||
|
||||
def test_failure_is_reported_not_raised(self):
|
||||
"""409 (link already there) and friends come back as False."""
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101))
|
||||
|
||||
def test_unparseable_repo_makes_no_request(self):
|
||||
called = []
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)):
|
||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101))
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_native_dep_pairs_reads_repo_and_number(self):
|
||||
payload = [{"number": 101, "repository": {"full_name": REPO}},
|
||||
{"number": 7, "repository": {"full_name": "other-org/infra"}}]
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: payload):
|
||||
got = _gitea.native_dep_pairs("l", BASE, 102)
|
||||
self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)})
|
||||
|
||||
def test_native_dep_pairs_empty_when_unsupported(self):
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||
self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# push: the whole run
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PushCreatesLinksTest(PushTestCase):
|
||||
|
||||
def test_link_created_after_both_have_numbers(self):
|
||||
"""One run, topological order, one native link — no second pass."""
|
||||
self.two_issues()
|
||||
out, _ = self.run_push()
|
||||
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
self.assertLess(first, second, "blocker must be created first")
|
||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||
|
||||
def test_link_direction_matches_what_pull_reads_back(self):
|
||||
"""The link hangs off the BLOCKED issue, which is where native_deps
|
||||
looks — push and `pull.py --deps` must agree or the round trip lies."""
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
second = self.number_of("second-thing")
|
||||
with mock.patch.object(_gitea, "api", self.fake.api):
|
||||
self.assertEqual(_gitea.native_deps("l", BASE, second),
|
||||
[self.number_of("first-thing")])
|
||||
|
||||
def test_issue_without_dependencies_makes_no_dependency_request(self):
|
||||
"""Not even the idempotence GET — it is skipped when there is nothing
|
||||
to link, so the common case costs no extra round trip."""
|
||||
self.write_issue("lonely-thing", "Lonely thing")
|
||||
self.run_push()
|
||||
self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], [])
|
||||
|
||||
|
||||
class LocalOnlyDependencyTest(PushTestCase):
|
||||
|
||||
def test_local_dependency_is_warned_and_not_linked(self):
|
||||
self.two_issues()
|
||||
out, err = self.run_push("second-thing")
|
||||
|
||||
self.assertEqual(self.fake.dep_posts(), [])
|
||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||
self.assertNotIn("depends on ", out)
|
||||
self.assertIsNone(self.number_of("first-thing"))
|
||||
|
||||
|
||||
class IdempotenceTest(PushTestCase):
|
||||
|
||||
def test_repeat_push_does_not_duplicate_the_link(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.assertEqual(len(self.fake.dep_posts()), 1)
|
||||
|
||||
self.repull("first-thing")
|
||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||
self.run_push("--update")
|
||||
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||
{(REPO, self.number_of("first-thing"))})
|
||||
|
||||
def test_a_failing_link_warns_and_the_run_finishes(self):
|
||||
"""A 409 or any other refusal must not abort a push that has already
|
||||
created issues."""
|
||||
self.two_issues()
|
||||
self.fake.fail_dependency_post = True
|
||||
out, err = self.run_push()
|
||||
|
||||
self.assertIn("could not link", err)
|
||||
self.assertIn("index:", out) # the run completed
|
||||
self.assertIsNotNone(self.number_of("second-thing"))
|
||||
|
||||
|
||||
class UpdateCarriesNewLinksTest(PushTestCase):
|
||||
|
||||
def test_dependency_added_after_the_first_push_is_linked_by_update(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing")
|
||||
self.run_push()
|
||||
self.assertEqual(self.fake.dep_posts(), [])
|
||||
|
||||
# The issue comes back from Gitea, and the dependency is added to the
|
||||
# copy that came back — there is no other copy to add it to.
|
||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||
|
||||
self.run_push("--update", "second-thing")
|
||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||
{(REPO, self.number_of("first-thing"))})
|
||||
|
||||
|
||||
class DryRunTest(PushTestCase):
|
||||
|
||||
def test_dry_run_names_the_links_and_writes_nothing(self):
|
||||
self.two_issues()
|
||||
out, _ = self.run_push("--dry-run")
|
||||
|
||||
self.assertEqual(self.fake.calls, [], "--dry-run made a request")
|
||||
self.assertIn("link -> #? (first-thing, created by this run)", out)
|
||||
self.assertIn("1 dependency link(s) would be created", out)
|
||||
|
||||
def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self):
|
||||
self.write_issue("first-thing", "First thing",
|
||||
extra={"gitea": "%s#101" % REPO})
|
||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||
depends=["first-thing"])
|
||||
out, _ = self.run_push("--dry-run")
|
||||
|
||||
self.assertIn("link -> %s#101 (first-thing)" % REPO, out)
|
||||
self.assertEqual(self.fake.writes, [])
|
||||
|
||||
def test_dry_run_says_a_local_dependency_gets_no_link(self):
|
||||
self.two_issues()
|
||||
out, _ = self.run_push("--dry-run", "second-thing")
|
||||
self.assertIn("no link: first-thing is local-only", out)
|
||||
self.assertIn("0 dependency link(s) would be created", out)
|
||||
|
||||
|
||||
class BodyIsVerbatimTest(PushTestCase):
|
||||
"""The prose is untouched. The id marker is the one thing push adds, and it
|
||||
comes straight back off — `strip_id_marker` is the inverse."""
|
||||
|
||||
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
|
||||
"""map.py deliberately never edits the prose. Linking must not start."""
|
||||
self.two_issues()
|
||||
before = issue.load(self.root, "second-thing").body
|
||||
self.run_push()
|
||||
|
||||
created = [c for c in self.fake.calls
|
||||
if c[0] == "POST" and c[1] == "%s/issues" % BASE]
|
||||
sent = [c[2]["body"] for c in created]
|
||||
second_body = [b for b in sent if "Depends on" in b][0]
|
||||
|
||||
self.assertIn("- first-thing — ставит фундамент", second_body)
|
||||
self.assertNotIn("#101", second_body)
|
||||
self.assertEqual(gmap.strip_id_marker(second_body), before)
|
||||
|
||||
def test_body_survives_a_second_push_unchanged(self):
|
||||
self.two_issues()
|
||||
before = issue.load(self.root, "second-thing").body
|
||||
self.run_push()
|
||||
|
||||
self.repull("first-thing")
|
||||
self.repull("second-thing", body=before, depends=["first-thing"])
|
||||
self.assertEqual(issue.load(self.root, "second-thing").body, before)
|
||||
|
||||
self.run_push("--update")
|
||||
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
||||
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
|
||||
|
||||
|
||||
class DepStateTest(PushTestCase):
|
||||
"""The classifier both the dry run and the real run read from."""
|
||||
|
||||
def test_classifies_linked_in_run_and_local(self):
|
||||
issues = {
|
||||
"pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}),
|
||||
"coming": issue.Issue(id="coming"),
|
||||
"local": issue.Issue(id="local"),
|
||||
}
|
||||
iss = issue.Issue(id="dependent",
|
||||
depends=["pushed", "coming", "local", "ghost"])
|
||||
got = push.dep_state(iss, issues, {"coming", "dependent"})
|
||||
|
||||
self.assertEqual(got, [("pushed", "%s#101" % REPO, False),
|
||||
("coming", None, True),
|
||||
("local", None, False)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Where the issue store is, and that the answer does not depend on cwd.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything — the same rule the scripts under test
|
||||
live by. `skills/*/scripts/` are not packages, so the domain module is imported
|
||||
by path.
|
||||
|
||||
Most of these tests do not touch this repository at all. They build a throwaway
|
||||
repo in a temp directory — a `.git` marker, a copy of both script layers, a
|
||||
store with two issues — and run the real scripts inside it as subprocesses with
|
||||
different working directories. That is the only honest way to test a cwd bug:
|
||||
importing the module would resolve the store once, against the wrong tree.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
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")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
ALPHA = """\
|
||||
---
|
||||
id: alpha-issue
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: []
|
||||
origin: local
|
||||
---
|
||||
# Alpha issue
|
||||
|
||||
## Summary
|
||||
Первый issue фикстуры.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы в store что-то лежало.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
BETA = """\
|
||||
---
|
||||
id: beta-issue
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: [alpha-issue]
|
||||
origin: local
|
||||
---
|
||||
# Beta issue
|
||||
|
||||
## Summary
|
||||
Второй issue фикстуры, зависит от первого.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Depends on
|
||||
- alpha-issue
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы у графа было ребро.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
|
||||
def run(script, *args, **kw):
|
||||
"""Run one of the plugin's scripts and return (rc, stdout, stderr)."""
|
||||
cwd = kw.pop("cwd")
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
|
||||
class FakeRepo(object):
|
||||
"""A self-contained repository in a temp directory.
|
||||
|
||||
Both script layers are copied in, so `__file__`-anchored resolution lands
|
||||
inside the fixture and never on the developer's real store.
|
||||
"""
|
||||
|
||||
def __init__(self, with_store=True, issues=(ALPHA, BETA)):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
# realpath: on macOS $TMPDIR is a symlink, and a child process 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)
|
||||
# the transport resolves the login pin through skills/auth/scripts
|
||||
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
|
||||
os.makedirs(self.path("sub", "deeper"))
|
||||
|
||||
if with_store:
|
||||
os.makedirs(self.store)
|
||||
for text in issues:
|
||||
id = text.split("id: ", 1)[1].split("\n", 1)[0]
|
||||
with open(os.path.join(self.store, "%s.md" % id), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
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")
|
||||
|
||||
def script(self, layer, name):
|
||||
return self.path("skills", layer, "scripts", name)
|
||||
|
||||
def everywhere(self):
|
||||
"""Working directories that must all produce the same answer: the repo
|
||||
root, a plain subdirectory, a deeper one, the script directory itself,
|
||||
and — the case from the bug report — inside the store."""
|
||||
return [self.root, self.path("sub"), self.path("sub", "deeper"),
|
||||
self.path("skills", "issue", "scripts"), self.store]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# resolution, in isolation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestResolution(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_repo_root_found_from_any_depth(self):
|
||||
for start in self.repo.everywhere():
|
||||
self.assertEqual(issue.repo_root(start), self.repo.root, start)
|
||||
|
||||
def test_agents_md_works_as_a_marker(self):
|
||||
"""A checkout without .git — the plugin copied out of git — still
|
||||
resolves, because AGENTS.md marks the root too."""
|
||||
shutil.rmtree(self.repo.path(".git"))
|
||||
open(self.repo.path("AGENTS.md"), "w").close()
|
||||
self.assertEqual(issue.repo_root(self.repo.path("sub", "deeper")),
|
||||
self.repo.root)
|
||||
|
||||
def test_nearest_marker_wins(self):
|
||||
"""A repo inside a repo (a worktree, a vendored copy) resolves to the
|
||||
inner one, not the outer."""
|
||||
inner = self.repo.path("sub", "inner")
|
||||
os.makedirs(os.path.join(inner, ".git"))
|
||||
self.assertEqual(issue.repo_root(inner), inner)
|
||||
self.assertEqual(issue.repo_root(self.repo.root), self.repo.root)
|
||||
|
||||
def test_store_root_is_repo_root_plus_tmp_issues(self):
|
||||
self.assertEqual(issue.store_root(self.repo.path("sub", "deeper")),
|
||||
self.repo.store)
|
||||
|
||||
def test_default_root_is_absolute(self):
|
||||
"""The whole point: a default that cannot mean two directories."""
|
||||
self.assertTrue(os.path.isabs(issue.ISSUE_ROOT), issue.ISSUE_ROOT)
|
||||
self.assertEqual(issue.ISSUE_ROOT,
|
||||
os.path.join(REPO, "tmp", "issues"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the acceptance criterion: same answer from any subdirectory
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSameFromAnywhere(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def assertSameEverywhere(self, layer, name, *args):
|
||||
"""Run the script from the repo root and from every other directory;
|
||||
every result must be byte-identical to the one from the root."""
|
||||
dirs = self.repo.everywhere()
|
||||
base = run(self.repo.script(layer, name), *args, cwd=dirs[0])
|
||||
self.assertEqual(base[0], 0, "%s failed at the repo root:\n%s" % (name, base[2]))
|
||||
for d in dirs[1:]:
|
||||
self.assertEqual(run(self.repo.script(layer, name), *args, cwd=d), base,
|
||||
"%s disagrees when run from %s" % (name, d))
|
||||
return base
|
||||
|
||||
def test_issue_check(self):
|
||||
rc, out, _ = self.assertSameEverywhere("issue", "issue_check.py")
|
||||
self.assertIn("ok alpha-issue", out)
|
||||
self.assertIn("2 issue(s) checked, 0 with errors", out)
|
||||
|
||||
def test_issue_tree(self):
|
||||
_, out, _ = self.assertSameEverywhere("issue", "issue_tree.py")
|
||||
self.assertIn("beta-issue", out)
|
||||
self.assertIn("alpha-issue", out)
|
||||
|
||||
def test_issue_index(self):
|
||||
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
|
||||
self.assertIn("2 issue(s)", out)
|
||||
self.assertIn(os.path.join(self.repo.store, "INDEX.md"), out)
|
||||
|
||||
def test_no_second_store_is_ever_created(self):
|
||||
"""The bug's worst symptom: `issue_index.py` run from inside the store
|
||||
used to leave tmp/issues/tmp/issues/ behind, silently."""
|
||||
for d in self.repo.everywhere():
|
||||
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
|
||||
run(self.repo.script("issue", name), cwd=d)
|
||||
|
||||
found = []
|
||||
for dirpath, dirnames, filenames in os.walk(self.repo.root):
|
||||
if "__pycache__" in dirnames:
|
||||
dirnames.remove("__pycache__")
|
||||
if "INDEX.md" in filenames:
|
||||
found.append(dirpath)
|
||||
self.assertEqual(found, [self.repo.store],
|
||||
"a second store appeared: %s" % found)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# missing is not empty
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestMissingVersusEmpty(unittest.TestCase):
|
||||
|
||||
def test_missing_store_says_missing(self):
|
||||
repo = FakeRepo(with_store=False)
|
||||
self.addCleanup(repo.cleanup)
|
||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||
msg = out + err
|
||||
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
|
||||
self.assertIn("does not exist", msg, name)
|
||||
self.assertNotIn("is empty", msg, name)
|
||||
|
||||
def test_empty_store_says_empty(self):
|
||||
repo = FakeRepo(issues=())
|
||||
self.addCleanup(repo.cleanup)
|
||||
for name in ("issue_check.py", "issue_tree.py"):
|
||||
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||
msg = out + err
|
||||
self.assertNotEqual(rc, 0, name)
|
||||
self.assertIn("is empty", msg, name)
|
||||
self.assertNotIn("does not exist", msg, name)
|
||||
|
||||
def test_index_of_an_empty_store_is_legitimate(self):
|
||||
"""An existing store with nothing in it gets an index saying so. Only a
|
||||
missing directory is an error."""
|
||||
repo = FakeRepo(issues=())
|
||||
self.addCleanup(repo.cleanup)
|
||||
rc, out, err = run(repo.script("issue", "issue_index.py"), cwd=repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("0 issue(s)", out)
|
||||
with open(os.path.join(repo.store, "INDEX.md")) as f:
|
||||
self.assertIn("_empty_", f.read())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# nothing conjures a store
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestNoSilentCreation(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo(with_store=False)
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_readers_and_the_indexer_create_nothing(self):
|
||||
for d in (self.repo.root, self.repo.path("sub")):
|
||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||
run(self.repo.script("issue", name), cwd=d)
|
||||
self.assertFalse(os.path.exists(self.repo.path("tmp")),
|
||||
"the store was created by a read")
|
||||
self.assertFalse(os.path.exists(self.repo.path("sub", "tmp")),
|
||||
"a store was created relative to cwd")
|
||||
|
||||
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
|
||||
target = self.repo.path("sub", "nowhere")
|
||||
rc, out, err = run(self.repo.script("issue", "issue_index.py"),
|
||||
"--out", target, cwd=self.repo.root)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("does not exist", out + err)
|
||||
self.assertFalse(os.path.exists(target))
|
||||
|
||||
def test_issue_new_creates_the_store_and_says_so(self):
|
||||
"""Creating the first issue in a fresh checkout must still work — but
|
||||
out loud, and at the repo root, not below whatever cwd happens to be."""
|
||||
rc, out, err = run(self.repo.script("issue", "issue_new.py"),
|
||||
"--type", "task", "--title", "Bootstrap the store",
|
||||
cwd=self.repo.path("sub", "deeper"))
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("created store", err)
|
||||
self.assertIn(self.repo.store, err)
|
||||
self.assertTrue(os.path.isfile(
|
||||
os.path.join(self.repo.store, "bootstrap-the-store.md")))
|
||||
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")),
|
||||
"a store was created relative to cwd")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# an explicit --out is the operator's, not ours to rewrite
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestExplicitOutWins(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_absolute_out_is_honored(self):
|
||||
other = self.repo.path("sub", "other-store")
|
||||
os.makedirs(other)
|
||||
shutil.copy(os.path.join(self.repo.store, "alpha-issue.md"), other)
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", other, cwd=self.repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("1 issue(s) checked", out)
|
||||
|
||||
def test_relative_out_stays_relative_to_cwd(self):
|
||||
"""`--out tmp/issues` typed from a subdirectory means that
|
||||
subdirectory's tmp/issues — which is not there. Auto-resolution must
|
||||
not step in and "fix" what the operator typed."""
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("tmp", "issues"),
|
||||
cwd=self.repo.path("sub"))
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("does not exist", out + err)
|
||||
|
||||
# the same relative path from the root does resolve, by cwd alone
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("tmp", "issues"),
|
||||
cwd=self.repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("2 issue(s) checked", out)
|
||||
|
||||
def test_relative_out_can_climb(self):
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("..", "tmp", "issues"),
|
||||
cwd=self.repo.path("sub"))
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("2 issue(s) checked", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# both layers, one root
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSyncLayerAgrees(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def _probe(self, layer, cwd):
|
||||
"""Ask one layer, from `cwd`, which module defines the store and where
|
||||
it lands. The sync scripts put the issue scripts on sys.path themselves
|
||||
— `import map` is how they do it — so each layer is asked its own way.
|
||||
"""
|
||||
scripts = self.repo.path("skills", layer, "scripts")
|
||||
entry = "import map, issue" if layer == "sync" else "import issue"
|
||||
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
|
||||
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None)
|
||||
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
return p.stdout.strip().splitlines()
|
||||
|
||||
def test_both_layers_resolve_the_same_store_from_anywhere(self):
|
||||
for d in self.repo.everywhere():
|
||||
mod_i, root_i = self._probe("issue", d)
|
||||
mod_s, root_s = self._probe("sync", d)
|
||||
# sync does not redefine the store; it imports the domain module
|
||||
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
|
||||
self.assertEqual(root_i, self.repo.store, d)
|
||||
self.assertEqual(root_s, self.repo.store, d)
|
||||
|
||||
def test_every_out_flag_defers_to_the_domain_layer(self):
|
||||
"""Both layers agree by construction, not by coincidence: no script
|
||||
spells the default out for itself."""
|
||||
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
||||
"issue_tree.py", "issue_index.py",
|
||||
"issue_evict.py")),
|
||||
("sync", ("pull.py", "push.py", "remote.py",
|
||||
"comment.py", "evict.py"))):
|
||||
for name in names:
|
||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||
src = f.read()
|
||||
self.assertIn('"--out", default=issue.ISSUE_ROOT', src,
|
||||
"%s/%s does not take its --out default from the "
|
||||
"domain layer" % (layer, name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the layering rule, mechanically
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestLayering(unittest.TestCase):
|
||||
|
||||
def test_domain_layer_is_stdlib_only(self):
|
||||
"""skills/issue must keep working with skills/sync deleted — so no
|
||||
transport, and above all no subprocess, in the domain layer."""
|
||||
imported = set()
|
||||
for name in sorted(os.listdir(ISSUE_SCRIPTS)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
with open(os.path.join(ISSUE_SCRIPTS, name)) as f:
|
||||
for line in f:
|
||||
if line.startswith(("import ", "from ")):
|
||||
imported.add(line.split()[1].split(".")[0])
|
||||
local = {"issue", "issue_ac", "issue_index"}
|
||||
foreign = imported - local - sys.stdlib_module_names
|
||||
self.assertEqual(foreign, set(),
|
||||
"non-stdlib import in the domain layer: %s"
|
||||
% ", ".join(sorted(foreign)))
|
||||
self.assertNotIn("subprocess", imported)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user