refactor: turn the repo into a two-plugin marketplace

tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
map.py — md <-> Gitea JSON. The whole translation, and only the translation.
Pure functions: no network, no filesystem, no argparse. Give it a payload and
it hands back a domain Issue; give it an Issue and it hands back a request
body. That purity is the point — it can be reasoned about and tested without a
Gitea anywhere, and it is the single file to open when the two representations
disagree.
Direction of knowledge: this module imports the domain (issue.py) and is
imported by the transport's callers. The domain never imports this.
What crosses the boundary, and what does not:
domain Gitea note
----------------------------------------------------------------------
id (slug) body marker `<!-- tea:id … -->`, first line of
the tracker-side body; stripped out
of the local copy — see below
title title verbatim, both ways
body body verbatim up, verbatim down except
the marker and checkbox state — see
with_id_marker / merge_checkbox_state
state state open/closed, same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
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
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
never rewrites what the author wrote. Deliberate — a translator that edits
prose churns the body on every round trip.
The ONE thing this module does add to a body is the id marker, and it does so
because the slug now has to survive a push: `push.py` deletes the local file,
so the tracker has to remember what the issue was called here. See
`with_id_marker`.
"""
import os
import re
import sys
sys.path.insert(0, os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
import issue # noqa: E402
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
# an issue IS, which is exactly why it lives here and not in the domain.
LABEL_COLORS = {
"type/bug": "#ee0701",
"type/task": "#0e8a16",
"type/refactor": "#1d76db",
"type/test": "#fbca04",
"type/feature": "#5319e7",
"type/draft": "#cccccc",
"severity/low": "#c2e0c6",
"severity/medium": "#fbca04",
"severity/high": "#eb6420",
"severity/showstopper": "#ee0701",
"severity/critical": "#b60205",
}
DEFAULT_COLOR = "#ededed"
# What this bridge writes into the domain's `origin:` field. The domain records
# 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.
Exclusivity and meaning come from the domain taxonomy; only the color is
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
which is why these go through the API."""
out = {}
for name in names:
desc = ""
if name.startswith("type/"):
desc = issue.TYPES.get(name.split("/", 1)[1], "")
out[name] = {
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
"description": desc,
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
}
return out
def remote_key(repo, number):
"""Stable cross-repo handle: owner/repo#42."""
return "%s#%d" % (repo, int(number))
def parse_remote_key(key):
repo, _, num = (key or "").rpartition("#")
return (repo, int(num)) if repo and num.isdigit() else (None, None)
# --------------------------------------------------------------------------
# the id marker: the slug, kept tracker-side
# --------------------------------------------------------------------------
# `push.py` deletes the local file once the tracker has confirmed the write, so
# the slug — the issue's ONLY identity in the domain — cannot live only on this
# machine any more. It rides up in the body as an HTML comment:
#
# <!-- tea:id wire-sqlc-appclick -->
#
# Why the body and not `.remote.json`: the map is a local file, and "the local
# copy is not the record" is the whole point of deleting it. A marker in the
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
# and a second machine — none of which the map does. Why an HTML comment: Gitea
# renders markdown, so it is invisible to a human reader, and it comes back
# verbatim on every API read.
#
# WHERE: the first line of the tracker-side body, followed by one blank line.
# First because it is the one position that does not depend on what sections the
# issue happens to have, and because a human who does look at the raw markdown
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
#
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
# next pull and goes back up with exactly one. There is no code path that adds
# a marker to a body that has not just been stripped.
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
def id_marker(id):
"""The marker line for a slug. One place formats it, one regex reads it."""
return "<!-- tea:id %s -->" % id
def id_in_body(body):
"""The slug a tracker-side body claims, or None.
The FIRST valid marker wins; a second one is ignored here and removed by
`strip_id_marker` on the way in. The captured text must be a slug by the
domain's own rule — a marker holding anything else is not a slug and is
treated as if it were not there, so a mangled comment falls back to the
title instead of naming a file after garbage."""
for line in (body or "").splitlines():
m = _MARKER_LINE.match(line)
if m and issue.SLUG_OK.match(m.group(1)):
return m.group(1)
return None
def strip_id_marker(body):
"""`body` with every marker line removed. Idempotent.
A body that carries no marker is returned byte for byte — the common case
(an issue filed in the web UI) costs nothing and is not reformatted. When a
marker is removed from the top, the blank line it was written with goes with
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
text = body or ""
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
return text
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
return "\n".join(kept).lstrip("\n")
def with_id_marker(body, id):
"""`body` with exactly one marker, as its first line.
Strip-then-prepend, always — that is the guarantee that a body can never end
up with two, however many it arrived with."""
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
# --------------------------------------------------------------------------
# Gitea -> domain
# --------------------------------------------------------------------------
def numbers_in_body(body):
"""`#N` referenced from the body's dependency sections, as ints. Used only
to seed `depends:` on the first pull."""
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
def merge_checkbox_state(remote_body, local_body):
"""The remote body with every tick the local copy already had put back.
The one exception to "a pull overwrites the body", and it is deliberately
the narrowest one that works. A tick is **monotone** — an item only ever
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
reconciled: no base version, no drift tracking, no conflict to resolve. The
set is a set of item TEXTS, and an item comes out ticked when either side
has it ticked. Everything else in the body is still the remote's word.
Matching is on `Checkbox.text`, which the domain parser has already
stripped and rejoined with single spaces, so rewrapping a long item does
not cost it its tick. It is otherwise literal: reword an item and it is a
different item — the tick stays with the wording it was put on.
**The same text more than once** is read as the rule says, as a set: one
ticked local item ticks every remote item with that text. The alternative —
pairing duplicates up by order — is the reading that can still drop a tick
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
nothing), and dropping a tick is the bug this exists to fix. Two items
whose text is identical are the same item to whoever reads them.
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
pull, an empty store — returns the remote body untouched.
The price, accepted explicitly: UNticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, push.
"""
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
if not ticked:
return remote_body
body = remote_body
# set_checkbox trades one character for one character, so line numbers read
# off `remote_body` stay valid against the partially rewritten `body`.
for c in issue.checkboxes(remote_body):
if not c.checked and c.text in ticked:
body = issue.set_checkbox(body, c.line, True)
return body
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
local_body=None):
"""Build a domain Issue from a Gitea issue payload.
id_for_number maps a Gitea number to a local slug — dependencies whose
target has not been pulled yet are dropped from `depends:` (the body still
names them, so nothing is lost) rather than invented.
`local_body` is the body of the copy already in the store, when there is
one. It contributes exactly one thing: its ticked checkboxes survive the
overwrite (merge_checkbox_state). Pass None and the remote body is taken
whole, which is what a first pull does.
The id marker is stripped before anything else looks at the body: it is
transport bookkeeping, and the caller has already read the slug off it
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
lands on disk — sees the body the author wrote."""
body = merge_checkbox_state(
strip_id_marker((payload.get("body") or "").strip()), local_body)
id_for_number = id_for_number or {}
numbers = list(numbers_in_body(body))
for n in extra_numbers:
if n not in numbers:
numbers.append(n)
depends, unresolved = [], []
for n in numbers:
slug = id_for_number.get(n)
if slug and slug != id and slug not in depends:
depends.append(slug)
elif not slug:
unresolved.append(n)
extra = {
"gitea": remote_key(repo, payload["number"]),
"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"):
extra["comments"] = payload["comments"]
iss = issue.Issue(
id=id,
title=payload.get("title", ""),
body=body,
state=payload.get("state") or "open",
labels=[l.get("name", "") for l in payload.get("labels") or []],
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
milestone=(payload.get("milestone") or {}).get("title") or "",
depends=depends,
origin=ORIGIN,
extra=extra)
return iss, unresolved
def render_comments(comments):
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
out = []
for c in comments:
out.append("## comment %s%s%s" % (
c.get("id"), (c.get("user") or {}).get("login", ""),
(c.get("created_at") or "")[:10]))
out.append("")
out.append((c.get("body") or "(empty)").strip())
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# domain -> Gitea
# --------------------------------------------------------------------------
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
"""Request body for POST /issues or PATCH /issues/{n}.
The prose is sent verbatim — see the module docstring on why slugs in
`## Depends on` are not rewritten to `#N`. The one addition is the id
marker, prepended (never appended) so the tracker remembers the slug after
push has deleted the local file. `from_api` takes it straight back off, so
the body still round-trips byte for byte."""
payload = {"title": iss.title,
"body": with_id_marker(iss.body.strip(), iss.id)}
if label_ids is not None:
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
if iss.assignees:
payload["assignees"] = list(iss.assignees)
if milestone_id is not None:
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
def apply_remote(iss, payload, repo, synced):
"""Stamp the sync-owned fields onto an issue after a successful write.
Mutates and returns it; `origin` is the one domain field this touches."""
iss.origin = ORIGIN
iss.extra["gitea"] = remote_key(repo, payload["number"])
iss.extra["url"] = payload.get("html_url", "")
iss.extra["synced"] = synced
if payload.get("updated_at"):
iss.extra["remote-updated"] = payload["updated_at"]
return iss
def number_of(iss):
"""Gitea number for an already-synced issue, or None."""
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
return n