Files
marketplace/cli/internal/gitea/labels.go
T
naudachu 1239fdee70 refactor: move the transport onto the official Gitea SDK
The transport was hand-rolled net/http against the REST API. The payload shapes
were ours, in internal/wire, which meant every field Gitea learned was a field
somebody here had to notice; and "does this instance have issue dependencies?"
had to be guessed from a status code, because a 404 from a missing route and a
404 from a missing issue look alike.

The SDK settles both. The shapes are maintained by the people who maintain the
server, and the client negotiates the server's version when it is built, so the
dependency endpoint is now gated on `>= 1.20.0` — verified against the release
where the route appears, not assumed. Below the gate nothing is requested at all.

internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an
owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42`
and an issue URL are four spellings of one address, all four are what somebody
has in hand, and Key is what the ledger is keyed by. The payload structs go.

Four things that had to survive the move, and did:

- request bodies still land in .kettle/payload/, now via an http.RoundTripper on
  the client the SDK is given — which is better than before, because it files
  every request rather than the ones a call site remembered to name;
- errors still carry the HTTP status AND the response body, and a decode failure
  on a 2xx is deliberately not an APIError, so the dependency probe cannot read
  a bad decode as "feature missing";
- the number -> slug ledger is untouched, entries still outlive the files they
  name;
- Client.For(repo) still re-points at another repository for one call.

What it cost, written down in AGENTS.md where it happened. internal/mapping's
layering test was a fact about the import graph — nothing in its closure could
open a socket — and the SDK ships its types and its client in one package, so
the test now asserts what is still true: the bridge performs no I/O, checked on
direct imports plus a grep for time.Now. A run makes one extra request before it
does anything. Gitea's issue edit endpoint carries no labels, so a push whose
labels changed needs a second call; push makes it and says so. go.mod requires
go 1.26, which the SDK sets and which is now the floor for building this binary.

internal/issue and internal/project are byte-identical. The domain did not
notice, which is the whole argument for the layering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:29:38 +05:00

139 lines
4.7 KiB
Go

package gitea
import (
"fmt"
"strconv"
"strings"
sdk "code.gitea.io/sdk/gitea"
)
// ListLabels is every label in the repository, every page of it.
//
// A bootstrap decides its plan against this and never against a cache: a cache
// answers "what did we create last time", and the question is "what does the
// repository have right now".
func (c *Client) ListLabels() ([]*sdk.Label, error) {
owner, repo := c.owned()
return paginate(func(page, limit int) ([]*sdk.Label, error) {
got, resp, err := c.api.ListRepoLabels(owner, repo,
sdk.ListLabelsOptions{ListOptions: listOptions(page, limit)})
return got, fail(resp, err)
}, 100)
}
// CreateLabel adds a label to the repository.
//
// Through the API rather than through any CLI wrapper, because `exclusive` —
// the flag that makes `type/*` behave like a single choice — is not something
// the `tea` client could set.
//
// What a label MEANS is not decided here either: this creates what it is
// handed.
func (c *Client) CreateLabel(opt sdk.CreateLabelOption) (*sdk.Label, error) {
owner, repo := c.owned()
c.dump.label("label-" + opt.Name)
got, resp, err := c.api.CreateLabel(owner, repo, opt)
if err := fail(resp, err); err != nil {
return nil, err
}
if got == nil || got.ID == 0 {
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", opt.Name)
}
return got, nil
}
// EditLabel patches an existing label by id.
//
// It takes the same spec a create takes, and sends every field of it. The SDK
// spells an edit with pointers, where nil means "leave it alone" — but a label
// edit is rare enough that sending the unchanged name and description along
// costs nothing and removes a way to lose them on a server that reads an absent
// field as empty. The caller decides what the label should BE; this makes the
// tracker say that and nothing less.
func (c *Client) EditLabel(id int64, opt sdk.CreateLabelOption) (*sdk.Label, error) {
owner, repo := c.owned()
c.dump.label("label-" + opt.Name)
got, resp, err := c.api.EditLabel(owner, repo, id, sdk.EditLabelOption{
Name: &opt.Name,
Color: &opt.Color,
Description: &opt.Description,
Exclusive: &opt.Exclusive,
})
if err := fail(resp, err); err != nil {
return nil, err
}
return got, nil
}
// ListMilestones is every milestone in the repository, open and closed.
//
// Both states, always: a milestone is closed the moment its work is done, and a
// listing that hid those would fail to resolve exactly the filter somebody
// types when they want to see what was in it.
func (c *Client) ListMilestones() ([]*sdk.Milestone, error) {
owner, repo := c.owned()
return paginate(func(page, limit int) ([]*sdk.Milestone, error) {
got, resp, err := c.api.ListRepoMilestones(owner, repo, sdk.ListMilestoneOption{
ListOptions: listOptions(page, limit),
State: sdk.StateAll,
})
return got, fail(resp, err)
}, 100)
}
// ResolveMilestone finds a milestone by id or by title, and fails when there is
// none.
//
// It fails LOUDLY, and that is the whole point of resolving before filtering:
// Gitea silently ignores a `milestones=` filter it cannot resolve and answers
// with the entire backlog. A typo in a milestone name would otherwise read as
// "your milestone has 300 issues in it".
//
// Against the whole listing rather than the SDK's GetMilestoneByName, because a
// failure has to say what the repository actually HAS — and because that helper
// matches case-insensitively, which would resolve two different milestones to
// one on a repository that has both.
func (c *Client) ResolveMilestone(value string) (*sdk.Milestone, error) {
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for _, m := range got {
if m.Title == value || strconv.FormatInt(m.ID, 10) == value {
return m, nil
}
}
have := make([]string, 0, len(got))
for _, m := range got {
have = append(have, fmt.Sprintf("%s (id %d)", m.Title, m.ID))
}
if len(have) == 0 {
have = []string{"none"}
}
return nil, fmt.Errorf("no milestone %q in %s — have: %s", value, c.repo, strings.Join(have, ", "))
}
// FindMilestone is the milestone with this title, or nil when the repository
// has no such milestone.
//
// The quiet counterpart of ResolveMilestone, for a push: an issue naming a
// milestone the tracker does not have is filed without one, because refusing
// the whole push over a field the tracker will happily accept as empty helps
// nobody. "none" and "" are both "no milestone".
func (c *Client) FindMilestone(title string) (*sdk.Milestone, error) {
if title == "" || title == "none" {
return nil, nil
}
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for _, m := range got {
if m.Title == title {
return m, nil
}
}
return nil, nil
}