Files
marketplace/cli/internal/gitea/issues.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

333 lines
11 KiB
Go

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)
}