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:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
// Package wire is the protocol: the JSON shapes a Gitea instance sends and
// takes, the identifiers that address them, and nothing else.
//
// It is a package because two layers need the same vocabulary and neither may
// import the other. internal/gitea is the transport — HTTP verbs, pagination,
// status codes, credentials — and internal/mapping is the bridge — md <-> JSON,
// pure functions, no network. Both have to name a Gitea issue, and when each
// named it with a struct of its own, every command written on top of the two
// would have had to copy a payload field by field from one spelling into the
// other. Two copies of a shape also drift: the first field only one of them
// learns is a field the other silently drops.
//
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
// filesystem, no configuration, and above all not internal/issue. That is what
// lets the transport and the bridge share it without either one landing inside
// the other's layer, and layering_test.go fails the moment it stops being true.
//
// Structs and not map[string]any, because the two representations disagreeing
// is the failure this vocabulary exists to make debuggable: a typo in a key is
// a compile error here and a silently dropped field there. Anything Gitea sends
// that is not named below is not read by anybody — decoding is lossy on
// purpose, since the tracker is not the record for anything the domain owns.
package wire
// User is whoever wrote or was assigned something.
//
// Only the login crosses this boundary — it is the one field of a Gitea user
// that means anything to a command, it is what `assignees:` holds, and a
// display name is not an identity anything can be pushed against. A transport
// that carries the rest invites somebody to use it.
type User struct {
Login string `json:"login"`
}
// Label as the tracker holds it.
//
// Color is hex. Gitea returns it without the leading `#` (`ee0701`) and accepts
// it either way; both spellings are the same color, so a comparison has to
// strip before it compares.
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Milestone as the tracker holds it. The domain carries its title; the id
// exists only long enough to be sent back.
type Milestone struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
Description string `json:"description"`
}
// RepoRef is the repository an issue payload says it belongs to. Present on a
// dependency listing, where the answer may well be another repository.
type RepoRef struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
}
// PullRequest is non-nil on a row that is a pull request rather than an issue.
// Gitea's issue endpoints return both, and `type=issues` is a filter the server
// has been known to ignore — which is why every listing re-checks it.
type PullRequest struct {
Merged bool `json:"merged"`
HTMLURL string `json:"html_url"`
}
// Issue is a tracker row: a Gitea issue as the API reports it.
//
// Timestamps stay strings. They are written into an issue's metadata verbatim
// and compared as opaque values; parsing them here would mean formatting them
// back, and a round trip through a time package is a chance to hand the store a
// different string than the tracker sent.
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
// Ref is the branch the issue is pinned to.
Ref string `json:"ref"`
// HTMLURL and UpdatedAt are the tracker's own bookkeeping and land in the
// domain's Extra untouched.
HTMLURL string `json:"html_url"`
// Comments is a count, not a thread: the thread is fetched separately and
// parked beside the issue as a sidecar.
Comments int `json:"comments"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Milestone *Milestone `json:"milestone"`
Repository *RepoRef `json:"repository"`
PullRequest *PullRequest `json:"pull_request"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IsPullRequest reports whether this row is a pull request.
func (i *Issue) IsPullRequest() bool { return i.PullRequest != nil }
// LabelNames are the label names, in the order the tracker listed them.
func (i *Issue) LabelNames() []string {
out := make([]string, 0, len(i.Labels))
for _, l := range i.Labels {
out = append(out, l.Name)
}
return out
}
// AssigneeLogins are the assignees, as logins.
func (i *Issue) AssigneeLogins() []string {
out := make([]string, 0, len(i.Assignees))
for _, a := range i.Assignees {
out = append(out, a.Login)
}
return out
}
// MilestoneTitle is the milestone's title, or "" when there is none.
func (i *Issue) MilestoneTitle() string {
if i.Milestone == nil {
return ""
}
return i.Milestone.Title
}
// KeyIn is this issue's cross-repo handle. The payload's own repository wins
// when it carries one — a dependency listing answers with issues from other
// repositories — and fallback is the repository that was asked.
func (i *Issue) KeyIn(fallback Repo) Key {
repo := fallback
if i.Repository != nil {
if r, err := ParseRepo(i.Repository.FullName); err == nil {
repo = r
}
}
return Key{Repo: repo, Number: i.Number}
}
// Comment is one entry in an issue's thread.
//
// Read only, in practice: a thread is flattened to markdown for a reader and
// nothing writes that markdown back, which is why the rendering may be as lossy
// as a reader needs.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User User `json:"user"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}