Files
marketplace/plugins/tea/skills/issue/scripts/issue_evict.py
T
naudachu fb5445915f fix: resolve the issue store from the project, not the plugin
`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.

Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:

    ~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues   5 files, 2 origin: local
    ~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues   12 files
    ~/.claude/plugins/cache/claude-skills/tea/2.2.0/   empty, the current one

Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.

The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.

With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.

- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
  `.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
  copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
  sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
  checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
  and `pin.py` imports them. The domain depends on nothing, so it is the layer
  all three callers can borrow from, and the walk stays written once: the
  guard, the transport and the store cannot disagree about a directory.

The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:38:39 +05:00

180 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
issue_evict.py — closed issues leave the store. Offline.
issue_evict.py every closed issue that is not origin: local
issue_evict.py old-thing … only these
issue_evict.py --dry-run print what would go; touch nothing
The store is a working set, not an archive. A closed issue is not a unit of
work any more, and `pull.py` has kept new ones out of filter mode for a while —
but the files already on disk were nobody's job, so the only way to remove one
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
that job.
WHAT IS EVICTED, and it is two conditions, both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
local` means this file IS the issue — there is no other copy and deleting it
deletes the work. It is therefore never evicted, in any state, not even when
named explicitly on the command line: a closed local issue is reported and
kept. The only files that go are ones whose own metadata says the work can be
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
drops a file the tracker has just confirmed.
That parallel is exact except for where the confirmation comes from. Push has
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
because `state:` and `origin:` are domain fields and the answer is already in
the store — which is why this command lives in the domain layer and needs no
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
variant that refreshes `state:` from the tracker first; it makes the deletion
decision by calling `run()` below, so there is exactly one implementation of
"what may be evicted" and it is this one.
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
is an address, not a query — so a closed issue pulled after an eviction lands on
disk again. That is the tracker being asked a direct question, not a regression,
and the answer is to evict again when you are done with it.
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
ledger, its entries outlive the files they name (that is what makes `pull.py
<n>` land on the same slug after a push deleted the file), and an evicted issue
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
directory.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
CLOSED = "closed"
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
# it is printed whether or not the issue was named, because "this closed thing
# is still here" needs an answer every time.
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
def classify(issues, ids=None):
"""Split the store into (evict, protected, still_open).
Pure — it reads the loaded issues and decides; nothing here touches disk.
evict closed, and lives in a tracker too: safe to remove
protected closed, but `origin: local`: the only copy of the work
still_open not closed
`ids` restricts the question to those issues; without it the whole store is
considered. A protected issue is returned as such even when it was named
explicitly — naming a file does not make deleting it safe.
"""
chosen = list(ids) if ids else sorted(issues)
evict, protected, still_open = [], [], []
for id in chosen:
iss = issues[id]
if iss.state != CLOSED:
still_open.append(id)
elif iss.is_local:
protected.append(id)
else:
evict.append(id)
return evict, protected, still_open
def remove(root, id):
"""Delete everything the store holds under one slug; return the paths.
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
id, not a decision. Whether an issue may go is settled by `classify` before
this is reached, so the dangerous half of the operation has no branches in
it at all. There is exactly one call site.
"""
gone = []
for p in issue.slug_files(root, id):
os.remove(p)
gone.append(p)
return gone
def run(root, issues, ids=None, dry_run=False, out=None):
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
The one implementation of eviction, called both by `main` below and by the
sync layer's `evict.py` — which does nothing to this decision except hand
over issues whose `state:` it has just refreshed from the tracker.
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
[(id, why)] for everything considered and not removed.
"""
out = out or sys.stdout
evict, protected, still_open = classify(issues, ids)
gone, kept = {}, []
for id in evict:
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
if not dry_run:
gone[id] = paths
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
for p in paths:
out.write(" %s\n" % p)
for id in protected:
kept.append((id, LOCAL_REASON))
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
# An open issue is the normal case and says nothing worth a line — unless
# the operator named it, in which case they are owed the reason.
for id in still_open:
kept.append((id, "state: %s" % issues[id].state))
if ids:
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
if dry_run:
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
% (len(evict), len(kept)))
return gone, kept
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
# Only when something actually went: the index is a view of the directory,
# and rewriting it after a run that changed nothing is a write nobody asked
# for.
if gone:
path, n = issue_index.build(root)
out.write("index: %s%d issue(s)\n" % (path, n))
return gone, kept
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict closed issues from the local store (offline)")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every closed issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="print what would be removed; touch nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <project>/.tea/issues)")
args = ap.parse_args(argv)
root = args.out
if root is None:
sys.exit("issue_evict.py: %s" % issue.no_project_error())
if not issue.store_exists(root):
sys.exit("issue_evict.py: 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:
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
% ", ".join(missing))
run(root, issues, args.ids, args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())