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>
156 lines
5.9 KiB
Go
156 lines
5.9 KiB
Go
// 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"`
|
|
}
|