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:
naudachu
2026-08-10 00:37:57 +05:00
parent 091dceec1d
commit 9234d8004f
7 changed files with 457 additions and 42 deletions
+44 -3
View File
@@ -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
+236
View File
@@ -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()
+13
View File
@@ -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
View File
@@ -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")
+34
View File
@@ -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]