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,209 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// The store holds two kinds of file, and only one of them is a store.
|
||||
//
|
||||
// An issue whose origin is Local lives here and nowhere else — that file IS the
|
||||
// issue, and losing it loses the work. Anything with a tracker origin is a
|
||||
// cache: the tracker has it, this copy is a working copy, and it is deleted the
|
||||
// moment a push confirms the tracker is up to date.
|
||||
|
||||
// Root resolves the issue store for the current project. An explicit out
|
||||
// overrides it and is used exactly as typed: a relative out stays relative to
|
||||
// the working directory, because that is what the operator asked for.
|
||||
func Root(out string) string {
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
return project.StoreRoot("")
|
||||
}
|
||||
|
||||
// ErrStoreMissing marks the "the store directory is not there" failure.
|
||||
//
|
||||
// Deliberately a different answer from "the store is empty". One is a path that
|
||||
// does not exist, the other is a repository with no issues filed yet, and
|
||||
// conflating the two is exactly what made a missed directory look like an empty
|
||||
// backlog.
|
||||
var ErrStoreMissing = errors.New("store missing")
|
||||
|
||||
// StoreExists reports whether root is a directory that can be read as a store.
|
||||
func StoreExists(root string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(root)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
// RequireStore asserts the store is there before reading or writing it.
|
||||
//
|
||||
// An empty root means no project was found at all — a different failure from a
|
||||
// project whose store has not been created yet, and the message says so.
|
||||
func RequireStore(root string) error {
|
||||
if root == "" {
|
||||
return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if !StoreExists(root) {
|
||||
return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateStore creates the store, reporting whether it made the directory.
|
||||
//
|
||||
// Only the commands that legitimately bootstrap a store call this — `new` and
|
||||
// `pull` — and both announce it. Nothing creates a store as a side effect of a
|
||||
// write: a missing directory is something to report, not something to conjure.
|
||||
// An unresolved root is never conjured either — without a marker there is no
|
||||
// project to create a store IN, and guessing one is how a store once ended up
|
||||
// inside the plugin.
|
||||
func CreateStore(root string) (bool, error) {
|
||||
if root == "" {
|
||||
return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if StoreExists(root) {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StoreError says why root cannot be read as a store, or nil when it holds
|
||||
// issues.
|
||||
//
|
||||
// The three messages are distinct on purpose — no project at all, a project
|
||||
// with no store, and a store with nothing in it are three different things to
|
||||
// do next.
|
||||
func StoreError(root string) error {
|
||||
switch {
|
||||
case root == "":
|
||||
return project.NotFoundError("")
|
||||
case !StoreExists(root):
|
||||
return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root)
|
||||
case len(AllIDs(root)) == 0:
|
||||
return fmt.Errorf("store %s exists but is empty", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PathOf is where the issue with this id lives.
|
||||
func PathOf(root, id string) string { return filepath.Join(root, id+".md") }
|
||||
|
||||
// AllIDs lists every issue in the store, by slug.
|
||||
//
|
||||
// An issue file is named by its slug and a slug has no dot in it, so
|
||||
// `<id>.comments.md` — the thread the sync layer parks beside an issue — is not
|
||||
// one, and neither is anything else that grew a second extension. Without that
|
||||
// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and
|
||||
// a bare push tries to file the comment thread as a unit of work.
|
||||
func AllIDs(root string) []string {
|
||||
if !StoreExists(root) {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") ||
|
||||
strings.HasPrefix(name, "tree-") {
|
||||
continue
|
||||
}
|
||||
id := name[:len(name)-3]
|
||||
if strings.Contains(id, ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SlugFiles lists every file the store holds under one slug — the issue and its
|
||||
// sidecars.
|
||||
//
|
||||
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
// companion another layer parked there (`<id>.comments.md` is the one that
|
||||
// exists today). AllIDs already refuses to read those as issues because a slug
|
||||
// has no dot in it; this is the same rule read the other way round.
|
||||
//
|
||||
// Which is how the domain can remove an issue completely without learning what
|
||||
// any of those companions are: it does not need to know that a comment thread
|
||||
// exists to know that a file named after this issue belongs to it and goes when
|
||||
// it goes. The issue's own file comes first — it is the headline of any receipt
|
||||
// printed from this list.
|
||||
//
|
||||
// A missing store is an empty list, not an error: nothing is there to remove.
|
||||
func SlugFiles(root, id string) []string {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prefix, own := id+".", id+".md"
|
||||
var self, sidecars []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, prefix) || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(root, name)
|
||||
if name == own {
|
||||
self = append(self, p)
|
||||
} else {
|
||||
sidecars = append(sidecars, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(sidecars)
|
||||
return append(self, sidecars...)
|
||||
}
|
||||
|
||||
// Load reads one issue. The file name wins over the id in the metadata block.
|
||||
func Load(root, id string) (*Issue, error) {
|
||||
raw, err := os.ReadFile(PathOf(root, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromText(string(raw), id), nil
|
||||
}
|
||||
|
||||
// LoadAll reads the whole store.
|
||||
func LoadAll(root string) (map[string]*Issue, error) {
|
||||
out := map[string]*Issue{}
|
||||
for _, id := range AllIDs(root) {
|
||||
i, err := Load(root, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = i
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save writes an issue to the store, which must already exist.
|
||||
func Save(root string, i *Issue) (string, error) {
|
||||
if err := RequireStore(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := PathOf(root, i.ID)
|
||||
if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user