#!/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: /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())