diff --git a/skills/issue/SKILL.md b/skills/issue/SKILL.md index 66c1a0f..53452e4 100644 --- a/skills/issue/SKILL.md +++ b/skills/issue/SKILL.md @@ -99,6 +99,55 @@ afterwards, and `issue_index.py` to refresh the table. If the issue is synced (`origin: gitea`), your edit is local until you run `push.py --update` from `/tea:sync`. Nothing tracks that drift automatically. +## Writing a proper description + +Issues get filed on the run — "comments aren't pulled", "the guard broke". +That is a request, not a statement of work: no reproduction steps, no +`path/file:line`, acceptance criteria nobody can check. Rewriting one into the +canonical format is a procedure, not improvisation. + +1. **Read the issue whole**, and everything it points at — the ids in + `depends:`, the `## Spec` target, the files it names. +2. **Determine the type and its template.** The `type/*` label selects one of + the templates in [`references/format.md`](references/format.md), and that + template's section list is the shape you are aiming at. If the label is + missing or wrong, decide it now and fix `labels:`; promoting a `type/draft` + to a concrete type is this same step. +3. **Locate the anchor points in the code.** Grep the repo for every file, + symbol, command, and error string the issue mentions, until you can name + lines: + ```bash + grep -rn 'GITEA_LOGIN' hooks/ skills/ + ``` + Work that does not exist yet still has anchor points — the files the change + will land in, and the ones that will call it. +4. **Gather the missing context.** What has to be there when you are done: + - code references in the `path/file.ext:line` form, for every place the + change lands; + - reproduction steps — exact commands and their real output (`type/bug` + splits them across `## Steps to reproduce` / `## Expected` / `## Actual`); + - acceptance criteria that are objectively checkable: a command that exits + 0, a file that exists, a section that is present — not aspirations; + - a real value for `## Spec` — a repo path, a URL, or the literal `none`. + + **A missing fact is either found in the repository or becomes a question to + the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in + `## Spec` as the legitimate answer it is — never a plausible-looking link. +5. **Rewrite the sections** with Edit: every section of the template, in the + template's order, English headers and Russian prose. Replace the body; do + not append a second telling of the same issue below the old one. +6. **Check it:** + ```bash + python3 /scripts/issue_check.py wire-sqlc-appclick + ``` + Errors mean malformed, warnings mean the type's template is not fully + filled in. Re-run `issue_index.py` if the labels changed. + +The procedure is identical for `origin: local` and `origin: gitea` — it works +on `tmp/issues/.md`, and this layer does not know the difference. Getting +the rewritten body into the tracker is a separate decision — `push.py --update` +in `/tea:sync` — and is no part of this. + ## Dependency graph `depends:` is the authoritative edge list; the body's `## Depends on` section diff --git a/skills/issue/references/format.md b/skills/issue/references/format.md index 0609903..d41d063 100644 --- a/skills/issue/references/format.md +++ b/skills/issue/references/format.md @@ -34,6 +34,7 @@ assignees: [naudachu] milestone: v0.2 depends: [migrate-schema] origin: gitea +branch: feat/wire-sqlc gitea: claude-skills/tea#42 remote-updated: 2026-08-09T18:24:01Z synced: 2026-08-09T18:40:00Z @@ -55,6 +56,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42 | `depends` | domain | ids this issue depends on — **the authoritative graph** | | `origin` | domain | `local`, or the name of a tracker this also lives in | | `gitea` | sync | the handle in that tracker: `owner/repo#N` | +| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one | | `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping | Domain fields render first, in the order above; sync fields follow, sorted. diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 4d75604..38ec351 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -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 ` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/.md` | +| `pull.py ` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/.md`, plus `.comments.md` when the thread is not empty | | `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success | | `comment.py --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 ``: `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/.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 /scripts/labels.py --dry-run # the plan, no writes +python3 /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: `.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 diff --git a/skills/sync/scripts/labels.py b/skills/sync/scripts/labels.py new file mode 100644 index 0000000..6594e1b --- /dev/null +++ b/skills/sync/scripts/labels.py @@ -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() diff --git a/skills/sync/scripts/map.py b/skills/sync/scripts/map.py index f02e073..e89b549 100644 --- a/skills/sync/scripts/map.py +++ b/skills/sync/scripts/map.py @@ -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 diff --git a/skills/sync/scripts/pull.py b/skills/sync/scripts/pull.py index 0857676..dee16f9 100644 --- a/skills/sync/scripts/pull.py +++ b/skills/sync/scripts/pull.py @@ -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/.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 .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") diff --git a/skills/sync/scripts/push.py b/skills/sync/scripts/push.py index a1920e8..b674032 100644 --- a/skills/sync/scripts/push.py +++ b/skills/sync/scripts/push.py @@ -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]