feat: add the kettle CLI, replacing the plugin's Python scripts

The plugin resolved its issue store from `__file__`, which put it inside a
versioned plugin cache: issues written from one project were invisible from the
next, and `origin: local` files — the only copy of that work by definition —
were stranded a version bump at a time. The walk that answers "which directory
is the project" was written three times over, and in a linked worktree the three
disagreed. Both are runtime failures rather than logic ones, so the fix is a
compiled binary: one walk, imported rather than re-derived, and a layering rule
the build graph enforces instead of a grep.

Seven packages, knowledge flowing one way. `project` answers which directory is
the project and depends on nothing. `issue` is the domain — format, taxonomy,
validation, checkboxes, dependency graph, the store, eviction — offline, with no
tracker in it. `wire` holds the protocol shapes. `gitea` is the transport,
`mapping` the bridge, `config` the credentials, `cmd` the command tree. Four
tests hold the boundaries, each failing on a real mistake rather than a naming
convention.

The marker moves to `.kettle/` and the login pin moves out of the harness's
settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens
live in one file per machine, mode 0600, outside every working tree. That
retires the PreToolUse guard hook entirely — the binary holds its own
credentials, so a command running under a login nobody chose is not expressible
rather than caught.

`kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a
move: a store left behind at an old path is one somebody edits by accident
months later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
package mapping
import (
"slices"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// Gitea -> domain.
// PayloadOptions are the things a caller knows and this package cannot: what
// the store already holds, what the tracker's numbers mean locally, and what
// time it is.
type PayloadOptions struct {
// IDForNumber maps a Gitea number to a local slug. A dependency whose
// target has not been pulled yet is dropped from `depends:` rather than
// invented — the body still names it, so nothing is lost, and a made-up
// slug would be an edge to a file that does not exist.
IDForNumber map[int]string
// ExtraNumbers are dependencies the caller learned somewhere other than the
// body, folded in with the ones the body names.
ExtraNumbers []int
// Synced is the timestamp stamped into `synced:`. The clock belongs to the
// caller: a package with a clock in it is not a pure one.
Synced string
// LocalBody 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. Empty is what a first pull passes.
LocalBody string
}
// FromPayload builds a domain issue from a Gitea issue payload, and returns the
// numbers it could not resolve to a slug.
//
// 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 to
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
// what lands on disk — sees the body the author wrote.
func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
numbers := NumbersInBody(body)
for _, n := range opt.ExtraNumbers {
if !slices.Contains(numbers, n) {
numbers = append(numbers, n)
}
}
// A number that resolves to this issue itself is dropped without a word: a
// body may well name its own number, and a self-edge is a cycle the graph
// would report as an error the author cannot fix.
var deps []string
var unresolved []int
for _, n := range numbers {
slug := opt.IDForNumber[n]
switch {
case slug != "" && slug != id && !slices.Contains(deps, slug):
deps = append(deps, slug)
case slug == "":
unresolved = append(unresolved, n)
}
}
// The repository the caller asked for, never the one the payload names: a
// dependency listing answers with issues from elsewhere, and this is the
// handle for the copy landing in THIS store.
extra := map[string]string{
GiteaKey: wire.Key{Repo: repo, Number: p.Number}.String(),
URLKey: p.HTMLURL,
SyncedKey: opt.Synced,
}
if p.Ref != "" {
extra[BranchKey] = p.Ref
}
if p.UpdatedAt != "" {
extra[RemoteUpdatedKey] = p.UpdatedAt
}
// Zero comments is not a fact worth a line in the file — every issue that
// has never been discussed would carry one.
if p.Comments > 0 {
extra[CommentsKey] = strconv.Itoa(p.Comments)
}
state := p.State
if state == "" {
state = "open"
}
// Appended into nil slices, so an issue with no labels is the same value as
// one loaded from a file — the store's own parser yields nothing, not an
// empty list, and two spellings of "none" is a comparison bug waiting.
var labels []string
for _, l := range p.Labels {
labels = append(labels, l.Name)
}
var assignees []string
for _, a := range p.Assignees {
assignees = append(assignees, a.Login)
}
milestone := ""
if p.Milestone != nil {
milestone = p.Milestone.Title
}
return &issue.Issue{
ID: id,
Title: p.Title,
Body: body,
State: state,
Labels: labels,
Assignees: assignees,
Milestone: milestone,
Depends: deps,
Origin: Origin,
Extra: extra,
}, unresolved
}
// NumbersInBody is every `#N` referenced from the body's dependency sections.
// Used only to seed `depends:` on the first pull — after that the metadata
// field is the graph and the prose is prose.
func NumbersInBody(body string) []int {
var out []int
for _, ref := range issue.BodyDepRefs(body) {
if !strings.HasPrefix(ref.Ref, "#") {
continue
}
if n, err := strconv.Atoi(ref.Ref[1:]); err == nil {
out = append(out, n)
}
}
return out
}
// MergeCheckboxState is the remote body with every tick the local copy already
// had put back.
//
// The one exception to "a pull overwrites the body", and 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.
//
// 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.
func MergeCheckboxState(remoteBody, localBody string) string {
ticked := map[string]bool{}
for _, c := range issue.Checkboxes(localBody) {
if c.Checked {
ticked[c.Text] = true
}
}
if len(ticked) == 0 {
return remoteBody
}
body := remoteBody
// SetCheckbox trades one character for one character, so line numbers read
// off remoteBody stay valid against the partially rewritten body.
for _, c := range issue.Checkboxes(remoteBody) {
if c.Checked || !ticked[c.Text] {
continue
}
// The line was just read off remoteBody by the same parser, so this
// cannot fail; if it ever did, one unticked item is a smaller loss than
// abandoning the merge and dropping every other tick with it.
if next, err := issue.SetCheckbox(body, c.Line, true); err == nil {
body = next
}
}
return body
}
// RenderComments flattens a comment thread to markdown. Read-only: nothing
// writes it back, which is why it may be as lossy as a reader needs.
func RenderComments(comments []wire.Comment) string {
var out []string
for _, c := range comments {
day := c.CreatedAt
if len(day) > 10 {
day = day[:10]
}
body := strings.TrimSpace(c.Body)
if body == "" {
body = "(empty)"
}
out = append(out,
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+c.User.Login+" — "+day,
"", body, "")
}
return strings.Join(out, "\n")
}
+123
View File
@@ -0,0 +1,123 @@
package mapping
import (
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
// an issue IS, which is exactly why the table lives here and not in the domain
// — internal/issue/taxonomy.go says as much where the labels themselves are.
//
// The keys are the canonical set and nothing else. TestEveryCanonicalLabelHasA
// Color walks issue.CanonicalLabels() and fails on a gap, so a type or a
// severity added over there cannot quietly arrive here as grey.
var labelColors = map[string]string{
"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",
}
// DefaultColor paints everything outside the canonical set. `tech/*` and
// `comp/*` are project-specific and have no preset, so guessing a color for one
// would be inventing a meaning it does not have.
const DefaultColor = "#ededed"
// LabelColor is the hex code a label is painted with in the tracker.
func LabelColor(name string) string {
if c, ok := labelColors[name]; ok {
return c
}
return DefaultColor
}
// LabelSpecs is the request body for each name, in the order given.
//
// A wire.LabelRequest and not a shape of this package's own: it is field for
// field what a label create takes, and a second spelling of it would mean the
// bootstrap command copying four fields across on its way to the transport.
// Exclusivity and meaning come from the domain taxonomy; only the color is
// decided here.
//
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
// its plan in that order — a map would shuffle the plan on every run and make
// two identical runs look like different ones.
func LabelSpecs(names []string) []wire.LabelRequest {
ns := exclusiveNamespaces()
out := make([]wire.LabelRequest, 0, len(names))
for _, name := range names {
out = append(out, wire.LabelRequest{
Name: name,
Color: LabelColor(name),
Description: typeMeaning(name),
Exclusive: hasAnyPrefix(name, ns),
})
}
return out
}
// CanonicalLabelSpecs is the set a repository needs before a push can attach
// anything.
//
// Derived from the domain's own list rather than restated: add a type over in
// the taxonomy and the next bootstrap creates it, with no line changing here
// except the color it is painted with.
func CanonicalLabelSpecs() []wire.LabelRequest { return LabelSpecs(issue.CanonicalLabels()) }
// exclusiveNamespaces are the namespaces at most one label may come from, read
// off the canonical set rather than listed again — the domain publishes exactly
// the exclusive namespaces there, in full, and that is what makes the set
// canonical.
//
// A prefix test and not a membership test, on purpose: a project's own
// `type/spike` is still exclusive. Being one of a set of alternatives is a
// property of the namespace, not of the members the taxonomy happens to know.
func exclusiveNamespaces() []string {
var out []string
seen := map[string]bool{}
for _, name := range issue.CanonicalLabels() {
ns, _, ok := strings.Cut(name, "/")
if !ok || seen[ns] {
continue
}
seen[ns] = true
out = append(out, ns+"/")
}
return out
}
// typeMeaning is the description a `type/*` label carries into the tracker, so
// the meaning a reader needs is on the chip rather than in this repository.
// Nothing else gets one: a severity explains itself, and a project's own
// namespaces are not ours to describe.
func typeMeaning(name string) string {
tail, ok := strings.CutPrefix(name, "type/")
if !ok {
return ""
}
for _, t := range issue.Types {
if t.Name == tail {
return t.Meaning
}
}
return ""
}
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
+95
View File
@@ -0,0 +1,95 @@
package mapping
import (
"regexp"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
var hexColor = regexp.MustCompile(`^#[0-9a-f]{6}$`)
// The canonical set is the domain's, and every member of it must have a color
// here. A type added over in the taxonomy that arrived as grey would look like
// a label somebody created by hand.
func TestEveryCanonicalLabelHasAColor(t *testing.T) {
for _, name := range issue.CanonicalLabels() {
color := LabelColor(name)
switch {
case color == DefaultColor:
t.Errorf("%s has no color of its own", name)
case !hexColor.MatchString(color):
t.Errorf("%s = %q, want #rrggbb in lower case", name, color)
}
}
// And the other direction: a color left behind after a label was retired
// paints nothing and is a lie about what the taxonomy holds.
if len(labelColors) != len(issue.CanonicalLabels()) {
t.Errorf("%d colors for %d canonical labels — one of the two lists moved without the other",
len(labelColors), len(issue.CanonicalLabels()))
}
// Anything outside the set is project-specific and nobody here can guess
// what it means.
if got := LabelColor("tech/sql"); got != DefaultColor {
t.Errorf("LabelColor(tech/sql) = %q, want the default", got)
}
}
func TestLabelSpecs(t *testing.T) {
cases := []struct {
name string
description string
exclusive bool
}{
{"type/bug", "Something behaves incorrectly in existing code", true},
{"type/draft", "Idea captured for later; not ready for work", true},
{"severity/critical", "", true},
// Exclusivity is a property of the namespace, not of the members the
// taxonomy happens to know.
{"type/spike", "", true},
{"tech/sql", "", false},
{"comp/appclick", "", false},
}
names := make([]string, len(cases))
for i, c := range cases {
names[i] = c.name
}
specs := LabelSpecs(names)
if len(specs) != len(cases) {
t.Fatalf("%d specs for %d names", len(specs), len(cases))
}
for i, c := range cases {
got := specs[i]
// The order is the taxonomy's: a bootstrap prints its plan in it, and
// two identical runs must not look like different ones.
if got.Name != c.name {
t.Fatalf("spec %d is %s, want %s", i, got.Name, c.name)
}
if got.Description != c.description {
t.Errorf("%s description = %q, want %q", c.name, got.Description, c.description)
}
if got.Exclusive != c.exclusive {
t.Errorf("%s exclusive = %v, want %v", c.name, got.Exclusive, c.exclusive)
}
if got.Color != LabelColor(c.name) {
t.Errorf("%s color = %q", c.name, got.Color)
}
}
}
func TestCanonicalLabelSpecsAreTheDomainsList(t *testing.T) {
specs := CanonicalLabelSpecs()
want := issue.CanonicalLabels()
if len(specs) != len(want) {
t.Fatalf("%d specs, want %d", len(specs), len(want))
}
for i, name := range want {
if specs[i].Name != name {
t.Errorf("spec %d = %s, want %s", i, specs[i].Name, name)
}
if !specs[i].Exclusive {
t.Errorf("%s must be exclusive — the canonical set IS the exclusive namespaces", name)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package mapping
import (
"os/exec"
"strings"
"testing"
)
// The bridge translates values and nothing else: no network, no filesystem, no
// clock, no configuration. Every one of those is a caller's to supply, which is
// what lets this package be reasoned about and tested without a Gitea anywhere.
//
// Two imports and no more: internal/issue for what an issue is, and
// internal/wire for the shapes on the other side. wire is allowed precisely
// because it is inert — shapes and identifiers over the standard library, with
// a layering test of its own — so naming a payload here costs nothing and
// reaches nowhere.
//
// DIRECT imports, not the dependency walk internal/issue does. The domain
// reaches os through internal/project and that is the domain's business; what
// this test is about is what this package itself reaches for. A transport that
// grew a helper here — or a lookup that quietly opened a config file — is what
// it catches.
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
"net": "an HTTP call belongs in the transport",
"os": "a pure function reads no file and no environment",
"os/exec": "nothing here shells out",
"io/ioutil": "a pure function reads no file",
"time": "the clock is the caller's; a timestamp arrives as a string",
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("mapping imports %s — %s", dep, why)
}
}
}
+106
View File
@@ -0,0 +1,106 @@
// Package mapping is md <-> Gitea JSON. The whole translation, and only the
// translation.
//
// Pure functions: no network, no filesystem, no flags, no clock. 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 one package to open when the
// two representations disagree.
//
// Direction of knowledge: this package imports the domain and the protocol
// (internal/wire), and nothing imports it but the command layer. The domain
// never imports it, and TestDomainDependsOnNothing over in internal/issue fails
// the moment it does; the transport never imports it either, and
// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both
// sides speak wire's shapes, which is what lets the two meet without either one
// reaching into the other.
//
// What crosses the boundary, and what does not:
//
// domain Gitea note
// ----------------------------------------------------------------------
// id (slug) body marker <!-- kettle:id … -->, first line of the
// tracker-side body; stripped out of the
// local copy — see marker.go
// title title verbatim, both ways
// body body verbatim up, verbatim down except the
// marker and checkbox state
// state state open/closed, the 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 this 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 package adds to a body is the id marker, and it does so
// because the slug has to survive a push: push deletes the local file, so the
// tracker has to be the thing that remembers what the issue was called here.
package mapping
import (
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// Origin is what this bridge writes into the domain's `origin:` field. The
// domain records that an issue exists somewhere else; only this layer knows
// where, and what the handle beside it means.
const Origin = "gitea"
// The sync-owned metadata fields, named once. Every one of them is bookkeeping
// about a tracker, which is why the domain carries them verbatim in
// Issue.Extra and never reads them — the format's ownership table draws the
// same line. A field spelled in three call sites is a field that gets renamed
// in two.
const (
// GiteaKey is the handle in the tracker: owner/repo#42, a wire.Key written
// out. Cross-repo on purpose — a number alone is only unique inside one
// repository, and an issue that has been moved, or a store that has ever
// pointed at two repositories, needs the answer to say which.
GiteaKey = "gitea"
// URLKey is the issue's web address, for a receipt a human can click.
URLKey = "url"
// SyncedKey is when this copy was last written from or to the tracker —
// how old the working copy is, and nothing more.
SyncedKey = "synced"
// RemoteUpdatedKey is the tracker's own updated_at.
RemoteUpdatedKey = "remote-updated"
// CommentsKey is how many comments the tracker holds, so a reader knows a
// thread exists without fetching it.
CommentsKey = "comments"
// BranchKey is Gitea's `ref` — the branch an issue is pinned to. Its value
// is a git branch name and means exactly `ref`, which is what makes it a
// sync field rather than a domain one.
BranchKey = "branch"
)
// RemoteKeyOf is the handle an issue carries, and whether it carries one at
// all.
//
// ok is false for anything that is not a handle: an empty field on a
// never-pushed issue, a line somebody hand-edited, a key written by a format
// that predates this one — and a bare `#42`, which names a number without the
// repository that makes it mean something. Callers act on ok rather than on a
// zero number, because "#0" and "not synced" would otherwise be the same
// answer.
func RemoteKeyOf(i *issue.Issue) (key wire.Key, ok bool) {
k, err := wire.ParseKey(i.Extra[GiteaKey])
if err != nil || k.Repo.Zero() {
return wire.Key{}, false
}
return k, true
}
// NumberOf is the Gitea number of an already-synced issue; ok is false for one
// that has never been pushed.
func NumberOf(i *issue.Issue) (number int, ok bool) {
k, ok := RemoteKeyOf(i)
return k.Number, ok
}
+381
View File
@@ -0,0 +1,381 @@
package mapping
import (
"encoding/json"
"reflect"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// The repository the fixtures are pushed to. A wire.Repo and not a string: the
// handle in `gitea:` is a key, and a key is a repository and a number.
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
// A file exactly as the store holds it: domain fields, then the sync fields the
// domain carries and never reads.
const stored = `---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
branch: feat/wire-sqlc
gitea: claude-skills/tea#42
synced: 2026-08-09T18:40:00Z
---
# Wire sqlc into the appclick repo layer
## Summary
Проводка sqlc в слой репозиториев.
## Spec
none
## Depends on
- #7 — нужна схема БД из этого issue
## Acceptance criteria
- [x] сгенерирован код
- [ ] тесты зелёные
`
func ptr[T any](v T) *T { return &v }
func roundTripOptions() RequestOptions {
return RequestOptions{
LabelIDs: map[string]int64{"type/task": 11, "tech/sql": 12},
MilestoneID: ptr(int64(5)),
IncludeState: true,
}
}
// The whole point of the package in one test: everything the format says is
// preserved comes back, and the body comes back byte for byte.
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
local := issue.FromText(stored, "wire-sqlc-appclick")
req := ToRequest(local, roundTripOptions())
if req.Title == nil || *req.Title != local.Title {
t.Errorf("title = %v, want %q", req.Title, local.Title)
}
if req.Body == nil {
t.Fatal("the request carries no body — a create would file an empty issue")
}
if got := IDInBody(*req.Body); got != local.ID {
t.Errorf("the request body does not claim the slug: %q", got)
}
if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) {
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
got, strings.TrimSpace(local.Body))
}
if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) {
t.Errorf("labels = %v, want %v", req.Labels, want)
}
if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) {
t.Errorf("assignees = %v, want %v", req.Assignees, want)
}
if req.Milestone == nil || *req.Milestone != 5 {
t.Errorf("milestone = %v, want 5", req.Milestone)
}
if req.State == nil || *req.State != "open" {
t.Errorf("state = %v", req.State)
}
if req.Ref == nil || *req.Ref != "feat/wire-sqlc" {
t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref)
}
// What the tracker hands back is the body it was given, plus its own
// bookkeeping.
echo := &wire.Issue{
Number: 42,
Title: *req.Title,
Body: *req.Body,
State: "open",
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
Ref: *req.Ref,
Comments: 3,
Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}},
Assignees: []wire.User{{Login: "naudachu"}},
Milestone: &wire.Milestone{ID: 5, Title: "v0.2"},
}
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
IDForNumber: map[int]string{7: "migrate-schema"},
Synced: "2026-08-09T18:40:00Z",
})
if len(unresolved) != 0 {
t.Errorf("unresolved = %v, want none", unresolved)
}
if back.Body != strings.TrimSpace(local.Body) {
t.Errorf("the body did not survive the trip:\n--- got ---\n%s\n--- want ---\n%s",
back.Body, strings.TrimSpace(local.Body))
}
if strings.Contains(back.Body, "kettle:id") || strings.Contains(back.Body, "tea:id") {
t.Error("the marker reached the local copy — it is transport bookkeeping and belongs nowhere near disk")
}
for _, c := range []struct{ name, got, want string }{
{"id", back.ID, local.ID},
{"title", back.Title, local.Title},
{"state", back.State, local.State},
{"milestone", back.Milestone, local.Milestone},
{"origin", back.Origin, local.Origin},
{"gitea", back.Extra[GiteaKey], "claude-skills/tea#42"},
{"branch", back.Extra[BranchKey], "feat/wire-sqlc"},
{"synced", back.Extra[SyncedKey], "2026-08-09T18:40:00Z"},
{"url", back.Extra[URLKey], "https://git.noodles.cam/claude-skills/tea/issues/42"},
{"remote-updated", back.Extra[RemoteUpdatedKey], "2026-08-09T18:24:01Z"},
{"comments", back.Extra[CommentsKey], "3"},
} {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if !reflect.DeepEqual(back.Labels, local.Labels) {
t.Errorf("labels = %v, want %v", back.Labels, local.Labels)
}
if !reflect.DeepEqual(back.Assignees, local.Assignees) {
t.Errorf("assignees = %v, want %v", back.Assignees, local.Assignees)
}
// `depends:` is slugs; the `#7` the prose names is translated at this edge
// and the prose itself is left alone.
if !reflect.DeepEqual(back.Depends, local.Depends) {
t.Errorf("depends = %v, want %v", back.Depends, local.Depends)
}
if !strings.Contains(back.Body, "- #7 — нужна схема БД из этого issue") {
t.Error("the ## Depends on prose was rewritten; it is the author's text and passes through unchanged")
}
// And the strongest form of "no churn": pushing what came back sends
// exactly what was sent the first time.
if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
}
}
// The other shape an issue comes in: nothing scheduled, nobody assigned.
func TestNoMilestoneAndNoAssignees(t *testing.T) {
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}})
if req.Assignees != nil {
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees)
}
if req.Milestone != nil {
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone)
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} {
if strings.Contains(body, key) {
t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body)
}
}
// A resolved-but-empty label set is the opposite statement and must be sent.
if !strings.Contains(body, `"labels":[11]`) {
t.Errorf("labels missing from %s", body)
}
empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(empty), `"labels":[]`) {
t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty)
}
silent, err := json.Marshal(ToRequest(local, RequestOptions{}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(silent), `"labels"`) {
t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent)
}
back, unresolved := FromPayload(&wire.Issue{
Number: 9,
Title: "A lone issue",
Body: WithIDMarker("## Summary\nОдин.", "lone"),
State: "open",
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
if len(unresolved) != 0 {
t.Errorf("unresolved = %v", unresolved)
}
if back.Milestone != "" || back.Assignees != nil || back.Labels != nil {
t.Errorf("empty came back as something: milestone=%q assignees=%v labels=%v",
back.Milestone, back.Assignees, back.Labels)
}
if _, ok := back.Extra[BranchKey]; ok {
t.Error("an absent ref must not write an empty branch: field")
}
if _, ok := back.Extra[CommentsKey]; ok {
t.Error("zero comments is not a fact worth a line in the file")
}
if !strings.Contains(back.Text(), "milestone: none") {
t.Error("an empty milestone must render back as none")
}
}
// A dependency whose target is not in the store yet is reported, never invented:
// a made-up slug is an edge to a file that does not exist.
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body},
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
t.Errorf("depends = %v, want %v", back.Depends, want)
}
if want := []int{8}; !reflect.DeepEqual(unresolved, want) {
t.Errorf("unresolved = %v, want %v", unresolved, want)
}
if !strings.Contains(back.Body, "- #8") {
t.Error("the body still names it, which is why dropping it from depends: loses nothing")
}
}
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n"
back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
PayloadOptions{
IDForNumber: map[int]string{7: "seven", 9: "nine"},
ExtraNumbers: []int{7, 9},
})
if want := []string{"seven", "nine"}; !reflect.DeepEqual(back.Depends, want) {
t.Errorf("depends = %v, want %v", back.Depends, want)
}
}
// The one exception to "a pull overwrites the body", and the narrowest one that
// works: a tick only ever travels one way, so the two sides are a set union.
func TestMergeCheckboxState(t *testing.T) {
cases := []struct {
name string
remote, local string
want string
wantUnchangedRef bool
}{
{
name: "a local tick survives the overwrite",
remote: "- [ ] один\n- [ ] два\n",
local: "- [x] два\n",
want: "- [ ] один\n- [x] два\n",
},
{
name: "rewrapping an item does not cost it its tick",
remote: "- [ ] очень длинный\n пункт\n",
local: "- [x] очень длинный пункт\n",
want: "- [x] очень длинный\n пункт\n",
},
{
name: "the same text twice is the same item to whoever reads it",
remote: "- [ ] дубль\n- [ ] дубль\n",
local: "- [ ] дубль\n- [x] дубль\n",
want: "- [x] дубль\n- [x] дубль\n",
},
{
name: "a first pull has nothing to merge",
remote: "- [ ] один\n",
local: "",
want: "- [ ] один\n",
},
{
name: "unticking is not monotone, so it does not travel",
remote: "- [x] один\n",
local: "- [ ] один\n",
want: "- [x] один\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := MergeCheckboxState(c.remote, c.local); got != c.want {
t.Errorf("got:\n%q\nwant:\n%q", got, c.want)
}
})
}
}
// What `gitea:` holds is a key, and it round-trips through the one parser.
// Anything that is not a key reads as "not synced" — never as issue #0, and
// never as the issue -3 a bare strconv.Atoi would have handed back.
func TestRemoteKeyRoundTrip(t *testing.T) {
cases := []struct {
key string
repo string
number int
ok bool
}{
{"claude-skills/tea#42", "claude-skills/tea", 42, true},
{"o/r#1", "o/r", 1, true},
// Never pushed, hand-edited, or written by a format that predates this
// one — all the same answer, and none of them is issue #0. `#42` is in
// the list because a handle without a repository addresses nothing.
{"", "", 0, false},
{"claude-skills/tea", "", 0, false},
{"#42", "", 0, false},
{"o/r#", "", 0, false},
{"o/r#-3", "", 0, false},
{"o/r#4x", "", 0, false},
}
for _, c := range cases {
got, ok := RemoteKeyOf(&issue.Issue{Extra: map[string]string{GiteaKey: c.key}})
if got.Repo.String() != c.repo || got.Number != c.number || ok != c.ok {
t.Errorf("RemoteKeyOf(%q) = (%v, %v), want (%q, %d, %v)",
c.key, got, ok, c.repo, c.number, c.ok)
}
if c.ok && got.String() != c.key {
t.Errorf("the key formatted back as %q, want %q", got, c.key)
}
}
}
func TestNumberOf(t *testing.T) {
synced := &issue.Issue{Extra: map[string]string{GiteaKey: "o/r#42"}}
if n, ok := NumberOf(synced); n != 42 || !ok {
t.Errorf("NumberOf = (%d, %v), want (42, true)", n, ok)
}
if _, ok := NumberOf(&issue.Issue{}); ok {
t.Error("an issue that has never been pushed has no number")
}
}
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
local := &issue.Issue{ID: "x", Origin: issue.Local}
ApplyRemote(local, &wire.Issue{
Number: 42,
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
if local.IsLocal() {
t.Error("origin must move: the work exists somewhere else now")
}
if local.Extra[GiteaKey] != "o/r#42" || local.Extra[URLKey] == "" ||
local.Extra[SyncedKey] != "2026-08-11T10:00:00Z" ||
local.Extra[RemoteUpdatedKey] != "2026-08-09T18:24:01Z" {
t.Errorf("extra = %v", local.Extra)
}
}
func TestRenderComments(t *testing.T) {
got := RenderComments([]wire.Comment{
{ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "},
{ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""},
})
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
"## comment 2 — bot — \n\n(empty)\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
+110
View File
@@ -0,0 +1,110 @@
package mapping
import (
"regexp"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
// The id marker: the slug, kept tracker-side.
//
// Push 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:
//
// <!-- kettle:id wire-sqlc-appclick -->
//
// Why the body and not a local number -> slug ledger: the ledger is a local
// file, and "the local copy is not the record" is the whole point of deleting
// one. A marker in the body survives a rename in the web UI, a lost ledger, a
// fresh clone, and a second machine — none of which the ledger 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. FromPayload strips every marker before the
// body reaches the store, so `.kettle/issues/<id>.md` holds exactly what the
// author wrote — checkbox line numbers, `kettle check`, 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. WithIDMarker never appends to what is there, and
// StripIDMarker 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.
//
// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea`
// and wrote `<!-- tea:id … -->`. Issues pushed under that name are sitting in
// the tracker right now, and their local files are gone — the marker is the
// only copy of their slug there is. A rename that stopped reading the old
// spelling would orphan every one of them: the pull would fall back to the
// title, allocate a fresh slug, and every `depends:` pointing at the old one
// would dangle. So the writer moved and the reader did not.
var markerRe = regexp.MustCompile(
`^[ \t]*<!--[ \t]*(?:kettle|tea):id[ \t]+(\S+)[ \t]*-->[ \t]*$`)
// IDMarker is the marker line for a slug. One place formats it, one regex
// reads it — and what that regex accepts is deliberately wider than this.
func IDMarker(id string) string { return "<!-- kettle:id " + id + " -->" }
// IDInBody is the slug a tracker-side body claims, or "" when it claims none.
//
// The FIRST valid marker wins; a second one is ignored here and removed by
// StripIDMarker 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.
func IDInBody(body string) string {
for _, line := range strings.Split(body, "\n") {
if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil {
if issue.IsSlug(m[1]) {
return m[1]
}
}
}
return ""
}
// StripIDMarker is body with every marker line removed, in either spelling.
// 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: StripIDMarker(WithIDMarker(b, id)) == b.
func StripIDMarker(body string) string {
lines := strings.Split(body, "\n")
found := false
for _, line := range lines {
if markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
found = true
break
}
}
if !found {
return body
}
kept := make([]string, 0, len(lines))
for _, line := range lines {
if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
kept = append(kept, line)
}
}
return strings.TrimLeft(strings.Join(kept, "\n"), "\n")
}
// WithIDMarker is 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, and it is what quietly rewrites a
// `tea:id` marker into the current spelling the next time the issue is pushed.
func WithIDMarker(body, id string) string {
return IDMarker(id) + "\n\n" + StripIDMarker(body)
}
+104
View File
@@ -0,0 +1,104 @@
package mapping
import (
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestIDInBodyReadsBothSpellings(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"the spelling this tool writes", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
// The whole reason the reader is wider than the writer.
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \n", "wire-sqlc"},
{"no marker at all", "## Summary\nx", ""},
// A mangled comment falls back to the title rather than naming a file
// after garbage.
{"not a slug", "<!-- kettle:id Wire_SQLC -->\n", ""},
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\n", "first-one"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IDInBody(c.body); got != c.want {
t.Errorf("IDInBody = %q, want %q", got, c.want)
}
})
}
}
// An issue pushed under the old name is sitting in the tracker with its local
// file long since deleted — the marker is the only copy of its slug there is.
// It has to keep resolving, and it has to come back up in the new spelling.
func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
const inTracker = "<!-- tea:id wire-sqlc-appclick -->\n\n## Summary\nПроводка sqlc.\n"
id := IDInBody(inTracker)
if id != "wire-sqlc-appclick" {
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
}
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
id, tea, PayloadOptions{})
if strings.Contains(iss.Body, "tea:id") {
t.Errorf("the old marker reached the local copy: %q", iss.Body)
}
if iss.Body != "## Summary\nПроводка sqlc." {
t.Errorf("body = %q", iss.Body)
}
// And the next push rewrites it into the current spelling, without ever
// having two.
up := *ToRequest(iss, RequestOptions{}).Body
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
t.Errorf("the marker was not rewritten: %q", up)
}
if strings.Contains(up, "tea:id") {
t.Errorf("both spellings went up: %q", up)
}
if n := strings.Count(up, ":id "); n != 1 {
t.Errorf("%d markers in the body, want 1", n)
}
}
func TestMarkersCannotAccumulate(t *testing.T) {
body := "## Summary\nx"
// Whatever it arrived with — one, the other, several — it goes up with one.
messy := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\n\n" + body
got := WithIDMarker(messy, "real-one")
if want := IDMarker("real-one") + "\n\n" + body; got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
if got := WithIDMarker(got, "real-one"); strings.Count(got, "<!--") != 1 {
t.Errorf("a second pass added one: %q", got)
}
}
func TestStripIsTheExactInverseOfWith(t *testing.T) {
bodies := []string{
"## Summary\nx",
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
"",
}
for _, b := range bodies {
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
}
}
}
// The common case — an issue filed in the web UI — costs nothing and is not
// reformatted.
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
body := "\n\n## Summary\nx\n\n\n"
if got := StripIDMarker(body); got != body {
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
}
}
+97
View File
@@ -0,0 +1,97 @@
package mapping
import (
"slices"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// domain -> Gitea.
// RequestOptions are what the transport resolved before the call: label names
// are ids by then, and a milestone title is a number.
//
// Both are lookups against one repository, which is why they cannot be done
// here — this package never learns which repository it is translating for
// beyond the name it is handed.
type RequestOptions struct {
// LabelIDs is name -> id for the labels this repository holds. A nil map
// leaves `labels` out of the request; a non-nil one sends the list, empty
// included, and a label the repository does not have is silently left off
// rather than failing the write — an unknown label is a bootstrap that has
// not run, not a reason to lose the issue.
LabelIDs map[string]int64
// MilestoneID is the resolved milestone. nil leaves the key out, which on
// an edit means "leave whatever is attached alone".
MilestoneID *int64
// IncludeState sends `state`. An edit that means to open or close says so;
// a create takes the tracker's default.
IncludeState bool
}
// ToRequest is the request body for creating or editing an issue.
//
// The prose is sent verbatim — see the package doc 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. FromPayload takes it straight back off, so the body
// still round-trips byte for byte.
//
// A create needs a title and a body, so those two are always filled. Every
// other key is left out unless the caller has an opinion about it: on a PATCH
// an absent key leaves the tracker's value alone, and a present one overwrites
// it — see wire.IssueRequest for what each of them clears when it is sent
// empty.
func ToRequest(i *issue.Issue, opt RequestOptions) *wire.IssueRequest {
r := &wire.IssueRequest{
Title: wire.Set(i.Title),
Body: wire.Set(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
}
if opt.LabelIDs != nil {
ids := []int64{}
for _, name := range i.Labels {
if id, ok := opt.LabelIDs[name]; ok {
ids = append(ids, id)
}
}
r.Labels = &ids
}
// Copied, so the request body and the issue it came from cannot alias one
// slice: whatever a caller does to either afterwards is not a change to
// what was sent.
if len(i.Assignees) > 0 {
r.Assignees = wire.Set(slices.Clone(i.Assignees))
}
if opt.MilestoneID != nil {
r.Milestone = opt.MilestoneID
}
if opt.IncludeState {
r.State = wire.Set(i.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.
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
r.Ref = wire.Set(branch)
}
return r
}
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
// write. Mutates and returns it; `origin` is the one domain field this touches,
// and it touches it because "this work exists somewhere else now" is exactly
// what has just become true.
func ApplyRemote(i *issue.Issue, p *wire.Issue, repo wire.Repo, synced string) *issue.Issue {
if i.Extra == nil {
i.Extra = map[string]string{}
}
i.Origin = Origin
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: p.Number}.String()
i.Extra[URLKey] = p.HTMLURL
i.Extra[SyncedKey] = synced
if p.UpdatedAt != "" {
i.Extra[RemoteUpdatedKey] = p.UpdatedAt
}
return i
}