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,229 @@
|
||||
// Package issue is what an issue IS. The domain layer.
|
||||
//
|
||||
// It knows the canonical markdown format, the label taxonomy, validation, and
|
||||
// the dependency graph. It knows NOTHING about any tracker: no Gitea, no
|
||||
// logins, no HTTP, no issue numbers. The layering rule is mechanically checked
|
||||
// — see TestDomainImportsNothing, which walks this package's transitive
|
||||
// dependencies and fails on anything outside the standard library and
|
||||
// internal/project.
|
||||
//
|
||||
// Delete the transport entirely and this layer keeps working: issues that live
|
||||
// only on this machine are first-class, not drafts on their way somewhere.
|
||||
//
|
||||
// Identity is a slug derived from the title, and it is the only identity the
|
||||
// domain has. The file name is the id:
|
||||
//
|
||||
// .tea/issues/wire-sqlc-appclick.md
|
||||
//
|
||||
// ---
|
||||
// id: wire-sqlc-appclick
|
||||
// state: open
|
||||
// labels: [type/task, tech/sql]
|
||||
// assignees: [naudachu]
|
||||
// milestone: v0.2
|
||||
// depends: [migrate-schema]
|
||||
// origin: gitea
|
||||
// gitea: owner/repo#42
|
||||
// synced: 2026-08-07T18:40:00Z
|
||||
// ---
|
||||
// # Wire sqlc into the appclick repo layer
|
||||
//
|
||||
// ## Summary
|
||||
// ...
|
||||
//
|
||||
// Keys down to origin are owned here. Everything below is written by the sync
|
||||
// layer; this package carries those keys through load/save verbatim in Extra
|
||||
// and never reads them. That passthrough is what lets one file represent both
|
||||
// a local issue and a synced one without the domain learning a second
|
||||
// vocabulary.
|
||||
//
|
||||
// Every metadata field is one line and lists are inline, so plain grep works
|
||||
// without a parser:
|
||||
//
|
||||
// grep -l 'labels:.*type/bug' .tea/issues/*.md
|
||||
// grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Origin is "does this issue exist anywhere but here" — a fact about the work,
|
||||
// so it is owned here. Its value is Local or a tracker's name; what that name
|
||||
// means, and the handle that goes with it (gitea: owner/repo#42), stay foreign
|
||||
// keys this layer carries but never reads.
|
||||
const Local = "local"
|
||||
|
||||
// DomainKeys are the metadata fields this layer owns, in render order. Foreign
|
||||
// keys render after these, sorted, so the sync layer can add fields without
|
||||
// touching this list.
|
||||
var DomainKeys = []string{"id", "state", "labels", "assignees", "milestone",
|
||||
"depends", "origin"}
|
||||
|
||||
var listKeys = map[string]bool{"labels": true, "assignees": true, "depends": true}
|
||||
|
||||
// States an issue may be in.
|
||||
var States = []string{"open", "closed"}
|
||||
|
||||
var slugOK = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var slugPunct = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// Issue is one unit of work. Extra holds metadata this layer does not own.
|
||||
type Issue struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
State string
|
||||
Labels []string
|
||||
Assignees []string
|
||||
Milestone string
|
||||
Depends []string
|
||||
Origin string
|
||||
Extra map[string]string
|
||||
}
|
||||
|
||||
// IsLocal reports whether this issue exists nowhere but here.
|
||||
//
|
||||
// A complete state, not a pending one — and the state in which this file is
|
||||
// the only copy of the work. An issue whose Origin names somewhere else can be
|
||||
// fetched from there again; this one cannot.
|
||||
func (i *Issue) IsLocal() bool { return i.Origin == Local }
|
||||
|
||||
// Type is the value of the mandatory, exclusive type/* label.
|
||||
func (i *Issue) Type() string { return i.namespaced("type/") }
|
||||
|
||||
// Severity is the value of the optional, exclusive severity/* label.
|
||||
func (i *Issue) Severity() string { return i.namespaced("severity/") }
|
||||
|
||||
func (i *Issue) namespaced(prefix string) string {
|
||||
for _, l := range i.Labels {
|
||||
if v, ok := strings.CutPrefix(l, prefix); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FromText parses a stored issue. A non-empty id overrides the one in the
|
||||
// metadata block, which is how the store makes the file name authoritative.
|
||||
func FromText(text, id string) *Issue {
|
||||
meta, title, body := ParseMeta(text)
|
||||
|
||||
extra := map[string]string{}
|
||||
for k, v := range meta {
|
||||
if !isDomainKey(k) {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
id = meta["id"]
|
||||
}
|
||||
milestone := meta["milestone"]
|
||||
if milestone == "none" {
|
||||
milestone = ""
|
||||
}
|
||||
state := meta["state"]
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
origin := meta["origin"]
|
||||
if origin == "" {
|
||||
origin = Local
|
||||
}
|
||||
|
||||
return &Issue{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Body: strings.TrimSpace(body),
|
||||
State: state,
|
||||
Labels: splitList(meta["labels"]),
|
||||
Assignees: splitList(meta["assignees"]),
|
||||
Milestone: milestone,
|
||||
Depends: splitList(meta["depends"]),
|
||||
Origin: origin,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Text renders the issue back to its canonical file form.
|
||||
func (i *Issue) Text() string {
|
||||
meta := map[string]string{}
|
||||
for k, v := range i.Extra {
|
||||
meta[k] = v
|
||||
}
|
||||
milestone := i.Milestone
|
||||
if milestone == "" {
|
||||
milestone = "none"
|
||||
}
|
||||
meta["id"] = i.ID
|
||||
meta["state"] = i.State
|
||||
meta["labels"] = renderList(i.Labels)
|
||||
meta["assignees"] = renderList(i.Assignees)
|
||||
meta["milestone"] = milestone
|
||||
meta["depends"] = renderList(i.Depends)
|
||||
meta["origin"] = i.Origin
|
||||
|
||||
body := strings.TrimSpace(i.Body)
|
||||
if body == "" {
|
||||
body = "(no body)"
|
||||
}
|
||||
return fmt.Sprintf("%s\n# %s\n\n%s\n", RenderMeta(meta), i.Title, body)
|
||||
}
|
||||
|
||||
// Slugify turns a title into an id. Titles are English by format rule, so
|
||||
// ASCII is enough; anything else is dropped rather than transliterated.
|
||||
func Slugify(text string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
maxLen = 48
|
||||
}
|
||||
s := strings.Trim(slugPunct.ReplaceAllString(strings.ToLower(text), "-"), "-")
|
||||
if len(s) > maxLen {
|
||||
cut := s[:maxLen]
|
||||
if i := strings.LastIndex(cut, "-"); i > 0 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
s = cut
|
||||
}
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
return "issue"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsSlug reports whether id is a well-formed identity.
|
||||
func IsSlug(id string) bool { return slugOK.MatchString(id) }
|
||||
|
||||
// UniqueID is base, or base-2, base-3… when the slug is already used.
|
||||
func UniqueID(root, base string, taken []string) (string, error) {
|
||||
used := map[string]bool{}
|
||||
for _, t := range taken {
|
||||
used[t] = true
|
||||
}
|
||||
for _, t := range AllIDs(root) {
|
||||
used[t] = true
|
||||
}
|
||||
if !used[base] {
|
||||
return base, nil
|
||||
}
|
||||
for n := 2; n < 1000; n++ {
|
||||
cand := fmt.Sprintf("%s-%d", base, n)
|
||||
if !used[cand] {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot allocate an id for %q", base)
|
||||
}
|
||||
|
||||
func isDomainKey(k string) bool {
|
||||
for _, d := range DomainKeys {
|
||||
if d == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user