Merge origin/main into feat/close-script
Two conflicts git could see (AGENTS.md, skills/sync/SKILL.md) and one it could not: the payload-root change removed api()'s out_root parameter, so close.py stops passing it, and its payload test now asserts PAYLOAD_ROOT instead of the deleted PAYLOAD_DIR.
This commit is contained in:
+73
-9
@@ -32,13 +32,18 @@ index.
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
||||
operator's pin from `.claude/settings.local.json` themselves, the same source
|
||||
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
||||
operator's pin from `.claude/settings.local.json` through
|
||||
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
||||
not merely the same file, so a directory where `tea` works is a directory where
|
||||
these work. That includes a **git worktree**, whose untracked pin sits in the
|
||||
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
||||
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
||||
`/tea:auth`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty |
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
||||
@@ -89,7 +94,7 @@ python3 <skill-base-dir>/scripts/pull.py 42
|
||||
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
|
||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
||||
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
|
||||
python3 <skill-base-dir>/scripts/pull.py 40 --deps # follow dependencies
|
||||
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
|
||||
```
|
||||
|
||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
||||
@@ -115,13 +120,55 @@ already on disk is refreshed either way — the local copy learns it was closed
|
||||
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
|
||||
closed issue as always, because an address is not a bulk read.
|
||||
|
||||
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
||||
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
||||
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
||||
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
||||
ones are there to write. Pagination follows the budget rather than the other way
|
||||
round:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| budget full | the next page is never requested |
|
||||
| pages run out | fewer than N, and that is the honest answer |
|
||||
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
||||
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
||||
|
||||
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
||||
closed issues included. It writes nothing, so there is no write for a limit to
|
||||
bound — enumeration is its whole job.
|
||||
|
||||
**Comments come with every pull** — there is no flag. An issue that has a
|
||||
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
|
||||
mode alike, and the issue's output line says how many. An issue with none
|
||||
costs nothing: the count arrives in the list payload, so no request is made
|
||||
and no file is written — and a file left over from a thread that has since
|
||||
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
||||
skipped issue makes no request at all.
|
||||
skipped issue makes one request for its links and no other.
|
||||
|
||||
**Dependencies come with every pull too, and this one costs.** A pull answers
|
||||
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
||||
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
||||
to `--depth` (default 3). It has to come from the native graph: the body's
|
||||
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
||||
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
||||
does nothing — it names the default.
|
||||
|
||||
| | requests |
|
||||
|---|---|
|
||||
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
||||
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
||||
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
||||
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
||||
| the same with `--no-deps` | 1 |
|
||||
|
||||
**A blocker the filter did not select still lands in the store, deliberately.**
|
||||
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
||||
an unlabelled one. It is there because a stored issue names it, not because it
|
||||
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
||||
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
||||
with it — nothing is left pointing at a file that is not there. Key mode
|
||||
(`pull.py 42`) has no such rule and stores it.
|
||||
|
||||
Two traps this handles for you:
|
||||
|
||||
@@ -234,7 +281,7 @@ accumulate them however many round trips it makes, and why a body that somehow
|
||||
gained two is cleaned on the next pull.
|
||||
|
||||
`depends:` survives the same round trip through Gitea's native links (below):
|
||||
push writes them, `pull.py --deps` reads them back, and the ledger turns the
|
||||
push writes them, every `pull.py` reads them back, and the ledger turns the
|
||||
numbers into the slugs they had here.
|
||||
|
||||
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
||||
@@ -254,7 +301,7 @@ The two directions are symmetric, and they use the same endpoint:
|
||||
| | direction | endpoint |
|
||||
|---|---|---|
|
||||
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
||||
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||
|
||||
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
||||
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
||||
@@ -299,6 +346,10 @@ decision, not a migration. A color or `exclusive` that drifted is printed, and
|
||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||
`comp/*` are open-ended by design and stay push-created.
|
||||
|
||||
Labels belong to the repository, not to any issue, so this one runs on a
|
||||
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
|
||||
and nothing creates it. The request bodies go to `tmp/payload/` (below).
|
||||
|
||||
A milestone must already exist in the repo — push attaches, it does not create.
|
||||
|
||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||
@@ -309,6 +360,12 @@ a git repo no `ref` is sent and a warning names the issues that went up without
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
never check out, create, or write anything.
|
||||
|
||||
The branch comes from the **current directory**, so run `push.py` from the tree
|
||||
the work is on. In a git worktree that is the worktree, and it is now also
|
||||
where the pin resolves from: the old workaround for the pin — run the scripts
|
||||
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
||||
is the one thing `branch:` exists to record.
|
||||
|
||||
## Closing and reopening
|
||||
|
||||
```bash
|
||||
@@ -368,7 +425,7 @@ leaves the working copy where it is.
|
||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them |
|
||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
|
||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||
|
||||
@@ -403,6 +460,13 @@ precisely so the mechanism this section rules out is not needed.
|
||||
|
||||
## Rich payloads for everything else
|
||||
|
||||
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
|
||||
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
||||
went up. One gitignored directory for all of them, chosen by the transport and
|
||||
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
||||
and it is never `tmp/issues/` — a command that touches no issue must not leave
|
||||
an issue store behind.
|
||||
|
||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
||||
|
||||
+156
-44
@@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance
|
||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||
lives one layer further out in skills/issue/scripts/issue.py.
|
||||
|
||||
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
|
||||
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
|
||||
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
|
||||
owns the search order, and the tea-guard hook imports the same module, so `tea`
|
||||
and the scripts can never disagree about which login a directory runs under. No
|
||||
script here accepts a login argument: the operator's pin is the only identity
|
||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||
|
||||
@@ -18,6 +20,13 @@ it is transport bookkeeping, not domain data — the domain never reads any of
|
||||
it, and losing the map still costs a re-pull and not information: the slug it
|
||||
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
||||
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
||||
|
||||
Request bodies go to tmp/payload/, which is this module's own scratchpad and
|
||||
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
||||
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
|
||||
touches no issue at all — it used to leave a store behind anyway, because the
|
||||
request file had nowhere else to live. One directory, every caller, resolved
|
||||
from this file the way the two domains resolve theirs.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
@@ -27,9 +36,33 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
PAYLOAD_DIR = ".payload"
|
||||
REMOTE_MAP = ".remote.json"
|
||||
|
||||
# How far past the ideal page count a `keep`-bounded listing may scan before it
|
||||
# gives up (see list_issues). The ideal is what `limit` would need if every
|
||||
# payload counted; the slack pays for the ones that do not. It is a bound on
|
||||
# requests, deliberately small: "fetch until N are kept" without one is "fetch
|
||||
# the whole tracker" on any repo whose filter matches mostly closed issues.
|
||||
PAGE_SLACK = 4
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root and page.store_root, so every
|
||||
# caller — sync, wiki, whatever comes next — writes to one directory whatever
|
||||
# it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# inside somebody's store, because a scratchpad that looks like store contents
|
||||
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||
|
||||
PAYLOAD_PARTS = ("tmp", "payload")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
@@ -44,32 +77,64 @@ def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# login
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def find_pin(start_dir=None):
|
||||
"""Walk up from start_dir; return the login from the first
|
||||
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
|
||||
d = os.path.abspath(start_dir or ".")
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
p = os.path.join(d, ".claude", "settings.local.json")
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
with open(p) as f:
|
||||
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
except Exception:
|
||||
pass
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||
return d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def payload_root(start=None):
|
||||
"""Absolute path of the request-body scratchpad.
|
||||
|
||||
`start` overrides the anchor so the resolution can be exercised against a
|
||||
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
|
||||
location stands — made absolute so an error can name the directory it
|
||||
really wrote to."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *PAYLOAD_PARTS)
|
||||
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
|
||||
|
||||
|
||||
PAYLOAD_ROOT = payload_root()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# login
|
||||
# --------------------------------------------------------------------------
|
||||
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
|
||||
# single written copy of the search order, and the tea-guard hook calls the
|
||||
# same function. When the two had a copy each, a git worktree got a hook that
|
||||
# resolved the pin and a transport that did not — in the same directory.
|
||||
#
|
||||
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
|
||||
# are anchored on their own file, this is not, and both are right. Where an
|
||||
# installation keeps its files is a fact about the installation; whose login a
|
||||
# project runs under is a fact about the project, and a plugin installed
|
||||
# outside any repository must not answer it from its own directory. See the
|
||||
# module docstring in pin.py.
|
||||
|
||||
_AUTH_SCRIPTS = os.path.abspath(
|
||||
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||
if _AUTH_SCRIPTS not in sys.path:
|
||||
sys.path.append(_AUTH_SCRIPTS)
|
||||
import pin # noqa: E402
|
||||
|
||||
|
||||
def require_login():
|
||||
login = find_pin(os.getcwd())
|
||||
"""The operator's pinned login, or exit pointing at /tea:auth.
|
||||
|
||||
No pin found is reported as exactly that. It stays a truthful message: the
|
||||
fix for "the pin is somewhere this search does not reach" belongs in
|
||||
pin.py, never in a hint here that sends the operator to pin it twice."""
|
||||
login, _ = pin.find_pin()
|
||||
if not login:
|
||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||
return login
|
||||
@@ -80,19 +145,21 @@ def require_login():
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
out_root=None, allow_fail=False):
|
||||
allow_fail=False):
|
||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
||||
|
||||
payload (a dict) is written to <out_root>/.payload/<name>.json and passed
|
||||
as -d @file — the file survives the call for retries and debugging.
|
||||
allow_fail returns None instead of exiting when the call fails."""
|
||||
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
|
||||
-d @file — the file survives the call for retries and debugging. Where
|
||||
that is, is not the caller's business and never was: the directory is
|
||||
this layer's scratchpad, and the one time it was a caller's decision it
|
||||
got pointed at the issue store. allow_fail returns None instead of
|
||||
exiting when the call fails."""
|
||||
cmd = ["tea", "api", "--login", login]
|
||||
if method != "GET":
|
||||
cmd += ["-X", method]
|
||||
if payload is not None:
|
||||
pdir = os.path.join(out_root or ".", PAYLOAD_DIR)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
path = os.path.join(pdir, "%s.json" % (payload_name or "request"))
|
||||
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
||||
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
||||
with open(path, "w") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
cmd += ["-d", "@" + path]
|
||||
@@ -114,17 +181,28 @@ def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
||||
|
||||
|
||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page; return the concatenated list."""
|
||||
def pages(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page, yielding each page as it arrives.
|
||||
|
||||
A generator, because a caller whose budget is spent on what it *keeps*
|
||||
cannot be served by a function that fetches everything first: the page after
|
||||
the one that completed the budget must never be requested. Stop consuming
|
||||
and no further request is made."""
|
||||
sep = "&" if "?" in endpoint else "?"
|
||||
out = []
|
||||
for page in range(1, max_pages + 1):
|
||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
return
|
||||
yield batch
|
||||
if len(batch) < limit:
|
||||
break
|
||||
return # a short page is the last one
|
||||
|
||||
|
||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page; return the concatenated list."""
|
||||
out = []
|
||||
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
|
||||
out.extend(batch)
|
||||
return out
|
||||
|
||||
|
||||
@@ -187,11 +265,31 @@ def matches(payload, milestone_id=None, labels=()):
|
||||
|
||||
|
||||
def list_issues(login, base, state="open", labels=(), query=None,
|
||||
milestone=None, limit=100):
|
||||
milestone=None, limit=100, keep=None):
|
||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
||||
|
||||
One request per page, and the payload already carries the issue bodies — a
|
||||
whole milestone costs one call per 50 issues, not one per issue."""
|
||||
whole milestone costs one call per 50 issues, not one per issue.
|
||||
|
||||
`limit` counts the payloads the CALLER cares about, not the ones the server
|
||||
returned. Without `keep` those are the same thing and this behaves as it
|
||||
always did. With it, `keep(payload)` says whether a payload counts, pages
|
||||
keep coming until `limit` of them have, and the returned list carries the
|
||||
ones that did not count too — they were enumerated, and a caller that has
|
||||
something to say about them (pull.py: "N closed, not stored") still can.
|
||||
|
||||
What `keep` means is the caller's business; this module only counts. Two
|
||||
boundaries hold whatever it decides:
|
||||
|
||||
- **Stop at the limit.** The page after the one that completed the budget
|
||||
is not requested — `pages` is a generator and this loop returns out of it.
|
||||
- **Stop at the page budget.** A predicate that rejects everything must not
|
||||
turn a bounded read into a walk of the whole tracker, so a filtered read
|
||||
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
|
||||
payload counted. Hitting that with an unfilled budget is a warning, not a
|
||||
silent short answer: the caller asked for N and is told it got fewer."""
|
||||
if limit < 1:
|
||||
die("--limit must be 1 or more, got %d" % limit)
|
||||
ms_id, ms_title = (None, None)
|
||||
if milestone is not None:
|
||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
||||
@@ -206,10 +304,25 @@ def list_issues(login, base, state="open", labels=(), query=None,
|
||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
||||
|
||||
per_page = min(limit, 50)
|
||||
got = paginate(login, endpoint, limit=per_page,
|
||||
max_pages=max(1, -(-limit // per_page)))
|
||||
got = [p for p in got if matches(p, ms_id, labels)]
|
||||
return got[:limit], ms_title
|
||||
ideal = max(1, -(-limit // per_page))
|
||||
budget = ideal if keep is None else ideal * PAGE_SLACK
|
||||
|
||||
got, kept, seen_pages, last_full = [], 0, 0, False
|
||||
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
|
||||
seen_pages += 1
|
||||
last_full = len(batch) == per_page
|
||||
for p in batch:
|
||||
if not matches(p, ms_id, labels):
|
||||
continue
|
||||
got.append(p)
|
||||
if keep is None or keep(p):
|
||||
kept += 1
|
||||
if kept >= limit:
|
||||
return got, ms_title
|
||||
if keep is not None and seen_pages >= budget and last_full:
|
||||
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
|
||||
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
|
||||
return got, ms_title
|
||||
|
||||
|
||||
def get_issue(login, base, number):
|
||||
@@ -245,7 +358,7 @@ def native_dep_pairs(login, base, number):
|
||||
return out
|
||||
|
||||
|
||||
def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
|
||||
def add_dependency(login, base, number, dep_repo, dep_number):
|
||||
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
||||
|
||||
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
@@ -264,8 +377,7 @@ def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
|
||||
return False
|
||||
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
||||
payload_name="dep-%d-%d" % (number, dep_number),
|
||||
out_root=out_root, allow_fail=True)
|
||||
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
|
||||
return got is not None
|
||||
|
||||
|
||||
@@ -297,7 +409,7 @@ def ensure_labels(login, base, specs, root):
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
created = api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"), out_root=root)
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not created or "id" not in created:
|
||||
die("could not create label %r" % name)
|
||||
cache[name] = created["id"]
|
||||
|
||||
@@ -226,8 +226,7 @@ def main():
|
||||
touched = 0
|
||||
for id, number, _repo in targets:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
|
||||
{"state": state}, payload_name="state-%d" % number,
|
||||
out_root=root)
|
||||
{"state": state}, payload_name="state-%d" % number)
|
||||
# The gate. Above it nothing local has been written; below it the file
|
||||
# is about to say something the tracker had better agree with.
|
||||
if not confirmed(got, number, state):
|
||||
|
||||
@@ -73,13 +73,11 @@ def main():
|
||||
|
||||
if args.edit:
|
||||
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
||||
{"body": body}, payload_name="comment-%d" % args.edit,
|
||||
out_root=root)
|
||||
{"body": body}, payload_name="comment-%d" % args.edit)
|
||||
verb = "edited"
|
||||
else:
|
||||
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
||||
{"body": body}, payload_name="comment-%s" % args.id,
|
||||
out_root=root)
|
||||
{"body": body}, payload_name="comment-%s" % args.id)
|
||||
verb = "posted"
|
||||
if not isinstance(got, dict) or "id" not in got:
|
||||
_gitea.die("%s failed, unexpected response" % verb)
|
||||
|
||||
@@ -32,6 +32,11 @@ created by push as they come up, and deleting or renaming anything at all.
|
||||
Only repository labels are read; an organization's own labels sit behind a
|
||||
different endpoint and are neither read nor written.
|
||||
|
||||
The issue store is out of scope too, and not incidentally. A label belongs to
|
||||
the repository, not to any issue, so this command neither reads tmp/issues/ nor
|
||||
creates it — the taxonomy it paints comes from the domain MODULE, and the
|
||||
request bodies it sends go to the transport's own tmp/payload/.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
@@ -185,8 +190,7 @@ def main():
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"),
|
||||
out_root=issue.ISSUE_ROOT)
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not new or "id" not in new:
|
||||
_gitea.die("could not create label %r" % name)
|
||||
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
||||
@@ -211,8 +215,7 @@ def main():
|
||||
for field, _is, _want in drift:
|
||||
patch[field] = spec[field]
|
||||
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
||||
payload_name="label-%s" % name.replace("/", "-"),
|
||||
out_root=issue.ISSUE_ROOT)
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
fixed += 1
|
||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
|
||||
|
||||
+131
-35
@@ -28,11 +28,31 @@ not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
||||
|
||||
A closed issue is not a unit of work, so filter mode enumerates it but leaves
|
||||
it out of the store: `--state all` still shows the whole picture, and only
|
||||
`--state closed` writes one. The limit is on the write, not on the selection —
|
||||
an issue already on disk is refreshed either way, so the local copy learns it
|
||||
was closed instead of staying open forever, and the count of the ones left out
|
||||
goes to stderr. Key mode is exempt: an address is not a bulk read, and
|
||||
`pull.py 1` fetches a closed issue as it always did.
|
||||
`--state closed` writes one. An issue already on disk is refreshed either way,
|
||||
so the local copy learns it was closed instead of staying open forever, and the
|
||||
count of the ones left out goes to stderr. Key mode is exempt: an address is not
|
||||
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
|
||||
|
||||
**`--limit` is on the write, not on the selection.** It counts the issues this
|
||||
run puts in the store — written, or left in place by `--cached` — and never the
|
||||
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
|
||||
first 30 issues are closed still writes 20, if 20 open ones are there to write:
|
||||
pages keep coming until the budget is full. Two boundaries keep that honest:
|
||||
|
||||
- Pages stop the moment the budget is full. Never one page more.
|
||||
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
|
||||
would need if nothing were dropped. A filter that matches almost only closed
|
||||
issues therefore ends in a warning and a short answer, not in a walk of the
|
||||
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
|
||||
with it.
|
||||
- Dependencies are outside the count: a blocker is followed because a stored
|
||||
issue named it, not because the filter selected it. `--limit 20` can
|
||||
therefore leave more than 20 files behind — the budget counts the selection's
|
||||
writes, and the graph is not part of the selection.
|
||||
|
||||
`remote.py` is the deliberate exception, and it is not the same flag twice: it
|
||||
writes nothing at all, so there is no write to bound and its `--limit` means
|
||||
what it says — how many lines to print.
|
||||
|
||||
Comments ride along by default, in both modes and for every issue written:
|
||||
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
|
||||
@@ -42,8 +62,39 @@ from an earlier pull is deleted. An absent file therefore means "no comments",
|
||||
never "not asked for". The thread is pull-only: editing it changes nothing in
|
||||
Gitea (post with comment.py).
|
||||
|
||||
**Dependencies come with every pull.** A pull answers with the whole unit of
|
||||
work — the issue and what blocks it — so `depends:` is filled from Gitea's
|
||||
native dependency graph and every blocker is pulled too, recursively, down to
|
||||
`--depth` (default 3). That graph is the only source there is: `map.from_api`
|
||||
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
|
||||
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
|
||||
recursion, and no request spent on either. `--deps` is still accepted and now
|
||||
does nothing; it names what already happens.
|
||||
|
||||
What it costs, stated rather than hidden:
|
||||
|
||||
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
|
||||
fetched once and used twice, since the same links both fill `depends:` and
|
||||
tell the walk where to go next. A closed issue that filter mode drops costs
|
||||
nothing: nothing was stored, so there is no unit of work to complete.
|
||||
- **One request per blocker the selection did not already carry** — a `GET` for
|
||||
the issue itself, then its own links, and so on until `--depth`.
|
||||
- So `--milestone X` over 50 open issues is one list request + 50 link requests
|
||||
+ one pair for every blocker outside the milestone, where it used to be one
|
||||
request flat. `--no-deps` is the way back to one.
|
||||
|
||||
**In filter mode a blocker the filter did not select still lands in the store,
|
||||
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
|
||||
disk and `--label` an unlabelled one: a blocker is followed because a stored
|
||||
issue names it, not because it matched. The one blocker that does not land is a
|
||||
closed one — closed is not a unit of work, filter mode drops it the way it drops
|
||||
any other closed issue, and the `depends:` edge to it goes with it, so nothing
|
||||
points at a file that is not there. Key mode has no such rule and stores it.
|
||||
|
||||
Other flags:
|
||||
--deps [--depth N] follow dependencies and pull them too
|
||||
--no-deps do not fill depends:, do not follow blockers
|
||||
--deps accepted, does nothing: it is the default now
|
||||
--depth N how deep to follow blockers (default 3)
|
||||
--cached skip issues already on disk (body AND comments)
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
@@ -52,7 +103,9 @@ have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
|
||||
on either side wins for any item whose text matches, because a tick is monotone
|
||||
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
||||
has the rule and its price). `--cached` skips an issue before any of that: it is
|
||||
not read and not merged. Draw the graph afterwards with the domain's own
|
||||
not read and not merged — it still costs its one link request, because a cached
|
||||
issue's blockers can be missing from disk even when it is not (`--cached
|
||||
--no-deps` is the free one). Draw the graph afterwards with the domain's own
|
||||
issue_tree.py — it needs no network.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
@@ -98,6 +151,23 @@ def id_for(payload, store_ids, remote_map, repo, root):
|
||||
taken=store_ids)
|
||||
|
||||
|
||||
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
|
||||
"""Would this payload leave a file in the store? The `--limit` predicate.
|
||||
|
||||
It has to be the same test the walk below applies, or the budget is spent on
|
||||
issues that never land — which is the bug this exists to prevent. So: a
|
||||
closed issue counts only when the store already has it (it is refreshed, and
|
||||
that is a write); anything else counts, including one `--cached` will skip,
|
||||
because a skipped issue is still an issue the store holds when the run ends.
|
||||
|
||||
Cheap in the common case: only a closed payload costs an `id_for`, and that
|
||||
is a lookup plus, at worst, a stat."""
|
||||
if not (drop_closed and payload.get("state") == "closed"):
|
||||
return True
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
return os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
|
||||
def comments_path(root, id):
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||
Named in `_gitea` because push.py has to delete the same file."""
|
||||
@@ -131,8 +201,18 @@ def main():
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||
help="filter mode only (default: open)")
|
||||
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)")
|
||||
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them")
|
||||
ap.add_argument("--limit", type=int, default=100,
|
||||
help="filter mode: how many issues to STORE, not to enumerate"
|
||||
" (default: 100)")
|
||||
# Dependencies are the default: a pull answers with the unit of work, not
|
||||
# one row of it. `--deps` stays accepted so the calls and command tables
|
||||
# written against the old default keep working — it now sets what is
|
||||
# already set.
|
||||
ap.add_argument("--no-deps", dest="deps", action="store_false",
|
||||
help="do not fill depends: and do not follow blockers")
|
||||
ap.add_argument("--deps", dest="deps", action="store_true",
|
||||
help="accepted, does nothing: dependencies are followed by default")
|
||||
ap.set_defaults(deps=True)
|
||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
@@ -180,9 +260,14 @@ def main():
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
# The limit bounds the write, so the transport is told what a write is
|
||||
# and counts those; the closed ones it enumerated on the way come back
|
||||
# in the list anyway, to be reported and dropped below.
|
||||
payloads, ms_title = _gitea.list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit)
|
||||
milestone=args.milestone, limit=args.limit,
|
||||
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
|
||||
repo, root))
|
||||
if not payloads:
|
||||
_gitea.die("no issues match that filter")
|
||||
what = []
|
||||
@@ -208,35 +293,42 @@ def main():
|
||||
stored = os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
# Closed and not already ours: nothing is written and nothing is asked
|
||||
# of the server for it, not even its comments. The slug stays unclaimed
|
||||
# too, so no other issue ends up pointing `depends:` at a missing file.
|
||||
# of the server for it — not its comments, not its links, and its own
|
||||
# blockers are not followed. The slug stays unclaimed too, so no other
|
||||
# issue ends up pointing `depends:` at a missing file.
|
||||
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||
dropped.append(number)
|
||||
continue # not stored: no unit of work here, so no links are fetched
|
||||
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
|
||||
# The native links, fetched ONCE for the two things they are for:
|
||||
# filling this issue's `depends:` and telling the walk where to go next.
|
||||
# One request per issue that lands in the store, and only one — the cost
|
||||
# the docstring quotes is this line.
|
||||
deps = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # body and thread unread; only the links cost
|
||||
else:
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # untouched, unread, and not one request spent
|
||||
else:
|
||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
# The copy already on disk, as it was when this run started. It
|
||||
# contributes its ticked checkboxes and nothing else; None when
|
||||
# the store has never seen this issue.
|
||||
prev = issues.get(id)
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=extra,
|
||||
synced=_gitea.now_iso(),
|
||||
local_body=prev.body if prev else None)
|
||||
issue.save(root, iss)
|
||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
# The copy already on disk, as it was when this run started. It
|
||||
# contributes its ticked checkboxes and nothing else; None when
|
||||
# the store has never seen this issue.
|
||||
prev = issues.get(id)
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=deps,
|
||||
synced=_gitea.now_iso(),
|
||||
local_body=prev.body if prev else None)
|
||||
issue.save(root, iss)
|
||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
|
||||
if args.deps and depth < args.depth:
|
||||
child_numbers = (gmap.numbers_in_body(payload.get("body") or "")
|
||||
+ _gitea.native_deps(login, base, number))
|
||||
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
|
||||
for n in child_numbers:
|
||||
if n in seen_numbers:
|
||||
continue
|
||||
@@ -265,8 +357,10 @@ def main():
|
||||
|
||||
# Compact output — the only thing that lands in the model's context. The
|
||||
# thread rides on the issue's own line; no file means no comments.
|
||||
graph = False
|
||||
for id in sorted(set(written) | set(skipped)):
|
||||
iss = issue.load(root, id)
|
||||
graph = graph or bool(iss.depends)
|
||||
note = " (cached)" if id in skipped else ""
|
||||
cpath = comments_path(root, id)
|
||||
if os.path.isfile(cpath):
|
||||
@@ -275,7 +369,9 @@ def main():
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), note))
|
||||
print("index: %s" % index_path)
|
||||
if args.deps:
|
||||
# Now that dependencies are the default, the hint is worth printing when
|
||||
# there is something to draw, not on every run that could have drawn it.
|
||||
if graph:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ way, so nothing is lost, but the tracker shows no edge for it.
|
||||
|
||||
The graph goes up with them. Once an issue has its number, every `depends:`
|
||||
entry that also has one becomes a **native Gitea link** — the same
|
||||
`/dependencies` that `pull.py --deps` reads back, so the tracker shows the
|
||||
`/dependencies` that every `pull.py` reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. Topological order
|
||||
means the blocker already has its number by then; no second pass is needed.
|
||||
`--update` links whatever appeared in `depends:` since the last push. A link
|
||||
@@ -334,12 +334,12 @@ def main():
|
||||
if sent_number:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
|
||||
payload, payload_name="issue-%s" % id, out_root=root)
|
||||
payload, payload_name="issue-%s" % id)
|
||||
verb = "updated"
|
||||
else:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
||||
payload_name="issue-%s" % id, out_root=root)
|
||||
payload_name="issue-%s" % id)
|
||||
verb = "created"
|
||||
# The gate. Below this line the local file is going to be deleted, so
|
||||
# anything short of a confirmed write has to stop the run here.
|
||||
@@ -364,7 +364,7 @@ def main():
|
||||
if missing:
|
||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
||||
payload_name="labels-%s" % id, out_root=root)
|
||||
payload_name="labels-%s" % id)
|
||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
||||
|
||||
# The in-memory issue is stamped even though its file is going: the rest
|
||||
@@ -392,7 +392,7 @@ def main():
|
||||
for slug, (drepo, dnum) in wanted_links:
|
||||
if not dnum or (drepo, dnum) in have:
|
||||
continue
|
||||
if _gitea.add_dependency(login, base, number, drepo, dnum, root):
|
||||
if _gitea.add_dependency(login, base, number, drepo, dnum):
|
||||
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
||||
else:
|
||||
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
||||
|
||||
@@ -16,6 +16,11 @@ Usage:
|
||||
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
||||
[--milestone M] [--limit N] [--repo owner/repo]
|
||||
|
||||
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
|
||||
not what the same flag means to `pull.py`, and the difference is not an
|
||||
oversight — pull.py bounds what it writes, and this command writes nothing, so
|
||||
there is nothing else for a limit to bound. Enumeration is the whole job.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
|
||||
Reference in New Issue
Block a user