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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user