9480e48312
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>
210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
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
|
|
}
|