#!/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: 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 `), 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 ` 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 ` 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: /tmp/issues)") args = ap.parse_args(argv) root = args.out 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())