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,332 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// GetIssue fetches one issue by number.
|
||||
//
|
||||
// A number is an address, not a query: this answers for a closed issue exactly
|
||||
// as it does for an open one.
|
||||
func (c *Client) GetIssue(number int) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A 200 that carries no number is not this issue. Gitea has answered that
|
||||
// way for a repository whose issue tracker is disabled.
|
||||
if got.Number == 0 {
|
||||
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
// CreateIssue files a new issue. name labels the request body in the
|
||||
// scratchpad; the issue's slug is what makes that dump worth keeping.
|
||||
func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
body := &Body{Name: name, Data: req}
|
||||
if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
// EditIssue patches an existing issue. Only the fields set on req are sent.
|
||||
func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
body := &Body{Name: name, Data: req}
|
||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
// SetLabels replaces an issue's labels with exactly these ids.
|
||||
//
|
||||
// It exists because Gitea occasionally drops labels handed to it on create, and
|
||||
// the answer to that is to re-apply them rather than to trust the echo.
|
||||
func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) {
|
||||
if ids == nil {
|
||||
ids = []int64{}
|
||||
}
|
||||
var got []wire.Label
|
||||
body := &Body{Name: name, Data: struct {
|
||||
Labels []int64 `json:"labels"`
|
||||
}{ids}}
|
||||
if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ListComments is an issue's whole thread, every page of it.
|
||||
func (c *Client) ListComments(number int) ([]wire.Comment, error) {
|
||||
return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit)
|
||||
}
|
||||
|
||||
// CreateComment posts a comment on an issue.
|
||||
func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) {
|
||||
var got wire.Comment
|
||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
||||
if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
// EditComment rewrites one comment, addressed by its own id and not by the
|
||||
// issue it is on — which is how Gitea addresses it.
|
||||
func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) {
|
||||
var got wire.Comment
|
||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
type commentBody struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// listing, and the filter the server does not honour
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// IssueFilter is what a listing asks for.
|
||||
type IssueFilter struct {
|
||||
// State is open (the default), closed, or all.
|
||||
State string
|
||||
// Labels are label names; an issue must carry all of them.
|
||||
Labels []string
|
||||
// Query is Gitea's keyword search over title and body.
|
||||
Query string
|
||||
// Milestone is an id or a title. It is resolved against the repository
|
||||
// before it is trusted — see ResolveMilestone.
|
||||
Milestone string
|
||||
// Limit counts the payloads the CALLER cares about, not the ones the server
|
||||
// returned. Must be 1 or more.
|
||||
Limit int
|
||||
// Keep says whether a payload counts against Limit. Without it every
|
||||
// payload counts and a listing behaves as any other. With it, pages keep
|
||||
// coming until Limit have counted, and the returned list carries the ones
|
||||
// that did not count too — they were enumerated, and a caller with
|
||||
// something to say about them ("11 closed, not stored") still can.
|
||||
//
|
||||
// What Keep means is the caller's business; this package only counts.
|
||||
Keep func(*wire.Issue) bool
|
||||
}
|
||||
|
||||
// IssueListing is what a filtered read found.
|
||||
type IssueListing struct {
|
||||
// Issues are every payload that passed the filter, kept or not.
|
||||
Issues []wire.Issue
|
||||
// Milestone is the resolved milestone title, for a receipt.
|
||||
Milestone string
|
||||
// Warning is set when a Keep-bounded read ran out of page budget with the
|
||||
// budget unfilled. Returned rather than printed: the transport does not own
|
||||
// the operator's terminal, and a caller that is rendering JSON needs it as
|
||||
// data.
|
||||
Warning string
|
||||
}
|
||||
|
||||
// ListIssues reads filtered issue payloads.
|
||||
//
|
||||
// One request per page, and a payload already carries the issue body — a whole
|
||||
// milestone costs one call per page, not one per issue.
|
||||
//
|
||||
// Two boundaries hold whatever Keep decides:
|
||||
//
|
||||
// - Stop at the limit. The page after the one that completed the budget is
|
||||
// never requested.
|
||||
// - Stop at the page budget. A predicate that rejects everything must not turn
|
||||
// a bounded read into a walk of the whole tracker, so a filtered read scans
|
||||
// at most PageSlack times the pages Limit would need if every payload
|
||||
// counted. Hitting that with the budget unfilled sets Warning rather than
|
||||
// answering short in silence: the caller asked for N and is told it got
|
||||
// fewer.
|
||||
func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
||||
if f.Limit < 1 {
|
||||
return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit)
|
||||
}
|
||||
|
||||
out := &IssueListing{}
|
||||
var milestoneID int64
|
||||
if f.Milestone != "" {
|
||||
ms, err := c.ResolveMilestone(f.Milestone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
milestoneID, out.Milestone = ms.ID, ms.Title
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
state := f.State
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
params.Set("state", state)
|
||||
params.Set("type", "issues")
|
||||
if len(f.Labels) > 0 {
|
||||
params.Set("labels", strings.Join(f.Labels, ","))
|
||||
}
|
||||
if f.Query != "" {
|
||||
params.Set("q", f.Query)
|
||||
}
|
||||
if out.Milestone != "" {
|
||||
params.Set("milestones", out.Milestone)
|
||||
}
|
||||
path := c.repoPath("issues?" + params.Encode())
|
||||
|
||||
perPage := min(f.Limit, pageLimit)
|
||||
ideal := max(1, (f.Limit+perPage-1)/perPage)
|
||||
budget := ideal
|
||||
if f.Keep != nil {
|
||||
budget = ideal * PageSlack
|
||||
}
|
||||
|
||||
kept, seen, lastFull := 0, 0, false
|
||||
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
|
||||
seen++
|
||||
lastFull = len(batch) == perPage
|
||||
for i := range batch {
|
||||
p := &batch[i]
|
||||
if !matches(p, milestoneID, f.Labels) {
|
||||
continue
|
||||
}
|
||||
out.Issues = append(out.Issues, *p)
|
||||
if f.Keep == nil || f.Keep(p) {
|
||||
kept++
|
||||
if kept >= f.Limit {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Keep != nil && seen >= budget && lastFull {
|
||||
out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+
|
||||
" — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matches re-checks on the client what the server was already asked for.
|
||||
//
|
||||
// Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve
|
||||
// and answers with the whole backlog, which is why the milestone is resolved to
|
||||
// an id first and every payload is checked against that id here. The same
|
||||
// re-check on labels costs nothing, and `pull_request` is the one filter that
|
||||
// matters most — a pull request rendered as a unit of work is not a bug the
|
||||
// operator can see until it is in the store.
|
||||
//
|
||||
// A function and not a method: the payload is the protocol's, and re-checking a
|
||||
// filter the server ignored is this package's business, not the protocol's.
|
||||
func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
|
||||
if i.IsPullRequest() {
|
||||
return false
|
||||
}
|
||||
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
|
||||
return false
|
||||
}
|
||||
have := make(map[string]bool, len(i.Labels))
|
||||
for _, l := range i.Labels {
|
||||
have[l.Name] = true
|
||||
}
|
||||
for _, want := range labels {
|
||||
if !have[want] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// dependencies
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// issueMeta is Gitea's IssueMeta: how a dependency names another issue.
|
||||
type issueMeta struct {
|
||||
Index int `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
// Dependencies are the issues that block this one — Gitea's own dependency
|
||||
// links, read in the direction AddDependency writes them.
|
||||
//
|
||||
// An instance that does not have the endpoint, or has dependencies turned off
|
||||
// for this repository, answers with a status rather than a list. That is
|
||||
// reported as "no dependencies" and not as a failure: a pull must still bring
|
||||
// the issue itself back from a tracker whose dependency support is off.
|
||||
//
|
||||
// Deliberately narrower than the Python it replaces, which swallowed every
|
||||
// failure here including a dead connection. "The server said no" and "there was
|
||||
// no server" are different answers, and only the first one means the feature is
|
||||
// missing.
|
||||
func (c *Client) Dependencies(number int) ([]wire.Issue, error) {
|
||||
var got []wire.Issue
|
||||
err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got)
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// DependencyKeys is the same links as cross-repo handles — what a repeat push
|
||||
// compares against so it does not POST a link the tracker already has.
|
||||
//
|
||||
// A bare number is ambiguous the moment a dependency lives in another
|
||||
// repository, and Gitea lets it, so the repository travels with it.
|
||||
func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
|
||||
deps, err := c.Dependencies(number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]wire.Key, 0, len(deps))
|
||||
for i := range deps {
|
||||
out = append(out, deps[i].KeyIn(c.repo))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddDependency makes issue number depend on dep.
|
||||
//
|
||||
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
//
|
||||
// POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
||||
// body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
// "Make the issue in the url depend on the issue in the form."
|
||||
//
|
||||
// So the URL names the blocked issue and the body the blocker, which is the
|
||||
// direction Dependencies reads back. A link that already exists answers 409, so
|
||||
// callers pre-filter with DependencyKeys and treat a failure here as a note
|
||||
// rather than an abort: one missing cross-link must not undo a push that has
|
||||
// already created issues.
|
||||
func (c *Client) AddDependency(number int, dep wire.Key) error {
|
||||
if dep.Repo.Zero() {
|
||||
return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep)
|
||||
}
|
||||
if dep.Number < 1 {
|
||||
return fmt.Errorf("dependency %s names no issue number", dep)
|
||||
}
|
||||
body := &Body{
|
||||
Name: fmt.Sprintf("dep-%d-%d", number, dep.Number),
|
||||
Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
||||
}
|
||||
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user