feat: work the sync backlog — comments, labels, refs, closed issues
Five tracker issues, all in the bridge layer except the last. pull.py fetches comments by default (#6). The thread was reachable only through --comments, and only for a single issue, so a bulk pull left every local copy silently incomplete: a missing <id>.comments.md could mean "no comments" or "never asked". Now every written issue gets its thread, in key and filter mode alike; an empty one costs no request (the count rides in the list payload) and writes no file, and a file left over from an earlier pull is deleted. --cached skips the thread along with the body. The --comments flag is gone. labels.py bootstraps the canonical label set (#7). Labels used to appear as a side effect of the first push that happened to use them, so a repo could not be filtered by type/bug until somebody pushed a bug. The set is finite and already described by the domain taxonomy — 6 type/* and 5 severity/* — which makes it a run, not a decision. Names and exclusivity come from issue.TYPES / SEVERITIES / EXCLUSIVE_NS, colors from map.label_specs; no list is duplicated. An exact name is never re-created or patched. Lookalikes (bug, Bug, "type: bug", kind/bug) are reported with their id and left alone — renaming somebody else's label is a decision, not a migration. Color or exclusive drift is printed, and changed only under --fix. branch: carries Gitea's ref (#8). map.to_payload sends ref only when the field is non-empty, since ref="" would clear whatever the server has; from_api reads it back; push fills an empty one from `git rev-parse --abbrev-ref HEAD` and writes it into the issue file. A hand-written value is never overwritten, on create or on --update. Detached HEAD and running outside a repo warn and send no ref. Reading the branch is the only thing these scripts ask of git. The domain needs no change: unknown keys already ride in Issue.extra and render after the domain fields. Bulk pulls no longer store closed issues (#10). Filter mode wrote every payload the server returned, so --state all dragged the closed backlog into a store that gets read whole — INDEX.md, grep over tmp/issues/*.md. They are still enumerated, the number left out goes to stderr, and an issue already on disk is refreshed either way so the local copy learns it was closed instead of staying open forever. --state closed stores them, and key mode is exempt: an address is not a bulk read. /tea:issue gains a "Writing a proper description" procedure (#9). Six steps from reading an issue to issue_check.py, the rule that a missing fact is found in the repository or asked about rather than invented, and the note that the procedure is identical for origin: local and origin: gitea while delivery to the tracker belongs to /tea:sync. No new script. Verified: labels.py run for real against claude-skills/tea (9 created, 2 already present) and idempotent on a second run; pull.py exercised live for the closed-skip, --state closed, key-mode and comment paths; the push write path covered offline with the transport stubbed. skills/issue/scripts/ still imports stdlib only, with no subprocess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+44
-3
@@ -38,9 +38,10 @@ the `tea-guard` hook reads. No pin → 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` |
|
||||
| `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 |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
|
||||
@@ -84,6 +85,21 @@ not one per issue. Filters AND together; `--state` defaults to `open`;
|
||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||
local edits are lost. `--cached` skips issues already on disk.
|
||||
|
||||
**Closed issues stay out of the store.** In filter mode they are enumerated
|
||||
but not written: `--state all` still shows the whole picture, only `--state
|
||||
closed` puts one on disk, and the number left out goes to stderr. An issue
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
Two traps this handles for you:
|
||||
|
||||
- **Gitea silently ignores an unresolvable milestone filter** and returns the
|
||||
@@ -125,8 +141,33 @@ Missing labels are created with the canonical color and, for `type/*` and
|
||||
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names
|
||||
and their meaning come from the domain taxonomy.
|
||||
|
||||
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
||||
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
||||
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
||||
`type/*` and `severity/*` names — in one run:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
||||
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
||||
```
|
||||
|
||||
It reads the repo's labels first. An exactly-matching name is never re-created
|
||||
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
||||
is reported with its id and left alone: renaming somebody else's label is a
|
||||
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.
|
||||
|
||||
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
|
||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||
and writes it back into the issue file; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
||||
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.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
@@ -138,6 +179,7 @@ A milestone must already exist in the repo — push attaches, it does not create
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | — | slugs; seeded from `#N` on pull |
|
||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||
|
||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
||||
@@ -146,8 +188,7 @@ from the `#N` it finds there, a push never rewrites what the author wrote. A
|
||||
translator that edits prose churns the body on every round trip.
|
||||
|
||||
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
||||
`pull.py --comments` and `comment.py`, and editing it by hand changes nothing
|
||||
in Gitea.
|
||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||
|
||||
## Drift
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
labels.py — put the canonical label set into a repository, in one run.
|
||||
|
||||
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
||||
front instead of trickling in as a side effect of whichever push first happens
|
||||
to use one. Until a name exists in the repository nobody can filter by it in
|
||||
the web UI, so somebody makes their own with a foreign color and without
|
||||
`exclusive`, and the set arrives in pieces over months.
|
||||
|
||||
labels.py --dry-run print the plan, write nothing
|
||||
labels.py create whatever is missing
|
||||
labels.py --fix also patch color / `exclusive` drift
|
||||
labels.py --repo owner/repo outside the repository's own checkout
|
||||
|
||||
No label name is spelled out in this file. The names are assembled from the
|
||||
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
|
||||
map.label_specs; add a type over in skills/issue and the next run creates it.
|
||||
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
|
||||
through `tea api`.
|
||||
|
||||
The repository's own labels are read before anything is written. A name that
|
||||
matches exactly is left alone — never re-created, never patched; a color or
|
||||
`exclusive` that disagrees with the spec is reported, and corrected only under
|
||||
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
|
||||
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
|
||||
`type: x` against `type/x`) is reported with its id and never touched —
|
||||
renaming somebody else's label is a decision, not a step.
|
||||
|
||||
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
|
||||
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.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
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 map as gmap # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the canonical set
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Which taxonomy collection fills which exclusive namespace. Both sides are the
|
||||
# domain's — this dict is only the join between them, and it is the whole
|
||||
# reason no name has to be repeated here.
|
||||
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
|
||||
|
||||
|
||||
def canonical_names():
|
||||
"""Every name in the canonical set, in taxonomy order.
|
||||
|
||||
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
|
||||
is MEMBERS, i.e. the domain again. A namespace the domain declares but
|
||||
MEMBERS does not know about is handed back separately — better reported
|
||||
than quietly missing from the set."""
|
||||
names, orphan = [], []
|
||||
for ns in issue.EXCLUSIVE_NS:
|
||||
if ns in MEMBERS:
|
||||
names += [ns + m for m in MEMBERS[ns]]
|
||||
else:
|
||||
orphan.append(ns)
|
||||
return names, orphan
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# lookalikes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
WORDS = re.compile(r'[^a-z0-9]+')
|
||||
|
||||
|
||||
def akin(name):
|
||||
"""Comparison keys for a label name: its tail, and the whole name squashed.
|
||||
|
||||
Case, separators and the namespace in front are noise — what a person
|
||||
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
|
||||
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
|
||||
Two names resemble each other when these sets intersect."""
|
||||
parts = [p for p in WORDS.split(name.lower()) if p]
|
||||
return {parts[-1], "".join(parts)} if parts else set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# plan
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def color_of(value):
|
||||
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
|
||||
return (value or "").lstrip("#").lower()
|
||||
|
||||
|
||||
def drift_of(spec, got):
|
||||
"""Where an existing label disagrees with the spec, as (field, is, want).
|
||||
|
||||
Only color and `exclusive` — a description somebody rewrote is theirs, and
|
||||
the name matched exactly or we would not be here."""
|
||||
out = []
|
||||
if color_of(got.get("color")) != color_of(spec.get("color")):
|
||||
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
|
||||
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
|
||||
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
|
||||
str(bool(spec.get("exclusive"))).lower()))
|
||||
return out
|
||||
|
||||
|
||||
def plan(specs, existing):
|
||||
"""(rows, similar) for one repository, decided before anything is written.
|
||||
|
||||
A row is (name, spec, got, drift), one per canonical label in taxonomy
|
||||
order: `got` is the repository's own payload when that exact name is
|
||||
already there (None when it is not), `drift` what disagrees with the spec.
|
||||
|
||||
`similar` is (name, id, [canonical it resembles]) for the repository's
|
||||
other labels. They are reported and left alone: this script owns the
|
||||
canonical names, not everything that looks like one."""
|
||||
by_name = dict((l.get("name", ""), l) for l in existing or [])
|
||||
|
||||
rows = []
|
||||
for name in specs:
|
||||
got = by_name.get(name)
|
||||
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
|
||||
|
||||
keys = dict((name, akin(name)) for name in specs)
|
||||
similar = []
|
||||
for l in existing or []:
|
||||
name = l.get("name", "")
|
||||
if name in specs:
|
||||
continue
|
||||
mine = akin(name)
|
||||
hits = [n for n in specs if keys[n] & mine]
|
||||
if hits:
|
||||
similar.append((name, l.get("id"), hits))
|
||||
return rows, similar
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# run
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Create the canonical type/* and severity/* labels in a repository")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print the plan; not one writing request")
|
||||
ap.add_argument("--fix", action="store_true",
|
||||
help="also patch color/exclusive on labels that already exist")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
args = ap.parse_args()
|
||||
|
||||
names, orphan = canonical_names()
|
||||
for ns in orphan:
|
||||
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
|
||||
"— nothing created for it" % ns)
|
||||
specs = gmap.label_specs(names)
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
|
||||
# Read first, always: the plan is decided against the repository itself,
|
||||
# never against tmp/issues/.labels.json. That cache is what makes
|
||||
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
|
||||
# answers "what did we create last time", and the answer here has to be
|
||||
# "what does the repository have right now".
|
||||
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
|
||||
rows, similar = plan(specs, existing)
|
||||
|
||||
fixed, drifted = 0, 0
|
||||
for name, spec, got, drift in rows:
|
||||
mark = " exclusive" if spec.get("exclusive") else ""
|
||||
|
||||
if got is None:
|
||||
if args.dry_run:
|
||||
print("create %-20s %s%s" % (name, spec["color"], mark))
|
||||
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)
|
||||
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))
|
||||
continue
|
||||
|
||||
if not drift:
|
||||
print("present %-20s id %s" % (name, got.get("id")))
|
||||
continue
|
||||
|
||||
drifted += 1
|
||||
shown = ", ".join("%s %s -> %s" % d for d in drift)
|
||||
if not args.fix:
|
||||
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
if args.dry_run:
|
||||
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
# Gitea 1.26 patches only the fields it is given, but the unchanged
|
||||
# name and description ride along anyway: they cost nothing and an
|
||||
# older server that reads an absent field as empty would blank them.
|
||||
patch = {"name": name, "description": got.get("description") or ""}
|
||||
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)
|
||||
fixed += 1
|
||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
|
||||
for name, id, hits in similar:
|
||||
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
|
||||
% (name, id, ", ".join(hits)))
|
||||
|
||||
missing = sum(1 for r in rows if r[2] is None)
|
||||
print("%d canonical label(s): %d %s, %d present%s%s"
|
||||
% (len(rows), missing, "to create" if args.dry_run else "created",
|
||||
len(rows) - missing,
|
||||
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
|
||||
", %d similar" % len(similar) if similar else ""))
|
||||
if drifted and not args.fix:
|
||||
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
|
||||
if args.dry_run:
|
||||
print("dry-run — nothing was written")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,6 +23,7 @@ What crosses the boundary, and what does not:
|
||||
milestone milestone.title resolved to an id on write
|
||||
depends — slugs; #N is translated at the edge
|
||||
— number, html_url lands in extra as gitea:/url:
|
||||
— ref extra as branch:; push fills it from git
|
||||
|
||||
`depends:` is the authoritative graph and is always slugs. The body's
|
||||
`## Depends on` section is human prose and is passed through UNCHANGED in both
|
||||
@@ -58,6 +59,11 @@ DEFAULT_COLOR = "#ededed"
|
||||
# that an issue exists somewhere else; only this module knows where.
|
||||
ORIGIN = "gitea"
|
||||
|
||||
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
|
||||
# field: its value is a git branch name and means exactly `ref`, so the domain
|
||||
# carries it in `extra` and never reads it.
|
||||
BRANCH_KEY = "branch"
|
||||
|
||||
|
||||
def label_specs(names):
|
||||
"""{name: {color, description, exclusive}} for the transport to create.
|
||||
@@ -124,6 +130,8 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non
|
||||
"url": payload.get("html_url", ""),
|
||||
"synced": synced or "",
|
||||
}
|
||||
if payload.get("ref"):
|
||||
extra[BRANCH_KEY] = payload["ref"]
|
||||
if payload.get("updated_at"):
|
||||
extra["remote-updated"] = payload["updated_at"]
|
||||
if payload.get("comments"):
|
||||
@@ -174,6 +182,11 @@ def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||
payload["milestone"] = milestone_id
|
||||
if include_state:
|
||||
payload["state"] = iss.state
|
||||
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||
# clear whatever is set on the Gitea side, so the key is left out instead.
|
||||
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
|
||||
if branch:
|
||||
payload["ref"] = branch
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
+79
-39
@@ -21,10 +21,25 @@ returns the whole backlog, so the milestone is resolved up front and every
|
||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
||||
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.
|
||||
|
||||
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
|
||||
nothing when there is nothing to fetch — the payload already carries the
|
||||
comment count, so an issue with none makes no request, and a file left over
|
||||
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).
|
||||
|
||||
Other flags:
|
||||
--deps [--depth N] follow dependencies and pull them too
|
||||
--comments also fetch comments (single issue only)
|
||||
--cached skip issues already on disk instead of refetching
|
||||
--cached skip issues already on disk (body AND comments)
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
||||
@@ -55,6 +70,29 @@ def id_for(payload, store_ids, remote_map, repo, root):
|
||||
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
|
||||
|
||||
|
||||
def comments_path(root, id):
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug."""
|
||||
return os.path.join(root, "%s.comments.md" % id)
|
||||
|
||||
|
||||
def sync_comments(login, base, root, id, number, count):
|
||||
"""Bring <id>.comments.md in line with the server; return it, or None when
|
||||
the issue has no thread.
|
||||
|
||||
`count` is the payload's own comment count, so an issue with none costs no
|
||||
request. A file from an earlier pull is removed when the thread is empty:
|
||||
the absence of the file is the answer, not a gap in what was asked for."""
|
||||
path = comments_path(root, id)
|
||||
comments = _gitea.get_comments(login, base, number) if count else []
|
||||
if comments:
|
||||
with open(path, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
return path
|
||||
if os.path.isfile(path):
|
||||
os.remove(path) # stale thread from an earlier pull
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
|
||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
||||
@@ -67,8 +105,6 @@ def main():
|
||||
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("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||
ap.add_argument("--comments", action="store_true",
|
||||
help="also fetch comments (single issue only)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
@@ -100,7 +136,12 @@ def main():
|
||||
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
|
||||
if gmap.parse_remote_key(k)[0] == repo}
|
||||
|
||||
written, skipped, pending = [], [], []
|
||||
# A closed issue is not a unit of work: filter mode enumerates it but keeps
|
||||
# it out of the store unless the operator named the state. A key is an
|
||||
# address, not a bulk read, so key mode is exempt.
|
||||
drop_closed = filtered and args.state != "closed"
|
||||
|
||||
written, skipped, dropped, pending = [], [], [], []
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
@@ -124,29 +165,34 @@ def main():
|
||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
|
||||
seen_numbers = set(numbers)
|
||||
|
||||
if args.comments and len(queue) > 1:
|
||||
_gitea.die("--comments works on a single issue; loop over the numbers instead")
|
||||
|
||||
# ---- walk ------------------------------------------------------------
|
||||
while queue:
|
||||
payload, depth = queue.pop(0)
|
||||
number = payload["number"]
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
stored = os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
if args.cached and os.path.isfile(issue.path_of(root, id)):
|
||||
skipped.append(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.
|
||||
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||
dropped.append(number)
|
||||
else:
|
||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=extra,
|
||||
synced=_gitea.now_iso())
|
||||
issue.save(root, iss)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # untouched, and not one request spent on it
|
||||
else:
|
||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=extra,
|
||||
synced=_gitea.now_iso())
|
||||
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 "")
|
||||
@@ -157,6 +203,11 @@ def main():
|
||||
seen_numbers.add(n)
|
||||
queue.append((_gitea.get_issue(login, base, n), depth + 1))
|
||||
|
||||
# Nothing is dropped in silence — say how many closed ones stayed out.
|
||||
if dropped:
|
||||
sys.stderr.write("%d closed issue(s) enumerated, not stored"
|
||||
" (--state closed to pull them)\n" % len(dropped))
|
||||
|
||||
# ---- second pass: dependencies that were not yet known on first write --
|
||||
for id, unresolved in pending:
|
||||
newly = [number_of_id[n] for n in unresolved
|
||||
@@ -169,31 +220,20 @@ def main():
|
||||
iss.depends.append(slug)
|
||||
issue.save(root, iss)
|
||||
|
||||
cpath = None
|
||||
if args.comments:
|
||||
id = written[0] if written else skipped[0]
|
||||
_repo, number = gmap.parse_remote_key(issue.load(root, id).extra.get("gitea", ""))
|
||||
comments = _gitea.get_comments(login, base, number)
|
||||
cpath = os.path.join(root, "%s.comments.md" % id)
|
||||
if comments:
|
||||
with open(cpath, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
else:
|
||||
if os.path.isfile(cpath):
|
||||
os.remove(cpath) # stale file from an earlier pull
|
||||
cpath = None
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
index_path, _ = issue_index.build(root)
|
||||
|
||||
# Compact output — the only thing that lands in the model's context.
|
||||
# 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.
|
||||
for id in sorted(set(written) | set(skipped)):
|
||||
iss = issue.load(root, id)
|
||||
note = " (cached)" if id in skipped else ""
|
||||
cpath = comments_path(root, id)
|
||||
if os.path.isfile(cpath):
|
||||
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
|
||||
print("%s [%s] %s — %s %s%s" % (
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), " (cached)" if id in skipped else ""))
|
||||
if cpath:
|
||||
print("comments: %s" % cpath)
|
||||
issue.path_of(root, id), note))
|
||||
print("index: %s" % index_path)
|
||||
if args.deps:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
@@ -25,10 +25,16 @@ way, so nothing is lost, but the `#N` cross-links will be missing.
|
||||
Missing labels are created with the canonical color and, for type/* and
|
||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||
|
||||
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
||||
filled with the current git branch and written back to the file; one that is
|
||||
already set is never touched. Detached HEAD, or no repo at all: no `ref` is
|
||||
sent and a warning says so.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -61,6 +67,21 @@ def select(issues, ids, update):
|
||||
return chosen
|
||||
|
||||
|
||||
def git_branch():
|
||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
||||
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
||||
exits non-zero; both mean "no branch to name", which is not an error."""
|
||||
try:
|
||||
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
except OSError:
|
||||
return None
|
||||
name = r.stdout.strip()
|
||||
if r.returncode != 0 or not name or name == "HEAD":
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
||||
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
||||
@@ -99,6 +120,19 @@ def main():
|
||||
for c in issue.find_cycles(edges):
|
||||
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
||||
|
||||
# ---- branch: -> Gitea `ref` ------------------------------------------
|
||||
# Only an empty field is filled: a branch written by hand is the author's
|
||||
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
||||
# no repo) is not an error — the issue goes up without a `ref`.
|
||||
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
||||
branch = git_branch() if blank else None
|
||||
if branch:
|
||||
for id in blank:
|
||||
issues[id].extra[gmap.BRANCH_KEY] = branch
|
||||
elif blank:
|
||||
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
||||
"— no `ref` on: %s" % ", ".join(blank))
|
||||
|
||||
if args.dry_run:
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
|
||||
Reference in New Issue
Block a user