83f73c5cea
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>
165 lines
7.0 KiB
Python
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())
|