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>
This commit is contained in:
@@ -13,10 +13,17 @@ package cmd_test
|
||||
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
|
||||
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
|
||||
// overwrite the developer's own tokens.
|
||||
//
|
||||
// The fake answers /api/v1/version before anything else: the SDK asks an
|
||||
// instance what it is before it hands back a client, so a fake that did not
|
||||
// answer would fail every command at startup — and it is that answer the
|
||||
// dependency gate is decided on, which is why it says a version new enough to
|
||||
// have them.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -27,11 +34,27 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// pullGiteaVersion is what both fakes in this package claim to be: new enough
|
||||
// for the issue-dependency endpoints, which is what the transport gates on.
|
||||
const pullGiteaVersion = "1.26.1"
|
||||
|
||||
// pullVersionRoute answers the version handshake and reports whether it did.
|
||||
func pullVersionRoute(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.URL.Path != "/api/v1/version" {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"version":"`+pullGiteaVersion+`"}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -39,9 +62,9 @@ import (
|
||||
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
||||
type pullFakeGitea struct {
|
||||
mu sync.Mutex
|
||||
issues map[int]*wire.Issue
|
||||
issues map[int]*sdk.Issue
|
||||
deps map[int][]int
|
||||
comments map[int][]wire.Comment
|
||||
comments map[int][]sdk.Comment
|
||||
labels map[string]int64
|
||||
next int
|
||||
|
||||
@@ -52,35 +75,35 @@ type pullFakeGitea struct {
|
||||
|
||||
func pullNewGitea() *pullFakeGitea {
|
||||
return &pullFakeGitea{
|
||||
issues: map[int]*wire.Issue{},
|
||||
issues: map[int]*sdk.Issue{},
|
||||
deps: map[int][]int{},
|
||||
comments: map[int][]wire.Comment{},
|
||||
comments: map[int][]sdk.Comment{},
|
||||
labels: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
// pullAdd puts an issue in the tracker the way the web UI would: it is there
|
||||
// before this project ever hears about it.
|
||||
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
|
||||
func (g *pullFakeGitea) pullAdd(p sdk.Issue) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p.State == "" {
|
||||
p.State = "open"
|
||||
p.State = sdk.StateOpen
|
||||
}
|
||||
p.HTMLURL = pullURL(p.Number)
|
||||
g.issues[p.Number] = &p
|
||||
if p.Number > g.next {
|
||||
g.next = p.Number
|
||||
p.HTMLURL = pullURL(int(p.Index))
|
||||
g.issues[int(p.Index)] = &p
|
||||
if int(p.Index) > g.next {
|
||||
g.next = int(p.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
|
||||
func (g *pullFakeGitea) pullIssue(n int) sdk.Issue {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p := g.issues[n]; p != nil {
|
||||
return *p
|
||||
}
|
||||
return wire.Issue{}
|
||||
return sdk.Issue{}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullRetitle(n int, title string) {
|
||||
@@ -108,6 +131,9 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if pullVersionRoute(w, r) {
|
||||
return
|
||||
}
|
||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||
@@ -116,36 +142,36 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case path == "labels" && r.Method == http.MethodGet:
|
||||
out := []wire.Label{}
|
||||
out := []sdk.Label{}
|
||||
for name, id := range g.labels {
|
||||
out = append(out, wire.Label{ID: id, Name: name})
|
||||
out = append(out, sdk.Label{ID: id, Name: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
pullJSON(w, out)
|
||||
|
||||
case path == "labels" && r.Method == http.MethodPost:
|
||||
var req wire.LabelRequest
|
||||
var req sdk.CreateLabelOption
|
||||
pullDecode(r, &req)
|
||||
id := int64(1000 + len(g.labels))
|
||||
g.labels[req.Name] = id
|
||||
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
||||
pullJSON(w, sdk.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
||||
|
||||
case path == "milestones" && r.Method == http.MethodGet:
|
||||
pullJSON(w, []wire.Milestone{})
|
||||
pullJSON(w, []sdk.Milestone{})
|
||||
|
||||
case path == "issues" && r.Method == http.MethodPost:
|
||||
if g.writesFail {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req wire.IssueRequest
|
||||
var req sdk.CreateIssueOption
|
||||
pullDecode(r, &req)
|
||||
g.next++
|
||||
p := &wire.Issue{
|
||||
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
|
||||
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
||||
p := &sdk.Issue{
|
||||
Index: int64(g.next), Title: req.Title, Body: req.Body,
|
||||
State: sdk.StateOpen, HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
||||
}
|
||||
g.issues[p.Number] = p
|
||||
g.issues[g.next] = p
|
||||
pullJSON(w, p)
|
||||
|
||||
case path == "issues" && r.Method == http.MethodGet:
|
||||
@@ -163,10 +189,12 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req wire.IssueRequest
|
||||
var req sdk.EditIssueOption
|
||||
pullDecode(r, &req)
|
||||
if req.Title != nil {
|
||||
p.Title = *req.Title
|
||||
// An empty title is Gitea's "leave it alone" — the one field of an
|
||||
// edit that says so with a zero value rather than with null.
|
||||
if req.Title != "" {
|
||||
p.Title = req.Title
|
||||
}
|
||||
if req.Body != nil {
|
||||
p.Body = *req.Body
|
||||
@@ -174,9 +202,10 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if req.State != nil {
|
||||
p.State = *req.State
|
||||
}
|
||||
if req.Labels != nil {
|
||||
p.Labels = g.pullLabelsFor(req.Labels)
|
||||
}
|
||||
// No labels here on purpose: Gitea's edit endpoint takes none, so
|
||||
// an issue whose labels changed gets them through PUT ./labels
|
||||
// below, and a fake that quietly accepted them would hide a push
|
||||
// that never sent them.
|
||||
}
|
||||
pullJSON(w, p)
|
||||
|
||||
@@ -185,7 +214,7 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
switch {
|
||||
case m[2] == "dependencies" && r.Method == http.MethodGet:
|
||||
out := []wire.Issue{}
|
||||
out := []sdk.Issue{}
|
||||
for _, d := range g.deps[n] {
|
||||
if p := g.issues[d]; p != nil {
|
||||
out = append(out, *p)
|
||||
@@ -202,15 +231,13 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case m[2] == "comments" && r.Method == http.MethodGet:
|
||||
out := g.comments[n]
|
||||
if out == nil {
|
||||
out = []wire.Comment{}
|
||||
out = []sdk.Comment{}
|
||||
}
|
||||
pullJSON(w, out)
|
||||
case m[2] == "labels" && r.Method == http.MethodPut:
|
||||
var req struct {
|
||||
Labels []int64 `json:"labels"`
|
||||
}
|
||||
var req sdk.IssueLabelsOption
|
||||
pullDecode(r, &req)
|
||||
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
|
||||
g.issues[n].Labels = g.pullLabelsFor(req.Labels)
|
||||
pullJSON(w, g.issues[n].Labels)
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
|
||||
@@ -242,10 +269,10 @@ func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
|
||||
out := []wire.Issue{}
|
||||
out := []sdk.Issue{}
|
||||
for _, n := range numbers {
|
||||
p := g.issues[n]
|
||||
if state != "" && state != "all" && p.State != state {
|
||||
if state != "" && state != "all" && string(p.State) != state {
|
||||
continue
|
||||
}
|
||||
has := map[string]bool{}
|
||||
@@ -273,7 +300,7 @@ func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
||||
pullJSON(w, out[start:end])
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
|
||||
func (g *pullFakeGitea) pullLabelsFor(ids []int64) []*sdk.Label {
|
||||
if ids == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -281,10 +308,10 @@ func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
|
||||
for name, id := range g.labels {
|
||||
byID[id] = name
|
||||
}
|
||||
var out []wire.Label
|
||||
for _, id := range *ids {
|
||||
var out []*sdk.Label
|
||||
for _, id := range ids {
|
||||
if name, ok := byID[id]; ok {
|
||||
out = append(out, wire.Label{ID: id, Name: name})
|
||||
out = append(out, &sdk.Label{ID: id, Name: name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -304,13 +331,6 @@ func pullJSON(w http.ResponseWriter, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func pullStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// pullEnv starts the fake and returns the environment that points the binary at
|
||||
// it. The credential home is a temp directory: a test run may neither read nor
|
||||
// overwrite the developer's own tokens.
|
||||
@@ -430,9 +450,9 @@ func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
|
||||
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{
|
||||
Number: 7, Title: "Closed but addressable", State: "closed",
|
||||
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
|
||||
g.pullAdd(sdk.Issue{
|
||||
Index: 7, Title: "Closed but addressable", State: sdk.StateClosed,
|
||||
Body: "## Summary\nДело сделано.\n", Updated: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
@@ -456,8 +476,8 @@ func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
||||
// --no-deps is how you ask for one row of it.
|
||||
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
||||
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
||||
g.pullAdd(sdk.Issue{Index: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
||||
g.pullAdd(sdk.Issue{Index: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
||||
g.pullBlocks(2, 1)
|
||||
env := pullEnv(t, g)
|
||||
|
||||
@@ -549,9 +569,9 @@ func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
|
||||
// it.
|
||||
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
|
||||
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
||||
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
|
||||
bug := []*sdk.Label{{ID: 1, Name: "type/bug"}}
|
||||
g.pullAdd(sdk.Issue{Index: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
||||
g.pullAdd(sdk.Issue{Index: 2, Title: "Fixed last week", State: sdk.StateClosed,
|
||||
Body: "## Summary\nx\n", Labels: bug})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
@@ -581,10 +601,10 @@ func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
||||
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{
|
||||
Number: 3, Title: "Came down and went back up",
|
||||
g.pullAdd(sdk.Issue{
|
||||
Index: 3, Title: "Came down and went back up",
|
||||
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
||||
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
|
||||
Labels: []*sdk.Label{{ID: 1, Name: "type/task"}},
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
@@ -637,8 +657,8 @@ func TestPushDryRunNeedsNoCredential(t *testing.T) {
|
||||
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
|
||||
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
|
||||
g.pullAdd(sdk.Issue{Index: 4, Title: "Something open", Body: "x"})
|
||||
g.pullAdd(sdk.Issue{Index: 5, Title: "Something closed", State: sdk.StateClosed, Body: "x"})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
r := runWith(t, dir, env, "", "remote")
|
||||
|
||||
Reference in New Issue
Block a user