Files
naudachu 83f73c5cea 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>
2026-08-11 00:25:28 +05:00

165 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""
evict.py — ask Gitea which stored issues are closed, then evict those.
evict.py check every synced issue in the store, evict the
ones Gitea says are closed
evict.py old-thing … only these
evict.py --dry-run ask, report, change nothing
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
decides and deletes — this script adds exactly one thing in front of it: a
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
so an issue closed in the web UI an hour ago still reads `open` here and the
offline command will (correctly) leave it alone. That is the gap this closes,
and it is the observed workflow: before this existed the operator had to
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
before anything could remove them.
Order of operations, and it is the whole safety argument:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be an object carrying the number we asked about and a
state from the domain's own vocabulary (`confirmed_state`);
3. only then is the eviction run, by handing the refreshed issues to
`issue_evict.run` — the same decision, the same deletion, the same
protection of `origin: local`, in one place.
A `tea` that will not run, a non-2xx, an answer for another issue, a state
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
issues whose answers had already arrived. That is stricter than `push.py`, which
deletes as it goes, and it costs nothing here — there is no ordering constraint
between evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. An `origin: gitea` issue whose handle is missing or unparseable
cannot be verified, so it is reported and kept rather than guessed at.
Cost: one GET per candidate. The store is a working set that push keeps small,
and a wrong answer here deletes a file, so each issue is asked about by its own
address rather than inferred from a list that a `--limit` could have truncated.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_evict # noqa: E402
import map as gmap # noqa: E402
def candidates(issues, ids=None):
"""(checkable, unverifiable) — which issues the tracker can be asked about.
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
that lives in another repo is asked about there. unverifiable is
[(id, why)]: it names a tracker but carries no handle to reach it by, which
is a file to report, never one to delete on a guess.
An `origin: local` issue is in neither list. It has no handle because it has
never left this machine, and asking Gitea about it is not a question that
has an answer.
"""
checkable, unverifiable = [], []
for id in (list(ids) if ids else sorted(issues)):
iss = issues[id]
if iss.is_local:
continue
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not repo or not number:
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
% iss.origin))
continue
checkable.append((id, repo, number))
return checkable, unverifiable
def confirmed_state(got, number):
"""The state Gitea confirmed for `number`, or None — the deletion gate.
The counterpart of `push.confirmed_number`, and written the same way: boring,
and saying no by default, because everything downstream of a `str` return
here may delete a file. An answer counts only when it is a dict, carries the
very number we asked about, and names a state the domain recognizes.
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
about issue `true` is not an answer about issue 42.
What it does not have to catch, because it never gets here: a non-2xx or a
`tea` that would not run at all — `_gitea.api` exits on both.
"""
if not isinstance(got, dict):
return None
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return None
state = got.get("state")
return state if state in issue.STATES else None
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict issues Gitea reports as closed from the local store")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every synced issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="ask the tracker and report; write and delete nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args(argv)
root = args.out
if not issue.store_exists(root):
_gitea.die("store %s does not exist — nothing to evict" % root)
issues = issue.load_all(root)
missing = [i for i in args.ids if i not in issues]
if missing:
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
checkable, unverifiable = candidates(issues, args.ids)
for id, why in unverifiable:
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
if not checkable:
print("nothing to check: no issue in the store carries a `gitea:` handle")
return 0
login = _gitea.require_login()
# ---- every answer first, deletions after -----------------------------
fresh = {}
for id, repo, number in checkable:
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
state = confirmed_state(got, number)
if state is None:
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
"(%.200r). Nothing was evicted."
% (id, repo, number, got))
fresh[id] = state
# The store stops lying even about the issues that stay: an answer already
# paid for is written back when it disagrees with the file. This is the only
# write this script makes, and a dry run makes none.
for id, state in sorted(fresh.items()):
was = issues[id].state
if was == state:
continue
print("state %s %s -> %s" % (id, was, state))
issues[id].state = state
if not args.dry_run:
issue.save(root, issues[id])
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())