2f82b501bd
The store is a working set, not an archive. Until now nothing removed a closed issue from it: #10 put a filter on the write and said so explicitly ("existing store files are not cleaned"), and the migration was never anybody's job. The only way out was rm past every script, followed by rebuilding INDEX.md by hand. issue_evict.py removes <id>.md and every sidecar under that slug for an issue that is state: closed AND carries an origin: naming a tracker, then rebuilds INDEX.md. --dry-run prints and writes nothing at all. Two conditions, and the second one is the whole safety argument. An origin: local issue IS the work — there is no other copy — so it is never evicted, in any state, not even when named on the command line: it is reported and kept. The only files that go are ones whose own metadata says pull.py <n> brings them back, which is the trade push.py already makes when it drops a file the tracker just confirmed. The command lives in the domain layer, and the layering rule decides that rather than convenience: state: and origin: are domain fields and the answer is already on disk, so eviction needs no network, no login and no tea. The domain also gains issue.slug_files — every file the store holds under one slug, which is all_ids' "a slug has no dot in it" read the other way round, and lets the domain remove an issue completely without learning what a comment thread is. skills/sync/scripts/evict.py is the bridge form, and it exists because a local state: is only as fresh as the last pull: an issue closed in the web UI still reads open here. It refreshes state: from Gitea, then calls issue_evict.run — one implementation of "what may be evicted", in the layer that owns the fields it reads. Same gate as push, one step earlier: every candidate's state is fetched before anything is removed, each answer must be an object carrying the number asked about and a state the domain recognizes (confirmed_state, the counterpart of confirmed_number), and a failed or unconfirmed call evicts nothing — not even the candidates whose answers had already arrived, and no refreshed state: is written back either. A candidate is an issue with a gitea: handle; origin: local has none, is never asked about, and is never removed. .remote.json is deliberately not pruned. It is the number -> slug ledger, its entries are supposed to outlive the files they name, and an evicted issue is in exactly the state a pushed one is. AGENTS.md gains the rule the tracker side never wrote down: pull by number fetches an issue in any state — an address is not a query. Eviction does not revoke it, so a closed issue pulled after a cleanup is on disk again, and that is the tracker answering what it was asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
571 lines
23 KiB
Python
571 lines
23 KiB
Python
#!/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()
|