Files
marketplace/skills/sync/scripts/labels.py
naudachu 596cf853e8 fix: keep request payloads out of the issue store
`labels.py` handed `_gitea.api` the issue store as a place to put the
request file, and on a checkout without a store that quietly created
`tmp/issues/.payload/`. Bootstrapping a repository's labels touches no
issue at all, so the one rule the store has — nothing materializes it as
a side effect of a write — was broken by an operation that has no
business knowing the store exists.

Where a request body goes was never the caller's decision to make. It is
now the transport's: `tmp/payload/`, resolved from `_gitea.py`'s own
location the way both domains resolve theirs, so every caller — sync and
wiki alike — writes to one directory whatever it was invoked from, and
`out_root` is gone from `api`, `add_dependency` and all six call sites.
The directory is created by the first write of a run and not before: a
`--dry-run` leaves nothing behind. `tmp/` is already gitignored.

The name carries the distinction the old path lost. A store holds the
only copy of something; this holds debris kept for a retry or a
post-mortem, and deleting it costs nothing. A dotdir sitting among an
issue's files claimed otherwise, and `ls tmp/issues` started lying about
what existed.

tests/test_payload_root.py runs the real `labels.py` in a throwaway repo
against a fake `tea` on PATH: no store appears, the payloads land in
tmp/payload/, a dry run writes nothing, and a run from a subdirectory
still resolves to the repo root. Two source checks keep the callers from
drifting apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:25:33 +05:00

240 lines
10 KiB
Python

#!/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.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
creates it — the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
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("/", "-"))
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("/", "-"))
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()