Files
marketplace/cli/internal/mapping/frompayload.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

208 lines
7.0 KiB
Go

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")
}