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:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
+412
View File
@@ -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()
+423
View File
@@ -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()
+641
View File
@@ -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()
+234
View File
@@ -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()
+811
View File
@@ -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()
+570
View File
@@ -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()
+418
View File
@@ -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()
+261
View File
@@ -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()
+354
View File
@@ -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()
+349
View File
@@ -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()
+433
View File
@@ -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()
+441
View File
@@ -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()