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