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:
naudachu
2026-08-11 19:29:38 +05:00
parent 9480e48312
commit 1239fdee70
292 changed files with 51205 additions and 864 deletions
+15 -6
View File
@@ -7,6 +7,8 @@ import (
"strings"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
@@ -111,7 +113,14 @@ first, or unlink them.`,
if !t.key.Repo.Zero() {
c = client.For(t.key.Repo)
}
req := wire.IssueRequest{State: wire.Set(state)}
// STATE AND NOTHING ELSE. Every other field of an edit is
// left at its zero value, which the SDK sends as null and
// Gitea reads as "no opinion" — bar the title, which goes
// as the empty string Gitea reads the same way. Building
// this by hand rather than through mapping.ToEdit is the
// point: a translated issue would carry its body.
want := sdk.StateType(state)
req := sdk.EditIssueOption{State: &want}
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
if err != nil {
return err
@@ -120,9 +129,9 @@ first, or unlink them.`,
// file is about to say something the tracker had better agree
// with. An answer counts only when it is the very issue that was
// patched, in the state that was asked for.
if got.Number != t.key.Number || got.State != state {
if int(got.Index) != t.key.Number || got.State != want {
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
t.key.In(c.Repo()), verb, got.Number, got.State)
t.key.In(c.Repo()), verb, got.Index, got.State)
}
fmt.Printf("%-8s %-24s %-20s %s\n",
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
@@ -297,14 +306,14 @@ func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
// the answer that authorized this write is also the newest thing the tracker has
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
// rather than left describing an older read.
func closeApply(root string, i *issue.Issue, state string, got *wire.Issue) (string, error) {
func closeApply(root string, i *issue.Issue, state string, got *sdk.Issue) (string, error) {
i.State = state
if i.Extra == nil {
i.Extra = map[string]string{}
}
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
if got.UpdatedAt != "" {
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
if stamp := mapping.Stamp(got.Updated); stamp != "" {
i.Extra[mapping.RemoteUpdatedKey] = stamp
}
return issue.Save(root, i)
}
+3 -2
View File
@@ -6,9 +6,10 @@ import (
"os"
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
@@ -81,7 +82,7 @@ comment id for that.`,
// issue actually is — even when the store has ever pointed at two.
client = client.For(key.Repo)
var got *wire.Comment
var got *sdk.Comment
verb := "posted"
if *edit != 0 {
verb = "edited"
+7 -5
View File
@@ -7,6 +7,8 @@ import (
"sort"
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
@@ -109,7 +111,7 @@ thing this command exists to fix.`,
if !ok {
return Fail("%s: the answer for %s does not confirm a state "+
"(issue #%d, state %q). Nothing was evicted.",
c.id, c.key, got.Number, got.State)
c.id, c.key, got.Index, got.State)
}
fresh[c.id] = state
}
@@ -216,13 +218,13 @@ func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkabl
// of a yes here may delete a file. An answer counts only when it is about the
// very issue that was asked about and names a state the domain recognizes. A
// non-2xx never reaches this: the transport has already returned an error.
func syncEvictConfirms(got *wire.Issue, number int) (string, bool) {
if got == nil || got.Number != number {
func syncEvictConfirms(got *sdk.Issue, number int) (string, bool) {
if got == nil || int(got.Index) != number {
return "", false
}
for _, s := range issue.States {
if got.State == s {
return got.State, true
if string(got.State) == s {
return s, true
}
}
return "", false
+10 -9
View File
@@ -7,6 +7,8 @@ import (
"regexp"
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
@@ -165,8 +167,8 @@ the store and never a child.`,
// taxonomy says it should be, what the repository already has under that exact
// name (nil when it has nothing), and where the two disagree.
type labelRow struct {
spec wire.LabelRequest
got *wire.Label
spec sdk.CreateLabelOption
got *sdk.Label
drift []labelDiff
}
@@ -187,10 +189,10 @@ type labelLookalike struct {
// In taxonomy order, because a bootstrap prints its plan in that order and a map
// would shuffle it on every run — two identical runs would look like different
// ones.
func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []labelLookalike) {
byName := make(map[string]*wire.Label, len(existing))
for i := range existing {
byName[existing[i].Name] = &existing[i]
func labelPlan(specs []sdk.CreateLabelOption, existing []*sdk.Label) ([]labelRow, []labelLookalike) {
byName := make(map[string]*sdk.Label, len(existing))
for _, l := range existing {
byName[l.Name] = l
}
canonical := make(map[string]map[string]bool, len(specs))
@@ -205,8 +207,7 @@ func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []
}
var similar []labelLookalike
for i := range existing {
l := &existing[i]
for _, l := range existing {
if _, exact := canonical[l.Name]; exact {
continue
}
@@ -228,7 +229,7 @@ func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []
//
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
// name matched exactly or this row would not exist.
func labelDrift(spec wire.LabelRequest, got *wire.Label) []labelDiff {
func labelDrift(spec sdk.CreateLabelOption, got *sdk.Label) []labelDiff {
var out []labelDiff
if labelHex(got.Color) != labelHex(spec.Color) {
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
+26 -19
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
@@ -172,7 +174,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
State: *state, Labels: labels, Query: query, Milestone: *milestone,
Limit: *limit,
Keep: func(p *wire.Issue) bool {
Keep: func(p *sdk.Issue) bool {
return pullLandsInStore(p, dropClosed, namer)
},
})
@@ -190,13 +192,18 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
seen := map[int]bool{}
for _, t := range queue {
seen[t.payload.Number] = true
seen[int(t.payload.Index)] = true
}
for len(queue) > 0 {
task := queue[0]
queue = queue[1:]
p := task.payload
// The SDK spells an issue number `Index`, and int64. It is
// an int everywhere on this side of the transport — in the
// ledger, in a key, in `depends:` — so it is narrowed once,
// here, rather than cast at every use.
number := int(p.Index)
id, err := namer.idFor(p)
if err != nil {
return err
@@ -208,13 +215,13 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// links, and its blockers are not followed. The slug stays
// unclaimed too, so no other issue ends up pointing
// `depends:` at a file that is not there.
if dropClosed && p.State == "closed" && !stored {
dropped = append(dropped, p.Number)
if dropClosed && p.State == sdk.StateClosed && !stored {
dropped = append(dropped, number)
continue
}
namer.taken[id] = true
numberOf[p.Number] = id
numberOf[number] = id
// The native links, fetched ONCE for the two things they are
// for: filling this issue's `depends:` and telling the walk
@@ -222,7 +229,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// store, and only one.
var blockers []int
if !*noDeps {
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
if blockers, err = pullBlockers(client, number, repo); err != nil {
return err
}
}
@@ -248,10 +255,10 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
if _, err := issue.Save(root, next); err != nil {
return err
}
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
if _, err := pullSyncComments(client, root, id, number, p.Comments); err != nil {
return err
}
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
ledger.Set(wire.Key{Repo: repo, Number: number}, id)
written = append(written, id)
pending = append(pending, unresolved{id, missing})
}
@@ -320,7 +327,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// pullTask is one issue to walk, and how far from a seed it was found.
type pullTask struct {
payload *wire.Issue
payload *sdk.Issue
depth int
}
@@ -387,8 +394,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
len(listing.Issues), strings.Join(what, " + "), f.State)
out := make([]pullTask, 0, len(listing.Issues))
for i := range listing.Issues {
out = append(out, pullTask{payload: &listing.Issues[i]})
for _, p := range listing.Issues {
out = append(out, pullTask{payload: p})
}
return out, nil
}
@@ -401,8 +408,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
// only when the store already has it (it is refreshed, and that is a write);
// anything else counts, including one --cached will skip, because a skipped
// issue is still an issue the store holds when the run ends.
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != "closed" {
func pullLandsInStore(p *sdk.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != sdk.StateClosed {
return true
}
id, err := namer.idFor(p)
@@ -423,13 +430,13 @@ func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
// either resolve to the wrong issue or invent an edge. The body still names it,
// so nothing is lost.
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
deps, err := c.Dependencies(number)
keys, err := c.DependencyKeys(number)
if err != nil {
return nil, err
}
var out []int
for i := range deps {
if k := deps[i].KeyIn(repo); k.Repo == repo {
for _, k := range keys {
if k.Repo == repo {
out = append(out, k.Number)
}
}
@@ -444,7 +451,7 @@ func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
// absence of the file is the answer, not a gap in what was asked for.
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
path := commentsSidecarPath(root, id)
var thread []wire.Comment
var thread []*sdk.Comment
if count > 0 {
var err error
if thread, err = c.ListComments(number); err != nil {
@@ -530,8 +537,8 @@ type pullNamer struct {
// idFor is the slug this remote issue belongs under. Three sources, in order —
// see the command's own documentation for why that order and not another.
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
func (n *pullNamer) idFor(p *sdk.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: int(p.Index)}); got != "" {
return got, nil
}
marked := mapping.IDInBody(p.Body)
+73 -37
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
@@ -241,7 +243,7 @@ and a warning says so.`,
return err
}
if m != nil {
msID = wire.Set(m.ID)
msID = sdk.OptionalInt64(m.ID)
}
milestones[i.Milestone] = msID
}
@@ -252,17 +254,17 @@ and a warning says so.`,
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
sent, synced := mapping.NumberOf(i)
var got *wire.Issue
var got *sdk.Issue
verb := "created"
if synced {
// An edit says what state it means; a create takes the
// tracker's default.
opt.IncludeState = true
verb = "updated"
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
got, err = client.EditIssue(sent, mapping.ToEdit(i, opt), "issue-"+id)
} else {
sent = 0
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
got, err = client.CreateIssue(mapping.ToCreate(i, opt), "issue-"+id)
}
// THE GATE. Below this line a local file is going to be
// deleted, so anything short of a confirmed write stops the
@@ -274,7 +276,7 @@ and a warning says so.`,
if !confirmed {
return Fail("%s: the tracker's answer does not confirm the write "+
"(it carries number %d) — %s is untouched, nothing was deleted",
id, got.Number, issue.PathOf(root, id))
id, got.Index, issue.PathOf(root, id))
}
// The number is confirmed, so the ledger learns it NOW —
@@ -290,35 +292,30 @@ and a warning says so.`,
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
}
// Gitea occasionally drops labels handed to it on create, so
// the echo is checked and the set re-applied rather than
// trusted. A failure here is a warning and not an abort: the
// issue IS in the tracker, and a run that stopped now would
// leave a file whose `gitea:` field was never written — which
// the next push would file all over again as a new issue.
applied := map[string]bool{}
for _, l := range got.Labels {
applied[l.Name] = true
}
var ids []int64
var missing []string
for _, name := range i.Labels {
lid, in := labelIDs[name]
if !in {
continue
}
ids = append(ids, lid)
if !applied[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
id, strings.Join(missing, ", "), err)
// THE LABELS THE TRACKER ENDED UP WITH HAVE TO BE THE
// ISSUE'S, and two different things leave them disagreeing:
//
// - a create can DROP labels handed to it, which Gitea has
// been seen to do, so the echo is checked rather than
// trusted;
// - an edit cannot carry labels AT ALL — Gitea's PATCH
// takes none — so a label added or removed locally is
// not in the answer either way.
//
// One answer to both: compare the set the tracker echoed
// with the set this issue wants, and PUT the whole list when
// they differ. A failure here is a warning and not an abort:
// the issue IS in the tracker, and a run that stopped now
// would leave a file whose `gitea:` field was never written
// — which the next push would file all over again as a new
// issue.
if drift := pushLabelDrift(got.Labels, i.Labels, labelIDs); len(drift) > 0 {
if _, err := client.SetLabels(number, mapping.LabelIDsFor(i, opt), "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not set the labels (%s): %v\n",
id, strings.Join(drift, ", "), err)
} else {
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
id, strings.Join(missing, ", "))
fmt.Fprintf(os.Stderr, "%s: labels set via PUT (%s)\n",
id, strings.Join(drift, ", "))
}
}
@@ -454,6 +451,44 @@ func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
return out, nil
}
// pushLabelDrift is the names on which the tracker's answer and the issue
// disagree — what a receipt says when the PUT that follows is made.
//
// BOTH DIRECTIONS. A wanted label the answer does not carry is one Gitea
// dropped or one an edit could not send; a label the answer carries that the
// issue no longer wants is one somebody removed locally, and leaving it would
// make `push --update` a command that can add a label but never take one off.
//
// Only labels this run resolved to an id count as wanted: an unknown name was
// already left off deliberately, and one the repository holds but the taxonomy
// does not know is not this command's to remove either — which is why the
// comparison is against the resolved set and not against `labels:` as written.
func pushLabelDrift(got []*sdk.Label, want []string, ids map[string]int64) []string {
have := map[string]bool{}
for _, l := range got {
if l != nil {
have[l.Name] = true
}
}
wanted := map[string]bool{}
var drift []string
for _, name := range want {
if _, resolved := ids[name]; !resolved {
continue
}
wanted[name] = true
if !have[name] {
drift = append(drift, name)
}
}
for _, l := range got {
if l != nil && !wanted[l.Name] {
drift = append(drift, "-"+l.Name)
}
}
return drift
}
// pushDep is what one `depends:` entry is, as far as linking is concerned.
type pushDep struct {
// Slug is the dependency as `depends:` spells it.
@@ -615,14 +650,15 @@ func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
// expected, or a `number` that is not a number — the transport fails all three
// before returning, and the file survives by never reaching the delete.
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
if got == nil || got.Number <= 0 {
func pushConfirmedNumber(got *sdk.Issue, sent int) (int, bool) {
if got == nil || got.Index <= 0 {
return 0, false
}
if sent != 0 && got.Number != sent {
number := int(got.Index)
if sent != 0 && number != sent {
return 0, false
}
return got.Number, true
return number, true
}
// pushGitBranch is the branch HEAD is on, or "".
+5 -5
View File
@@ -6,6 +6,7 @@ import (
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
@@ -80,10 +81,9 @@ milestones or labels, or the web UI.`,
ledger := gitea.LoadRemoteMap(root)
repo := client.Repo()
for i := range listing.Issues {
p := &listing.Issues[i]
for _, p := range listing.Issues {
labels := "-"
if names := p.LabelNames(); len(names) > 0 {
if names := mapping.LabelNames(p); len(names) > 0 {
labels = strings.Join(names, ", ")
}
// One line per issue is the whole point; a repository that
@@ -91,8 +91,8 @@ milestones or labels, or the web UI.`,
if len(labels) > labelColumn {
labels = labels[:labelColumn]
}
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Number, p.State, labels, p.Title)
if local := ledger.Slug(wire.Key{Repo: repo, Number: p.Number}); local != "" {
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Index, p.State, labels, p.Title)
if local := ledger.Slug(wire.Key{Repo: repo, Number: int(p.Index)}); local != "" {
fmt.Printf("%13s└─ local: %s\n", "", local)
}
}
+81 -61
View File
@@ -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")
+20 -5
View File
@@ -97,6 +97,13 @@ func wrNewTracker() *wrTracker {
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
tr.mu.Lock()
defer tr.mu.Unlock()
// The version handshake, answered before the log: it is not a call any
// command made, and counting it would make every "how many requests did
// that send" assertion in this file one out.
if pullVersionRoute(w, r) {
return
}
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
// The scheme Gitea uses and the client sends: the word `token`.
@@ -150,13 +157,21 @@ func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
var req struct {
State *string `json:"state"`
Title *string `json:"title"`
Title string `json:"title"`
Body *string `json:"body"`
}
wrDecode(r, &req)
// A close is state and nothing else; a title arriving here would be
// the command editing an issue it was only asked to close.
if req.Title != nil {
http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity)
// A close is state and nothing else; a title or a body arriving
// here would be the command editing an issue it was only asked to
// close.
//
// A title of "" is not one. The SDK's edit body has no pointer
// there and sends the key whatever happens, so an empty string is
// how "no opinion" is spelled for this one field — which is also
// how Gitea itself reads it, and Gitea is what this stands in for.
// Everything else is a pointer and must arrive as null.
if req.Title != "" || req.Body != nil {
http.Error(w, `{"message":"close sent more than a state"}`, http.StatusUnprocessableEntity)
return
}
if req.State != nil {
+273 -151
View File
@@ -10,17 +10,25 @@
// that layer is not imported here either: it sits above this package, not
// beside it.
//
// The JSON shapes and the issue keys are internal/wire's. They are not this
// package's to own, because the bridge needs exactly the same vocabulary and
// cannot import a transport to get it; a copy on each side is two structs that
// drift and a command that copies fields between them by hand.
// The payload shapes are code.gitea.io/sdk/gitea's, aliased `sdk` everywhere it
// is imported so that one type has one spelling across the tree. They are not
// this package's to own and never were: the bridge needs exactly the same
// vocabulary and cannot import a transport to get it, and a copy on each side
// is two structs that drift and a command that copies fields between them by
// hand. The issue KEYS are still internal/wire's — the SDK addresses an issue
// as (owner, repo, int64) and never parses `owner/repo#42` out of anything.
//
// Every request goes through Call. One place sets the header, one place reads
// a status code, one place files the request body. When this was a Python
// module shelling out to `tea api`, "why did that fail" meant reading a
// subprocess's stderr and guessing; here a failure is an *APIError carrying the
// status AND the body the server actually sent, because "500" on its own has
// never helped anybody.
// WHAT THIS PACKAGE IS, NOW THAT THE SDK EXISTS: the one place that holds the
// credentials, the scratchpad and the repository this project points at, so
// that no command has to. Every method here is a thin wrapper, and the three
// things the wrapping is for are the three things the SDK does not do:
//
// - every request body is filed under `.kettle/payload/` by a RoundTripper,
// so a retry or a post-mortem has the bytes that went out;
// - every failure comes back as an *APIError carrying the status AND what the
// server said, because "500" on its own has never helped anybody;
// - a listing stops when the caller has what it asked for, which a client
// that fetches whole pages into a slice cannot do.
package gitea
import (
@@ -30,21 +38,21 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
const (
// apiPrefix is where every Gitea instance puts its REST API.
apiPrefix = "/api/v1"
// userAgent names this binary in the server's log. A tracker admin looking
// at a burst of requests should be able to tell what made them.
userAgent = "kettle"
@@ -59,25 +67,28 @@ const (
// Client talks to one repository on one Gitea instance.
type Client struct {
// HTTP is the transport, exported so a caller can change the timeout or
// hand in an instrumented one. Never nil after New.
HTTP *http.Client
// api is the SDK client: one per run, shared by every copy For makes.
api *sdk.Client
// http is the SDK's transport, kept because AddDependency still sends one
// request by hand — see there.
http *http.Client
// dump is the RoundTripper that files request bodies. Shared with every
// copy For makes, because the scratchpad is one directory per run.
dump *dumper
base string // instance URL with the API prefix, no trailing slash
base string // instance URL, no API prefix and no trailing slash
token string
repo wire.Repo
// payloadRoot is resolved once, by New, and is never taken from a caller.
// The one time where a request body lands was an argument, it got pointed
// at the issue store — see writePayload.
payloadRoot string
}
// New builds a client for the repository this project points at.
//
// It refuses a half-filled configuration instead of letting the first call come
// back 401 or 404: those answers name nothing an operator can act on, and every
// field missing here has exactly one command that supplies it.
// field missing here has exactly one command that supplies it. That check comes
// first because building the client now DIALS — the SDK asks the instance for
// its version before it hands one back — and a missing token reported as a
// connection failure sends the operator to the wrong place.
func New(cfg *config.Resolved) (*Client, error) {
if cfg == nil {
return nil, errors.New("no resolved configuration — call config.Require first")
@@ -95,98 +106,61 @@ func New(cfg *config.Resolved) (*Client, error) {
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
base := strings.TrimRight(cfg.URL, "/")
dump := &dumper{next: http.DefaultTransport, root: project.PayloadRoot("")}
hc := &http.Client{Timeout: requestTimeout, Transport: dump}
api, err := sdk.NewClient(base,
sdk.SetToken(cfg.Token),
sdk.SetHTTPClient(hc),
sdk.SetUserAgent(userAgent))
if err != nil {
// A version string the SDK cannot parse is not a cosmetic failure, and
// it is refused here rather than shrugged off: the SDK hands back a
// usable-looking client that has quietly decided the server is Gitea
// 1.11, and its 1.11 compatibility path rewrites an issue's URL from a
// `repository` field a modern payload need not carry — a nil
// dereference on the first issue read. Saying so at the handshake beats
// crashing three calls later.
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
}
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
}
return &Client{
HTTP: &http.Client{Timeout: requestTimeout},
base: strings.TrimRight(cfg.URL, "/") + apiPrefix,
token: cfg.Token,
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
payloadRoot: project.PayloadRoot(""),
api: api,
http: hc,
dump: dump,
base: base,
token: cfg.Token,
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
}, nil
}
// Repo is the repository every path is built against.
// Repo is the repository every call is made against.
func (c *Client) Repo() wire.Repo { return c.repo }
// For returns a copy of this client pointed at another repository, for the run
// that was given an explicit owner/name. The credentials and the scratchpad
// come along; only the paths change.
// that was given an explicit owner/name.
//
// Bookkeeping and not a second connection: the SDK takes the owner and the name
// per call, so what changes is which pair this client passes. The credentials,
// the negotiated server version and the scratchpad are all shared, which is
// what makes `kettle pull owner/repo#42` cost nothing extra.
func (c *Client) For(r wire.Repo) *Client {
out := *c
out.repo = r
return &out
}
// Body is a request payload and the name its dump is filed under.
//
// The name is the caller's label for this call, not a path: it becomes
// `<name>.json` in the scratchpad, and something that identifies the call in a
// post-mortem — an issue's slug, a label's name — is worth more there than a
// serial number.
type Body struct {
Name string
Data any
}
// owned is the owner and name every SDK call takes, escaped by the SDK itself.
func (c *Client) owned() (string, string) { return c.repo.Owner, c.repo.Name }
// Call makes one request and decodes the answer into out, which may be nil when
// there is nothing to read.
//
// body may be nil. When it is not, its Data is marshalled once: the bytes filed
// in the scratchpad and the bytes on the wire are the same bytes, so a retry
// from the file sends what this call sent.
//
// An empty response body leaves out untouched — a 204 from a PATCH is a
// success, not a decode failure.
func (c *Client) Call(method, path string, body *Body, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = c.writePayload(body); err != nil {
return err
}
}
endpoint := c.base + "/" + strings.TrimLeft(path, "/")
var reader io.Reader
if payload != nil {
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, endpoint, reader)
if err != nil {
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
// The token travels in a header and never in the URL, so an error is
// free to quote the URL in full.
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s %s: reading the response: %w", method, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(raw)}
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected (%w): %s",
method, endpoint, resp.StatusCode, err, truncate(string(raw)))
}
return nil
}
// --------------------------------------------------------------------------
// what a failure says
// --------------------------------------------------------------------------
// APIError is a non-2xx answer, carrying both halves of what happened.
//
@@ -223,6 +197,36 @@ func StatusIs(err error, status int) bool {
return errors.As(err, &apiErr) && apiErr.Status == status
}
// fail turns one SDK call's (response, error) pair into this package's error.
//
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error
// and then closes it, so the body is only ever available through err; the
// status and the request line are only ever available through resp. Neither is
// a diagnosis on its own, and dropping either is how "the tracker said no"
// becomes a message nobody can act on.
//
// A 2xx that still errored is a decode failure, not an answer the server
// refused: it keeps the status out of the message and the shape out of
// StatusIs, because a caller asking "was that a 409" must not be told yes by a
// body it could not parse.
func fail(resp *sdk.Response, err error) error {
if err == nil {
return nil
}
if resp == nil || resp.Response == nil {
return err // never reached the server; the URL is already in the error
}
method, endpoint := "", ""
if r := resp.Request; r != nil {
method, endpoint = r.Method, r.URL.String()
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: err.Error()}
}
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected: %w",
method, endpoint, resp.StatusCode, err)
}
func truncate(s string) string {
if len(s) <= maxErrorBody {
return s
@@ -240,11 +244,17 @@ func truncate(s string) string {
// where request bodies land
// --------------------------------------------------------------------------
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
// and returns the bytes to send.
// dumper is the RoundTripper that files a copy of every request body under
// `.kettle/payload/`.
//
// The file survives the call, for a retry or a post-mortem.
//
// A RoundTripper and not a call site's decision, because a call site can forget
// and a RoundTripper cannot: it sees every request the SDK builds, including
// the ones no method of this package spelled out. What a call site still
// supplies is the NAME — see label — because an issue's slug identifies the
// call in a post-mortem and a serial number does not.
//
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
// this package's scratchpad — a SIBLING of the issue store under the same
// marker, resolved by the same walk, so which command wrote a body cannot
@@ -255,58 +265,179 @@ func truncate(s string) string {
// are debris of the transport, and when they share a path `ls` starts lying
// about what the project holds.
//
// It is created lazily, by the first write of a run and only then, so a dry run
// or a run with nothing to send leaves no directory behind.
func (c *Client) writePayload(b *Body) ([]byte, error) {
if c.payloadRoot == "" {
return nil, project.NotFoundError("")
// It is created lazily, by the first write of a run and only then, so a run
// with nothing to send — every read-only command, and the version handshake
// every run opens with — leaves no directory behind.
type dumper struct {
next http.RoundTripper
// root is resolved once, by New, and is never taken from a caller.
root string
mu sync.Mutex
name string
}
// label names the file the next request body lands in. One label serves one
// request: it is taken, not read, so a request the SDK makes on its own account
// cannot end up filed under the last thing a command was doing.
func (d *dumper) label(name string) {
d.mu.Lock()
d.name = name
d.mu.Unlock()
}
func (d *dumper) take() string {
d.mu.Lock()
defer d.mu.Unlock()
name := d.name
d.name = ""
return name
}
// RoundTrip files the body and then sends the request.
//
// A dump that cannot be written fails the call before it is made, which is the
// order the old hand-rolled client had and worth keeping: the point of the file
// is to hold what was sent, and one that does not exist for a request that did
// is worse than not having sent it.
func (d *dumper) RoundTrip(req *http.Request) (*http.Response, error) {
if err := d.file(req); err != nil {
return nil, err
}
return d.next.RoundTrip(req)
}
func (d *dumper) file(req *http.Request) error {
name := d.take()
if req.Body == nil || req.GetBody == nil {
return nil // a read: nothing to file
}
body, err := req.GetBody()
if err != nil {
return err
}
defer body.Close()
raw, err := io.ReadAll(body)
if err != nil {
return err
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if d.root == "" {
return project.NotFoundError("")
}
if name == "" {
name = derivedName(req)
}
if err := os.MkdirAll(d.root, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(d.root, safeName(name)+".json"), readable(raw), 0o644)
}
// readable is the request body as a person reads it: indented, and with the
// markup left alone.
//
// The SDK marshals with encoding/json's defaults, which escape `<`, `>` and `&`
// into their \u00xx spellings. An issue body carries `<!-- … -->` markers and
// prose full of `&`, and a dump escaped that way is unreadable exactly when
// somebody is reading it because something went wrong.
//
// So the bytes are re-encoded rather than filed verbatim: same JSON VALUE, and
// numbers verbatim (UseNumber, so an id is not rounded through a float), but
// not the same bytes. What that costs is byte-for-byte fidelity with the wire —
// what it buys is a file anybody can read and re-POST. Anything that will not
// parse is filed as it came, because a dump of something surprising is exactly
// the dump worth having.
func readable(raw []byte) []byte {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return raw
}
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetIndent("", " ")
// An issue body carries `<!-- … -->` markers and prose full of `&`.
// Escaping those to < would make the dump unreadable exactly when
// somebody is reading it because something went wrong.
enc.SetEscapeHTML(false)
if err := enc.Encode(b.Data); err != nil {
return nil, fmt.Errorf("encoding the %s request body: %w", b.name(), err)
if err := enc.Encode(v); err != nil {
return raw
}
raw := buf.Bytes()
if err := os.MkdirAll(c.payloadRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(c.payloadRoot, b.name()+".json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
return nil, err
}
return raw, nil
return buf.Bytes()
}
// name is the file stem, with everything that is not plainly a file name folded
// away.
// derivedName is what an unnamed request is filed under: its method and its
// path. Nobody has to remember to name a call for its body to be kept — a name
// only makes the file easier to find.
func derivedName(req *http.Request) string {
return strings.ToLower(req.Method) + "-" + strings.TrimPrefix(req.URL.Path, "/api/v1/")
}
// safeName is the file stem, with everything that is not plainly a file name
// folded away.
//
// Sanitizing here rather than trusting callers: label names are namespaced
// (`type/bug`), and a name passed straight through would write outside the
// scratchpad — which is the one thing this directory exists to prevent.
func (b *Body) name() string {
if b.Name == "" {
return "request"
}
func safeName(name string) string {
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
}
return '-'
}, b.Name)
}, name)
if safe = strings.Trim(safe, "-"); safe == "" {
return "request"
}
return safe
}
// --------------------------------------------------------------------------
// the one request the SDK cannot express
// --------------------------------------------------------------------------
// post sends one JSON body to a path under this instance's API and ignores
// whatever comes back.
//
// It exists for AddDependency and for nothing else — see there for what the SDK
// leaves out. It goes through the same http.Client, so the body is filed and a
// failure carries the status and the server's words exactly as every other call
// in this package does.
func (c *Client) post(path string, body any, name string) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return err
}
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
c.dump.label(name)
resp, err := c.http.Do(req)
if err != nil {
// The token travels in a header and never in the URL, so an error is
// free to quote the URL in full.
return err
}
defer resp.Body.Close()
answer, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
}
return nil
}
// --------------------------------------------------------------------------
// pagination
// --------------------------------------------------------------------------
@@ -327,21 +458,19 @@ const (
PageSlack = 4
)
// pages GETs a list endpoint page by page and hands each page to each as it
// arrives, stopping when each returns false, when a short page says the list is
// pages calls fetch page by page and hands each page to each as it arrives,
// stopping when each returns false, when a short page says the list is
// exhausted, or when budget pages have been read.
//
// A callback rather than a slice, because a caller whose budget is spent on
// what it KEEPS cannot be served by a function that fetches everything first:
// the page after the one that completed the budget must never be requested.
func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
// That is the one thing the SDK's own list options cannot do — they describe a
// page, and this describes when to stop asking for another.
func pages[T any](fetch func(page, limit int) ([]T, error), limit, budget int, each func([]T) (bool, error)) error {
for page := 1; page <= budget; page++ {
var batch []T
if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil {
batch, err := fetch(page, limit)
if err != nil {
return err
}
if len(batch) == 0 {
@@ -359,23 +488,16 @@ func pages[T any](c *Client, path string, limit, budget int, each func([]T) (boo
}
// paginate follows a list endpoint to exhaustion and returns the whole list.
func paginate[T any](c *Client, path string, limit int) ([]T, error) {
func paginate[T any](fetch func(page, limit int) ([]T, error), limit int) ([]T, error) {
var out []T
err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) {
err := pages(fetch, limit, maxPages, func(batch []T) (bool, error) {
out = append(out, batch...)
return true, nil
})
return out, err
}
// repoPath builds an endpoint under this client's repository. Owner and name
// are escaped: they arrive from a config file, and a file is a thing people
// type into.
func (c *Client) repoPath(suffix string) string {
return "repos/" + url.PathEscape(c.repo.Owner) + "/" + url.PathEscape(c.repo.Name) + "/" + suffix
}
// repoPathf is repoPath with the issue or label number formatted in.
func (c *Client) repoPathf(format string, args ...any) string {
return c.repoPath(fmt.Sprintf(format, args...))
// listOptions is one page, as the SDK asks for it.
func listOptions(page, limit int) sdk.ListOptions {
return sdk.ListOptions{Page: page, PageSize: limit}
}
+220 -41
View File
@@ -9,6 +9,12 @@ package gitea_test
// — and a request dump would land in the developer's own project. Nothing here
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
// so a run can neither read nor overwrite the developer's own tokens.
//
// EVERY FAKE ANSWERS /api/v1/version, because building a client is now a
// request: the SDK asks the instance what it is before it hands one back, and
// that answer is what the dependency gate is decided on later. A fake that did
// not answer it would be a fake no client can be built against — see
// versionRoute.
import (
"encoding/json"
@@ -19,15 +25,22 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// modernGitea is what a fake says it is: new enough for everything this
// transport asks for, dependency endpoints included.
const modernGitea = "1.26.1"
// newProject makes an initialized project and points the walk at it. Returns
// the project root.
func newProject(t *testing.T) string {
@@ -41,6 +54,31 @@ func newProject(t *testing.T) string {
return dir
}
// versionRoute answers the SDK's version handshake and reports whether it did,
// so every other handler can be written as though the request were not there.
func versionRoute(w http.ResponseWriter, r *http.Request, version string) bool {
if r.URL.Path != "/api/v1/version" {
return false
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"version":"`+version+`"}`)
return true
}
// serve is an httptest server that speaks the handshake and hands everything
// else to next.
func serve(t *testing.T, version string, next http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if versionRoute(w, r, version) {
return
}
next(w, r)
}))
t.Cleanup(srv.Close)
return srv
}
func newClient(t *testing.T, url string) *gitea.Client {
t.Helper()
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
@@ -65,7 +103,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
var asked []string
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.URL.RequestURI())
auth = r.Header.Get("Authorization")
@@ -82,8 +120,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
})
got, err := newClient(t, srv.URL).ListComments(42)
if err != nil {
@@ -102,7 +139,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
if auth != "token s3cret" {
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
}
want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50"
want := "/api/v1/repos/acme/widgets/issues/42/comments?limit=50&page=1"
if asked[0] != want {
t.Errorf("first request was %s, want %s", asked[0], want)
}
@@ -113,11 +150,10 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
}))
defer srv.Close()
})
_, err := newClient(t, srv.URL).GetIssue(7)
if err == nil {
@@ -152,21 +188,20 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
root := newProject(t)
var sent []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
sent, _ = io.ReadAll(r.Body)
writeJSON(t, w, map[string]any{
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
}))
defer srv.Close()
})
body := "<!-- kettle:id wire-sqlc --> a & b"
got, err := newClient(t, srv.URL).CreateIssue(
wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc")
sdk.CreateIssueOption{Title: "wire sqlc", Body: body}, "issue-wire-sqlc")
if err != nil {
t.Fatalf("CreateIssue: %v", err)
}
if got.Number != 42 {
t.Errorf("got issue #%d, want #42", got.Number)
if got.Index != 42 {
t.Errorf("got issue #%d, want #42", got.Index)
}
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
@@ -174,19 +209,33 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
if err != nil {
t.Fatalf("the request body was not filed at %s: %v", path, err)
}
if string(raw) != string(sent) {
// The same JSON VALUE as went out, and not the same bytes: the SDK marshals
// with encoding/json's defaults, so what is on the wire has its markup
// escaped and no indentation. A dump nobody can read is a dump nobody
// reads, and a re-POST of this file sends what this call sent.
var filed, onTheWire any
if err := json.Unmarshal(raw, &filed); err != nil {
t.Fatalf("the filed body is not JSON: %v\n%s", err, raw)
}
if err := json.Unmarshal(sent, &onTheWire); err != nil {
t.Fatalf("what was sent is not JSON: %v\n%s", err, sent)
}
if !reflect.DeepEqual(filed, onTheWire) {
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
}
// A dump escaped to \u003c is unreadable exactly when it is being read.
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
}
if !strings.Contains(string(raw), "\n \"") {
t.Errorf("the dump is not indented:\n%s", raw)
}
// The whole reason the scratchpad is a sibling.
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
t.Errorf("writing a request body materialized the issue store (%v)", err)
}
// A namespaced name must not climb out of the scratchpad.
if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil {
if _, err := newClient(t, srv.URL).CreateLabel(sdk.CreateLabelOption{Name: "type/bug", Color: "#ee0701"}); err != nil {
t.Fatalf("CreateLabel: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
@@ -198,14 +247,14 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
}
// A run that sends no body leaves no directory behind — the scratchpad is
// created by the first write and only then.
// created by the first write and only then. The version handshake every client
// opens with is a read, so building one is not "a run that sent something".
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
root := newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]any{"number": 42})
}))
defer srv.Close()
})
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
t.Fatalf("GetIssue: %v", err)
@@ -221,7 +270,7 @@ func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/milestones") {
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
return
@@ -237,8 +286,7 @@ func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
"pull_request": map[string]any{"merged": false}},
})
}))
defer srv.Close()
})
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
if err != nil {
@@ -247,7 +295,7 @@ func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
if got.Milestone != "v1" {
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
}
if len(got.Issues) != 1 || got.Issues[0].Number != 1 {
if len(got.Issues) != 1 || got.Issues[0].Index != 1 {
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
}
@@ -265,7 +313,7 @@ func TestListIssuesStopsAtThePageBudget(t *testing.T) {
newProject(t)
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
pages++
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out := []map[string]any{}
@@ -273,12 +321,11 @@ func TestListIssuesStopsAtThePageBudget(t *testing.T) {
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
})
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
Limit: 2,
Keep: func(i *wire.Issue) bool { return i.State == "open" },
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
})
if err != nil {
t.Fatalf("ListIssues: %v", err)
@@ -302,7 +349,7 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
newProject(t)
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
pages++
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out := []map[string]any{}
@@ -310,12 +357,11 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
}
writeJSON(t, w, out)
}))
defer srv.Close()
})
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
Limit: 2,
Keep: func(i *wire.Issue) bool { return i.State == "open" },
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
})
if err != nil {
t.Fatalf("ListIssues: %v", err)
@@ -328,29 +374,122 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
}
}
// A dependency endpoint the instance does not have is "no dependencies", not a
// failed pull. A dead connection still is one.
// A dependency endpoint the instance has but this repository does not is "no
// dependencies", not a failed pull. A dead connection still is one.
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}))
defer srv.Close()
})
got, err := newClient(t, srv.URL).DependencyKeys(42)
// Both clients are built while the server is up, because building one is
// itself a request now — and the question this asks is what a call does
// when the connection dies UNDER it, which is a different failure from a
// tracker that was never there.
live := newClient(t, srv.URL)
dead := newClient(t, srv.URL)
got, err := live.DependencyKeys(42)
if err != nil || len(got) != 0 {
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
}
srv.Close()
if _, err := newClient(t, srv.URL).Dependencies(42); err == nil {
if _, err := dead.Dependencies(42); err == nil {
t.Error("a dead connection was reported as an instance without dependency support")
}
}
// The gate the SDK made possible: an instance too old to have the endpoint at
// all is answered from its version, without a request.
//
// It matters because the alternative is guessing from a status code. An old
// Gitea answers a route it does not have with the same 404 it answers for an
// issue that does not exist, and a pull that read the second as "no blockers"
// would quietly drop half the unit of work.
func TestDependenciesAreNotAskedForOnAnInstanceTooOldToHaveThem(t *testing.T) {
newProject(t)
asked := 0
srv := serve(t, "1.19.4", func(w http.ResponseWriter, r *http.Request) {
asked++
writeJSON(t, w, []map[string]any{{"number": 7}})
})
c := newClient(t, srv.URL)
got, err := c.Dependencies(42)
if err != nil || len(got) != 0 {
t.Errorf("Dependencies = %v, %v; want none and no error", got, err)
}
if asked != 0 {
t.Errorf("%d request(s) went out — the version had already answered", asked)
}
// Writing one says so out loud instead: push reports it beside the issue it
// could not link, and a warning naming the version is something an operator
// can act on.
err = c.AddDependency(42, wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7})
if err == nil {
t.Fatal("a link was attempted against an instance that has no endpoint for it")
}
if !strings.Contains(err.Error(), "1.20.0") {
t.Errorf("the refusal does not name the version that would work: %v", err)
}
if asked != 0 {
t.Errorf("%d request(s) went out for a write the instance cannot take", asked)
}
}
// And the other side of the same gate: a modern instance is asked, and the
// answer comes back as cross-repo keys — a dependency is allowed to live
// somewhere else, and a bare number would not say where.
func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
newProject(t)
var body string
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
raw, _ := io.ReadAll(r.Body)
body = string(raw)
w.WriteHeader(http.StatusCreated)
return
}
writeJSON(t, w, []map[string]any{
{"number": 7},
{"number": 3, "repository": map[string]any{"full_name": "other/repo"}},
})
})
c := newClient(t, srv.URL)
got, err := c.DependencyKeys(42)
if err != nil {
t.Fatalf("DependencyKeys: %v", err)
}
want := []wire.Key{
{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7},
{Repo: wire.Repo{Owner: "other", Name: "repo"}, Number: 3},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("keys = %v, want %v", got, want)
}
// The one endpoint left with a hand-rolled request, because the SDK's
// IssueMeta carries an index and nothing else: a link to another repository
// needs the owner and the name with it.
if err := c.AddDependency(42, want[1]); err != nil {
t.Fatalf("AddDependency: %v", err)
}
for _, part := range []string{`"index":3`, `"owner":"other"`, `"repo":"repo"`} {
if !strings.Contains(body, part) {
t.Errorf("the link body does not carry %s: %s", part, body)
}
}
}
// A half-filled configuration is refused here rather than at the first 401,
// because a 401 names nothing an operator can act on.
// because a 401 names nothing an operator can act on — and before the client is
// built at all, because building one dials.
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
for _, tc := range []struct {
what string
@@ -372,14 +511,54 @@ func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
}
}
// An instance nobody can reach is named as one. The handshake is the first
// request of every run, so this is the failure an operator meets when the URL
// is wrong or the tracker is down, and it has to say which instance.
func TestNewSaysWhichInstanceItCouldNotReach(t *testing.T) {
newProject(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close()
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
if err == nil {
t.Fatal("a client was built against a tracker that is not there")
}
if !strings.Contains(err.Error(), srv.URL) {
t.Errorf("the error does not name the instance: %v", err)
}
}
// A version nobody can parse is refused at the handshake, not carried.
//
// The SDK hands back a client that has silently decided the server is 1.11 and
// then rewrites issue URLs from a field a modern payload need not carry, which
// is a nil dereference on the first issue read. A refusal that names the
// instance is the answer an operator can act on.
func TestNewRefusesAnInstanceWhoseVersionIsNotOne(t *testing.T) {
newProject(t)
srv := serve(t, "not-a-version", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("a call went out to %s after the handshake had already failed", r.URL.Path)
})
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
if err == nil {
t.Fatal("an unreadable version was accepted — the first issue read would panic inside the SDK")
}
if !strings.Contains(err.Error(), srv.URL) || !strings.Contains(err.Error(), config.EnvURL) {
t.Errorf("the refusal names neither the instance nor the setting that points at it: %v", err)
}
}
// The layering rule, from this side. The transport knows numbers, logins, HTTP
// and JSON; the domain knows none of those, and neither may reach the other.
//
// The bridge is out too, and for a reason of its own: it is the layer that
// translates between the two, so it sits ABOVE both. A transport that imported
// it would be a transport that knows what an issue is, one indirection later —
// and the protocol both of them share, internal/wire, exists precisely so that
// neither has to reach for the other to name a payload.
// and the vocabulary both of them share, the SDK's payloads, exists precisely
// so that neither has to reach for the other to name one.
func TestTransportDoesNotImportTheDomain(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
+147 -80
View File
@@ -3,10 +3,11 @@ package gitea
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
@@ -14,86 +15,98 @@ import (
//
// 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 {
func (c *Client) GetIssue(number int) (*sdk.Issue, error) {
owner, repo := c.owned()
got, resp, err := c.api.GetIssue(owner, repo, int64(number))
if err := fail(resp, err); 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 {
if got == nil || got.Index == 0 {
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
}
return &got, nil
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 {
func (c *Client) CreateIssue(opt sdk.CreateIssueOption, name string) (*sdk.Issue, error) {
owner, repo := c.owned()
c.dump.label(name)
got, resp, err := c.api.CreateIssue(owner, repo, opt)
if err := fail(resp, err); err != nil {
return nil, err
}
return &got, nil
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 {
// EditIssue patches an existing issue. Only the fields set on opt are sent.
//
// LABELS DO NOT GO THROUGH HERE. Gitea's edit endpoint takes no label list and
// neither does the SDK's EditIssueOption, so an issue whose labels changed
// needs SetLabels after this — push does exactly that, and says so.
func (c *Client) EditIssue(number int, opt sdk.EditIssueOption, name string) (*sdk.Issue, error) {
owner, repo := c.owned()
c.dump.label(name)
got, resp, err := c.api.EditIssue(owner, repo, int64(number), opt)
if err := fail(resp, err); err != nil {
return nil, err
}
return &got, nil
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) {
// the answer to that is to re-apply them rather than to trust the echo. It is
// also the only way to change the labels of an issue that already exists — see
// EditIssue.
func (c *Client) SetLabels(number int, ids []int64, name string) ([]*sdk.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 {
owner, repo := c.owned()
c.dump.label(name)
got, resp, err := c.api.ReplaceIssueLabels(owner, repo, int64(number), sdk.IssueLabelsOption{Labels: ids})
if err := fail(resp, err); 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)
func (c *Client) ListComments(number int) ([]*sdk.Comment, error) {
owner, repo := c.owned()
return paginate(func(page, limit int) ([]*sdk.Comment, error) {
got, resp, err := c.api.ListIssueComments(owner, repo, int64(number),
sdk.ListIssueCommentOptions{ListOptions: listOptions(page, limit)})
return got, fail(resp, err)
}, 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 {
func (c *Client) CreateComment(number int, text, name string) (*sdk.Comment, error) {
owner, repo := c.owned()
c.dump.label(name)
got, resp, err := c.api.CreateIssueComment(owner, repo, int64(number),
sdk.CreateIssueCommentOption{Body: text})
if err := fail(resp, err); err != nil {
return nil, err
}
return &got, nil
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 {
func (c *Client) EditComment(id int64, text, name string) (*sdk.Comment, error) {
owner, repo := c.owned()
c.dump.label(name)
got, resp, err := c.api.EditIssueComment(owner, repo, id, sdk.EditIssueCommentOption{Body: text})
if err := fail(resp, err); err != nil {
return nil, err
}
return &got, nil
}
type commentBody struct {
Body string `json:"body"`
return got, nil
}
// --------------------------------------------------------------------------
@@ -121,13 +134,13 @@ type IssueFilter struct {
// 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
Keep func(*sdk.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
Issues []*sdk.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
@@ -167,23 +180,27 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
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, ","))
owner, repo := c.owned()
fetch := func(page, limit int) ([]*sdk.Issue, error) {
opt := sdk.ListIssueOption{
ListOptions: listOptions(page, limit),
State: sdk.StateType(state),
// Issues and not pull requests. The server has been known to
// ignore this, which is why matches re-checks it.
Type: sdk.IssueTypeIssue,
Labels: f.Labels,
KeyWord: f.Query,
}
if out.Milestone != "" {
opt.Milestones = []string{out.Milestone}
}
got, resp, err := c.api.ListRepoIssues(owner, repo, opt)
return got, fail(resp, err)
}
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)
@@ -193,15 +210,14 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
}
kept, seen, lastFull := 0, 0, false
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
err := pages(fetch, perPage, budget, func(batch []*sdk.Issue) (bool, error) {
seen++
lastFull = len(batch) == perPage
for i := range batch {
p := &batch[i]
for _, p := range batch {
if !matches(p, milestoneID, f.Labels) {
continue
}
out.Issues = append(out.Issues, *p)
out.Issues = append(out.Issues, p)
if f.Keep == nil || f.Keep(p) {
kept++
if kept >= f.Limit {
@@ -230,10 +246,10 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
// 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() {
// A function and not a method: the payload is the SDK's, and re-checking a
// filter the server ignored is this package's business, not the payload's.
func matches(i *sdk.Issue, milestoneID int64, labels []string) bool {
if i.PullRequest != nil {
return false
}
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
@@ -255,28 +271,46 @@ func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
// 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"`
// dependenciesSince is the first Gitea release that answers at
// /issues/{index}/dependencies at all.
//
// Checked against the release tags themselves and not guessed: the routes are
// absent from routers/api/v1/api.go through 1.19 and present in 1.20. Asking
// the version rather than the endpoint is what turns "some error came back"
// into an answer — and it costs no request, because the SDK already negotiated
// the version when the client was built.
const dependenciesSince = ">= 1.20.0"
// hasDependencies reports whether this instance is new enough to have the
// dependency endpoints.
func (c *Client) hasDependencies() bool {
return c.api.CheckServerVersionConstraint(dependenciesSince) == nil
}
// 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.
// TWO WAYS FOR THERE TO BE NO ANSWER, and both are reported as "no
// dependencies" rather than as a failure, because a pull must still bring the
// issue itself back:
//
// - the instance predates the endpoint, which the version says before a
// request is made;
// - the instance has it but this repository does not — dependencies turned
// off, a tracker disabled — which only the tracker's own answer can say.
//
// 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)
func (c *Client) Dependencies(number int) ([]*sdk.Issue, error) {
if !c.hasDependencies() {
return nil, nil
}
owner, repo := c.owned()
got, resp, err := c.api.ListIssueDependencies(owner, repo, int64(number),
sdk.ListIssueDependenciesOptions{ListOptions: listOptions(1, pageLimit)})
err = fail(resp, err)
var apiErr *APIError
if errors.As(err, &apiErr) {
return nil, nil
@@ -298,12 +332,38 @@ func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
return nil, err
}
out := make([]wire.Key, 0, len(deps))
for i := range deps {
out = append(out, deps[i].KeyIn(c.repo))
for _, d := range deps {
out = append(out, keyIn(d, c.repo))
}
return out, nil
}
// keyIn is a payload'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 keyIn(p *sdk.Issue, fallback wire.Repo) wire.Key {
repo := fallback
if p.Repository != nil {
if r, err := wire.ParseRepo(p.Repository.FullName); err == nil {
repo = r
}
}
return wire.Key{Repo: repo, Number: int(p.Index)}
}
// issueMeta is Gitea's own IssueMeta: how a dependency names another issue.
//
// The SDK has a type of this name too and it carries only `index`, so it can
// only ever link inside one repository. Gitea's has taken an owner and a repo
// since the endpoint existed, and a `depends:` entry is allowed to live
// somewhere else — so this one struct and the raw POST that sends it are all
// that is left of the hand-rolled client.
type issueMeta struct {
Index int `json:"index"`
Owner string `json:"owner"`
Repo string `json:"repo"`
}
// AddDependency makes issue number depend on dep.
//
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
@@ -324,9 +384,16 @@ func (c *Client) AddDependency(number int, dep wire.Key) error {
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},
if !c.hasDependencies() {
return fmt.Errorf("this Gitea has no issue-dependency API (it is not %s) — link #%d -> %s by hand",
strings.TrimPrefix(dependenciesSince, ">= "), number, dep)
}
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
// Owner and name are escaped, the way the SDK escapes them for every other
// call: they arrive from a config file, and a file is a thing people type
// into.
return c.post(
fmt.Sprintf("repos/%s/%s/issues/%d/dependencies",
url.PathEscape(c.repo.Owner), url.PathEscape(c.repo.Name), number),
issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
fmt.Sprintf("dep-%d-%d", number, dep.Number))
}
+56 -26
View File
@@ -2,11 +2,10 @@ package gitea
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
sdk "code.gitea.io/sdk/gitea"
)
// ListLabels is every label in the repository, every page of it.
@@ -14,8 +13,13 @@ import (
// 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() ([]wire.Label, error) {
return paginate[wire.Label](c, c.repoPath("labels"), 100)
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.
@@ -26,26 +30,40 @@ func (c *Client) ListLabels() ([]wire.Label, error) {
//
// What a label MEANS is not decided here either: this creates what it is
// handed.
func (c *Client) CreateLabel(req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPost, c.repoPath("labels"), body, &got); err != nil {
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.ID == 0 {
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", req.Name)
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
return got, nil
}
// EditLabel patches an existing label by id.
func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPatch, c.repoPathf("labels/%d", id), body, &got); err != nil {
//
// 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
return got, nil
}
// ListMilestones is every milestone in the repository, open and closed.
@@ -53,8 +71,15 @@ func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error)
// 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() ([]wire.Milestone, error) {
return paginate[wire.Milestone](c, c.repoPath("milestones?state=all"), 100)
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
@@ -64,14 +89,19 @@ func (c *Client) ListMilestones() ([]wire.Milestone, error) {
// 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".
func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
//
// 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 i := range got {
if got[i].Title == value || strconv.FormatInt(got[i].ID, 10) == value {
return &got[i], nil
for _, m := range got {
if m.Title == value || strconv.FormatInt(m.ID, 10) == value {
return m, nil
}
}
have := make([]string, 0, len(got))
@@ -91,7 +121,7 @@ func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
// 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) (*wire.Milestone, error) {
func (c *Client) FindMilestone(title string) (*sdk.Milestone, error) {
if title == "" || title == "none" {
return nil, nil
}
@@ -99,9 +129,9 @@ func (c *Client) FindMilestone(title string) (*wire.Milestone, error) {
if err != nil {
return nil, err
}
for i := range got {
if got[i].Title == title {
return &got[i], nil
for _, m := range got {
if m.Title == title {
return m, nil
}
}
return nil, nil
+80 -27
View File
@@ -4,6 +4,9 @@ import (
"slices"
"strconv"
"strings"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
@@ -39,7 +42,7 @@ type PayloadOptions struct {
// transport bookkeeping, and the caller has already read the slug off it to
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
// what lands on disk — sees the body the author wrote.
func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
func FromPayload(p *sdk.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
numbers := NumbersInBody(body)
@@ -68,15 +71,15 @@ func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (
// dependency listing answers with issues from elsewhere, and this is the
// handle for the copy landing in THIS store.
extra := map[string]string{
GiteaKey: wire.Key{Repo: repo, Number: p.Number}.String(),
GiteaKey: wire.Key{Repo: repo, Number: int(p.Index)}.String(),
URLKey: p.HTMLURL,
SyncedKey: opt.Synced,
}
if p.Ref != "" {
extra[BranchKey] = p.Ref
}
if p.UpdatedAt != "" {
extra[RemoteUpdatedKey] = p.UpdatedAt
if stamp := Stamp(p.Updated); stamp != "" {
extra[RemoteUpdatedKey] = stamp
}
// Zero comments is not a fact worth a line in the file — every issue that
// has never been discussed would carry one.
@@ -84,41 +87,84 @@ func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (
extra[CommentsKey] = strconv.Itoa(p.Comments)
}
state := p.State
state := string(p.State)
if state == "" {
state = "open"
}
// Appended into nil slices, so an issue with no labels is the same value as
// one loaded from a file — the store's own parser yields nothing, not an
// empty list, and two spellings of "none" is a comparison bug waiting.
var labels []string
for _, l := range p.Labels {
labels = append(labels, l.Name)
}
var assignees []string
for _, a := range p.Assignees {
assignees = append(assignees, a.Login)
}
milestone := ""
if p.Milestone != nil {
milestone = p.Milestone.Title
}
return &issue.Issue{
ID: id,
Title: p.Title,
Body: body,
State: state,
Labels: labels,
Assignees: assignees,
Milestone: milestone,
Labels: LabelNames(p),
Assignees: AssigneeLogins(p),
Milestone: MilestoneTitle(p),
Depends: deps,
Origin: Origin,
Extra: extra,
}, unresolved
}
// LabelNames are a payload's label names, in the order the tracker listed them.
//
// Appended into a nil slice, so an issue with no labels is the same value as
// one loaded from a file — the store's own parser yields nothing, not an empty
// list, and two spellings of "none" is a comparison bug waiting. The same goes
// for the two below.
func LabelNames(p *sdk.Issue) []string {
var out []string
for _, l := range p.Labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
// AssigneeLogins are a payload's assignees, as logins.
//
// 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.
func AssigneeLogins(p *sdk.Issue) []string {
var out []string
for _, a := range p.Assignees {
if a != nil {
out = append(out, a.UserName)
}
}
return out
}
// MilestoneTitle is a payload's milestone title, or "" when it has none. The
// domain carries the title; the id exists only long enough to be sent back.
func MilestoneTitle(p *sdk.Issue) string {
if p.Milestone == nil {
return ""
}
return p.Milestone.Title
}
// Stamp is how a tracker timestamp is written into an issue's metadata, and ""
// for a time the payload did not carry.
//
// The zero time is not a date: an issue whose `updated_at` was absent would
// otherwise be stamped `0001-01-01`, which reads as a fact and is not one.
//
// RFC3339 both ways. These values are written into a file, compared as opaque
// strings and handed back; the SDK parses them into a time.Time on the way in,
// so something has to spell them out again, and the format Gitea sends is the
// format they go back out in. What was true when this was a string end to end —
// that no round trip could change the spelling — is not any more: a timestamp
// with a fraction of a second in it comes back without one.
func Stamp(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// NumbersInBody is every `#N` referenced from the body's dependency sections.
// Used only to seed `depends:` on the first pull — after that the metadata
// field is the graph and the prose is prose.
@@ -188,19 +234,26 @@ func MergeCheckboxState(remoteBody, localBody string) string {
// RenderComments flattens a comment thread to markdown. Read-only: nothing
// writes it back, which is why it may be as lossy as a reader needs.
func RenderComments(comments []wire.Comment) string {
func RenderComments(comments []*sdk.Comment) string {
var out []string
for _, c := range comments {
day := c.CreatedAt
if c == nil {
continue
}
day := Stamp(c.Created)
if len(day) > 10 {
day = day[:10]
}
who := ""
if c.Poster != nil {
who = c.Poster.UserName
}
body := strings.TrimSpace(c.Body)
if body == "" {
body = "(empty)"
}
out = append(out,
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+c.User.Login+" — "+day,
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+who+" — "+day,
"", body, "")
}
return strings.Join(out, "\n")
+11 -8
View File
@@ -3,8 +3,9 @@ package mapping
import (
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
@@ -43,20 +44,22 @@ func LabelColor(name string) string {
// LabelSpecs is the request body for each name, in the order given.
//
// A wire.LabelRequest and not a shape of this package's own: it is field for
// field what a label create takes, and a second spelling of it would mean the
// bootstrap command copying four fields across on its way to the transport.
// The SDK's own CreateLabelOption and not a shape of this package's own: it is
// field for field what a label create takes, and a second spelling of it would
// mean the bootstrap command copying four fields across on its way to the
// transport. It is what an EDIT is built from too — see Client.EditLabel — so
// one value says what a label should be, whether or not it exists yet.
// Exclusivity and meaning come from the domain taxonomy; only the color is
// decided here.
//
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
// its plan in that order — a map would shuffle the plan on every run and make
// two identical runs look like different ones.
func LabelSpecs(names []string) []wire.LabelRequest {
func LabelSpecs(names []string) []sdk.CreateLabelOption {
ns := exclusiveNamespaces()
out := make([]wire.LabelRequest, 0, len(names))
out := make([]sdk.CreateLabelOption, 0, len(names))
for _, name := range names {
out = append(out, wire.LabelRequest{
out = append(out, sdk.CreateLabelOption{
Name: name,
Color: LabelColor(name),
Description: typeMeaning(name),
@@ -72,7 +75,7 @@ func LabelSpecs(names []string) []wire.LabelRequest {
// Derived from the domain's own list rather than restated: add a type over in
// the taxonomy and the next bootstrap creates it, with no line changing here
// except the color it is painted with.
func CanonicalLabelSpecs() []wire.LabelRequest { return LabelSpecs(issue.CanonicalLabels()) }
func CanonicalLabelSpecs() []sdk.CreateLabelOption { return LabelSpecs(issue.CanonicalLabels()) }
// exclusiveNamespaces are the namespaces at most one label may come from, read
// off the canonical set rather than listed again — the domain publishes exactly
+84 -16
View File
@@ -1,26 +1,35 @@
package mapping
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// sdkPath is the one third-party import this package is allowed: the payload
// shapes it translates to and from.
const sdkPath = "code.gitea.io/sdk/gitea"
// The bridge translates values and nothing else: no network, no filesystem, no
// clock, no configuration. Every one of those is a caller's to supply, which is
// what lets this package be reasoned about and tested without a Gitea anywhere.
//
// Two imports and no more: internal/issue for what an issue is, and
// internal/wire for the shapes on the other side. wire is allowed precisely
// because it is inert — shapes and identifiers over the standard library, with
// a layering test of its own — so naming a payload here costs nothing and
// reaches nowhere.
// THE RULE THIS TEST USED TO MAKE was stronger and is no longer true. The
// shapes lived in internal/wire, which imported the standard library and
// nothing else, so "the bridge cannot reach a transport" held by construction:
// there was nothing in its dependency graph that could open a socket. The SDK's
// types come with the SDK's client attached, so the graph now contains an HTTP
// client whatever this package does with it — and a test that claimed otherwise
// would be a test that lies.
//
// DIRECT imports, not the dependency walk internal/issue does. The domain
// reaches os through internal/project and that is the domain's business; what
// this test is about is what this package itself reaches for. A transport that
// grew a helper here — or a lookup that quietly opened a config file — is what
// it catches.
// So it asserts the part that survives, which is also the part that catches a
// real mistake: what THIS package reaches for. DIRECT imports, not the
// dependency walk internal/issue does — the domain reaches os through
// internal/project and that is the domain's business. A transport that grew a
// helper here, or a lookup that quietly opened a config file, is what this
// catches, and it still fails on `os`, on `net/http` and on internal/gitea.
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
@@ -28,19 +37,78 @@ func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
"os": "a pure function reads no file and no environment",
"os/exec": "nothing here shells out",
"io/ioutil": "a pure function reads no file",
"time": "the clock is the caller's; a timestamp arrives as a string",
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
}
allowed := map[string]bool{
sdkPath: true,
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue": true,
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire": true,
}
for _, dep := range directImports(t) {
if why, bad := forbidden[dep]; bad {
t.Errorf("mapping imports %s — %s", dep, why)
continue
}
// A standard-library import path has no dot in its first element,
// because it has no domain name in front of it. Everything else has to
// be named above: one third party is a decision, two is a habit.
first, _, _ := strings.Cut(dep, "/")
if strings.Contains(first, ".") && !allowed[dep] {
t.Errorf("mapping imports %s — the only payload vocabulary here is %s", dep, sdkPath)
}
}
}
// The clock is the caller's, and `time` alone can no longer say so: the SDK
// hands over a time.Time, so this package imports the package to format one
// back into the string an issue file holds. What it must never do is ASK what
// time it is — a `synced:` stamped here would be stamped at translation rather
// than at the write it describes, and two issues pushed in one run would carry
// two different times for one run.
//
// The source, then, rather than the import graph: the difference between
// formatting a timestamp and having a clock is not visible in `go list`.
func TestTheBridgeHasNoClock(t *testing.T) {
for _, path := range sourceFiles(t) {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, banned := range []string{"time.Now(", "time.Since(", "time.Until("} {
if strings.Contains(string(raw), banned) {
t.Errorf("%s calls %s) — the clock belongs to the caller", filepath.Base(path), banned)
}
}
}
}
func directImports(t *testing.T) []string {
t.Helper()
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("mapping imports %s — %s", dep, why)
}
}
return strings.Fields(string(out))
}
// sourceFiles is this package's own .go files, tests excluded: a test may look
// at a clock, and one of them does.
func sourceFiles(t *testing.T) []string {
t.Helper()
out, err := exec.Command("go", "list", "-f", `{{range .GoFiles}}{{.}}
{{end}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
var paths []string
for _, name := range strings.Fields(string(out)) {
paths = append(paths, name)
}
if len(paths) == 0 {
t.Fatal("go list named no source files — this test would pass on an empty package")
}
return paths
}
+16 -8
View File
@@ -1,5 +1,5 @@
// Package mapping is md <-> Gitea JSON. The whole translation, and only the
// translation.
// Package mapping is md <-> Gitea's payloads. The whole translation, and only
// the translation.
//
// Pure functions: no network, no filesystem, no flags, no clock. Give it a
// payload and it hands back a domain issue; give it an issue and it hands back
@@ -7,13 +7,21 @@
// tested without a Gitea anywhere, and it is the one package to open when the
// two representations disagree.
//
// Direction of knowledge: this package imports the domain and the protocol
// (internal/wire), and nothing imports it but the command layer. The domain
// never imports it, and TestDomainDependsOnNothing over in internal/issue fails
// the moment it does; the transport never imports it either, and
// Direction of knowledge: this package imports the domain and the Gitea SDK,
// and nothing imports it but the command layer. The domain never imports it,
// and TestDomainDependsOnNothing over in internal/issue fails the moment it
// does; the transport never imports it either, and
// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both
// sides speak wire's shapes, which is what lets the two meet without either one
// reaching into the other.
// sides speak the SDK's shapes, which is what lets the two meet without either
// one reaching into the other.
//
// WHAT THAT COSTS, SAID OUT LOUD. The shapes used to be internal/wire's, a
// package that imported the standard library and nothing else, so "the bridge
// cannot reach the network" was a fact about the import graph. code.gitea.io/
// sdk/gitea carries an HTTP client, so it is not any more. What is still true
// is that nothing HERE does I/O, and layering_test.go asserts the version of
// the rule that can still be checked: no os, no net/http, no transport, no
// configuration, no clock, and no third party but the SDK.
//
// What crosses the boundary, and what does not:
//
+102 -63
View File
@@ -5,6 +5,9 @@ import (
"reflect"
"strings"
"testing"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
@@ -14,6 +17,17 @@ import (
// handle in `gitea:` is a key, and a key is a repository and a number.
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
// when parses a tracker timestamp the way the SDK hands one over, so a fixture
// can be written in the spelling Gitea actually sends.
func when(t *testing.T, s string) time.Time {
t.Helper()
got, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("parsing %q: %v", s, err)
}
return got
}
// A file exactly as the store holds it: domain fields, then the sync fields the
// domain carries and never reads.
const stored = `---
@@ -58,51 +72,60 @@ func roundTripOptions() RequestOptions {
// preserved comes back, and the body comes back byte for byte.
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
local := issue.FromText(stored, "wire-sqlc-appclick")
req := ToRequest(local, roundTripOptions())
req := ToCreate(local, roundTripOptions())
if req.Title == nil || *req.Title != local.Title {
t.Errorf("title = %v, want %q", req.Title, local.Title)
if req.Title != local.Title {
t.Errorf("title = %q, want %q", req.Title, local.Title)
}
if req.Body == nil {
if req.Body == "" {
t.Fatal("the request carries no body — a create would file an empty issue")
}
if got := IDInBody(*req.Body); got != local.ID {
if got := IDInBody(req.Body); got != local.ID {
t.Errorf("the request body does not claim the slug: %q", got)
}
if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) {
if got := StripIDMarker(req.Body); got != strings.TrimSpace(local.Body) {
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
got, strings.TrimSpace(local.Body))
}
if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) {
if want := []int64{11, 12}; !reflect.DeepEqual(req.Labels, want) {
t.Errorf("labels = %v, want %v", req.Labels, want)
}
if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) {
if want := []string{"naudachu"}; !reflect.DeepEqual(req.Assignees, want) {
t.Errorf("assignees = %v, want %v", req.Assignees, want)
}
if req.Milestone == nil || *req.Milestone != 5 {
if req.Milestone != 5 {
t.Errorf("milestone = %v, want 5", req.Milestone)
}
if req.State == nil || *req.State != "open" {
t.Errorf("state = %v", req.State)
if req.Ref != "feat/wire-sqlc" {
t.Errorf("ref = %q — branch: is a sync field and must ride along", req.Ref)
}
if req.Ref == nil || *req.Ref != "feat/wire-sqlc" {
t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref)
// An edit is the other half of the same translation, and the two must not
// disagree about the issue they describe.
edit := ToEdit(local, roundTripOptions())
if edit.Body == nil || *edit.Body != req.Body || edit.Title != req.Title {
t.Errorf("a create and an edit describe different issues: %q / %v", edit.Title, edit.Body)
}
if edit.State == nil || *edit.State != sdk.StateOpen {
t.Errorf("state = %v", edit.State)
}
if edit.Ref == nil || *edit.Ref != "feat/wire-sqlc" {
t.Errorf("ref = %v — branch: is a sync field and must ride along", edit.Ref)
}
// What the tracker hands back is the body it was given, plus its own
// bookkeeping.
echo := &wire.Issue{
Number: 42,
Title: *req.Title,
Body: *req.Body,
State: "open",
echo := &sdk.Issue{
Index: 42,
Title: req.Title,
Body: req.Body,
State: sdk.StateOpen,
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
Ref: *req.Ref,
Updated: when(t, "2026-08-09T18:24:01Z"),
Ref: req.Ref,
Comments: 3,
Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}},
Assignees: []wire.User{{Login: "naudachu"}},
Milestone: &wire.Milestone{ID: 5, Title: "v0.2"},
Labels: []*sdk.Label{{Name: "type/task"}, {Name: "tech/sql"}},
Assignees: []*sdk.User{{UserName: "naudachu"}},
Milestone: &sdk.Milestone{ID: 5, Title: "v0.2"},
}
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
IDForNumber: map[int]string{7: "migrate-schema"},
@@ -153,59 +176,72 @@ func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
// And the strongest form of "no churn": pushing what came back sends
// exactly what was sent the first time.
if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
if again := ToCreate(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
}
}
// The other shape an issue comes in: nothing scheduled, nobody assigned.
//
// On an EDIT that is a statement and not an absence, which is why this asserts
// on the bytes. Gitea reads a null as "no opinion" and a value as "make it
// this", and the SDK's edit body sends every key — so `"assignees":null` is the
// spelling that leaves the tracker's assignees alone, and `"assignees":[]`
// would clear them.
func TestNoMilestoneAndNoAssignees(t *testing.T) {
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}})
if req.Assignees != nil {
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees)
opt := RequestOptions{LabelIDs: map[string]int64{"type/task": 11}}
edit := ToEdit(local, opt)
if edit.Assignees != nil {
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", edit.Assignees)
}
if req.Milestone != nil {
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone)
if edit.Milestone != nil {
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", edit.Milestone)
}
raw, err := json.Marshal(req)
raw, err := json.Marshal(edit)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} {
if strings.Contains(body, key) {
t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body)
for _, key := range []string{`"assignees":null`, `"milestone":null`, `"state":null`, `"ref":null`} {
if !strings.Contains(body, key) {
t.Errorf("%s is not in the edit body; anything else there overwrites what the tracker holds: %s", key, body)
}
}
// A resolved-but-empty label set is the opposite statement and must be sent.
if !strings.Contains(body, `"labels":[11]`) {
t.Errorf("labels missing from %s", body)
}
empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(empty), `"labels":[]`) {
t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty)
}
silent, err := json.Marshal(ToRequest(local, RequestOptions{}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(silent), `"labels"`) {
t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent)
// The title is the one field of an edit that is not a pointer. Gitea reads
// an empty one as "leave it alone" too, but this issue has a title and it
// has to go up.
if !strings.Contains(body, `"title":"A lone issue"`) {
t.Errorf("the title is missing from %s", body)
}
back, unresolved := FromPayload(&wire.Issue{
Number: 9,
// A create is the opposite: there is nothing on the tracker's side to
// overwrite, so the resolved label ids are sent as they stand.
created, err := json.Marshal(ToCreate(local, opt))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(created), `"labels":[11]`) {
t.Errorf("labels missing from %s", created)
}
// A resolved label set that matched nothing is still an answer, and it is
// the same answer a PUT sends at an issue that already exists.
if got := LabelIDsFor(local, RequestOptions{LabelIDs: map[string]int64{}}); got == nil || len(got) != 0 {
t.Errorf("a resolved label set that matched nothing must be an empty list, got %v", got)
}
if got := LabelIDsFor(local, RequestOptions{}); got != nil {
t.Errorf("a caller that resolved no ids has no opinion about labels, got %v", got)
}
back, unresolved := FromPayload(&sdk.Issue{
Index: 9,
Title: "A lone issue",
Body: WithIDMarker("## Summary\nОдин.", "lone"),
State: "open",
State: sdk.StateOpen,
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
@@ -231,7 +267,7 @@ func TestNoMilestoneAndNoAssignees(t *testing.T) {
// a made-up slug is an edge to a file that does not exist.
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body},
back, unresolved := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body},
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
@@ -247,7 +283,7 @@ func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
body := "## Summary\nx\n\n## Depends on\n- #7\n"
back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
back, _ := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
PayloadOptions{
IDForNumber: map[int]string{7: "seven", 9: "nine"},
ExtraNumbers: []int{7, 9},
@@ -352,10 +388,10 @@ func TestNumberOf(t *testing.T) {
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
local := &issue.Issue{ID: "x", Origin: issue.Local}
ApplyRemote(local, &wire.Issue{
Number: 42,
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
UpdatedAt: "2026-08-09T18:24:01Z",
ApplyRemote(local, &sdk.Issue{
Index: 42,
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
Updated: when(t, "2026-08-09T18:24:01Z"),
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
if local.IsLocal() {
@@ -368,13 +404,16 @@ func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
}
}
// A thread is flattened for a reader, so it may be as lossy as a reader needs —
// but a payload that carries no date and no author still renders, because a
// comment that is there is worth showing whatever the tracker left out of it.
func TestRenderComments(t *testing.T) {
got := RenderComments([]wire.Comment{
{ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "},
{ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""},
got := RenderComments([]*sdk.Comment{
{ID: 1, Poster: &sdk.User{UserName: "naudachu"}, Created: when(t, "2026-08-09T18:24:01Z"), Body: " привет "},
{ID: 2, Poster: nil, Body: ""},
})
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
"## comment 2 — bot — \n\n(empty)\n"
"## comment 2 — — \n\n(empty)\n"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
sdk "code.gitea.io/sdk/gitea"
)
func TestIDInBodyReadsBothSpellings(t *testing.T) {
@@ -44,7 +44,7 @@ func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
}
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
iss, _ := FromPayload(&sdk.Issue{Index: 42, Title: "Wire sqlc", Body: inTracker},
id, tea, PayloadOptions{})
if strings.Contains(iss.Body, "tea:id") {
t.Errorf("the old marker reached the local copy: %q", iss.Body)
@@ -55,7 +55,7 @@ func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
// And the next push rewrites it into the current spelling, without ever
// having two.
up := *ToRequest(iss, RequestOptions{}).Body
up := ToCreate(iss, RequestOptions{}).Body
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
t.Errorf("the marker was not rewritten: %q", up)
}
+86 -40
View File
@@ -4,6 +4,8 @@ import (
"slices"
"strings"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
@@ -17,21 +19,24 @@ import (
// here — this package never learns which repository it is translating for
// beyond the name it is handed.
type RequestOptions struct {
// LabelIDs is name -> id for the labels this repository holds. A nil map
// leaves `labels` out of the request; a non-nil one sends the list, empty
// included, and a label the repository does not have is silently left off
// rather than failing the write — an unknown label is a bootstrap that has
// not run, not a reason to lose the issue.
// LabelIDs is name -> id for the labels this repository holds. A label the
// repository does not have is silently left off rather than failing the
// write — an unknown label is a bootstrap that has not run, not a reason to
// lose the issue.
//
// It is read by ToCreate and ignored by ToEdit, because Gitea's edit
// endpoint carries no labels at all. Changing them on an issue that exists
// is a PUT of its own; push makes it.
LabelIDs map[string]int64
// MilestoneID is the resolved milestone. nil leaves the key out, which on
// an edit means "leave whatever is attached alone".
MilestoneID *int64
// IncludeState sends `state`. An edit that means to open or close says so;
// a create takes the tracker's default.
// IncludeState sends `state` on an edit. An edit that means to open or
// close says so; a create takes the tracker's default.
IncludeState bool
}
// ToRequest is the request body for creating or editing an issue.
// ToCreate is the body of a create.
//
// The prose is sent verbatim — see the package doc on why slugs in
// `## Depends on` are not rewritten to `#N`. The one addition is the id marker,
@@ -39,59 +44,100 @@ type RequestOptions struct {
// deleted the local file. FromPayload takes it straight back off, so the body
// still round-trips byte for byte.
//
// A create needs a title and a body, so those two are always filled. Every
// other key is left out unless the caller has an opinion about it: on a PATCH
// an absent key leaves the tracker's value alone, and a present one overwrites
// it — see wire.IssueRequest for what each of them clears when it is sent
// empty.
func ToRequest(i *issue.Issue, opt RequestOptions) *wire.IssueRequest {
r := &wire.IssueRequest{
Title: wire.Set(i.Title),
Body: wire.Set(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
}
if opt.LabelIDs != nil {
ids := []int64{}
for _, name := range i.Labels {
if id, ok := opt.LabelIDs[name]; ok {
ids = append(ids, id)
}
}
r.Labels = &ids
}
// Copied, so the request body and the issue it came from cannot alias one
// slice: whatever a caller does to either afterwards is not a change to
// what was sent.
if len(i.Assignees) > 0 {
r.Assignees = wire.Set(slices.Clone(i.Assignees))
// Every field of a create is a value and every one of them is sent, which is
// safe in a way an edit is not: there is nothing on the tracker's side yet for
// an empty field to overwrite.
func ToCreate(i *issue.Issue, opt RequestOptions) sdk.CreateIssueOption {
out := sdk.CreateIssueOption{
Title: i.Title,
Body: WithIDMarker(strings.TrimSpace(i.Body), i.ID),
// Copied, so the request body and the issue it came from cannot alias
// one slice: whatever a caller does to either afterwards is not a
// change to what was sent.
Assignees: slices.Clone(i.Assignees),
Labels: LabelIDsFor(i, opt),
Ref: strings.TrimSpace(i.Extra[BranchKey]),
}
if opt.MilestoneID != nil {
r.Milestone = opt.MilestoneID
out.Milestone = *opt.MilestoneID
}
return out
}
// ToEdit is the body of an edit.
//
// EVERY FIELD IS A POINTER AND MOST OF THEM ARE LEFT NIL, because Gitea reads
// an absent value as "no opinion" and a present one as "make it this", and the
// difference is not academic: an empty `ref` CLEARS the branch an issue is
// pinned to, and an empty `assignees` clears its assignees. A caller meaning to
// change only the state would do both by accident with plain zero values.
//
// The exception is the title, which the SDK spells as a plain string and always
// sends. Gitea reads an EMPTY title as "leave it alone" — it is the one field
// of an edit where the zero value already means no opinion — so a caller that
// wants to rename says so by filling it, and `kettle close` stays a change of
// state and nothing else.
func ToEdit(i *issue.Issue, opt RequestOptions) sdk.EditIssueOption {
out := sdk.EditIssueOption{
Title: i.Title,
Body: sdk.OptionalString(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
Milestone: opt.MilestoneID,
}
if len(i.Assignees) > 0 {
out.Assignees = slices.Clone(i.Assignees)
}
if opt.IncludeState {
r.State = wire.Set(i.State)
state := sdk.StateType(i.State)
out.State = &state
}
// An empty `branch:` is "no opinion", not "no branch": sending ref="" would
// clear whatever is set on the Gitea side, so the key is left out instead.
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
r.Ref = wire.Set(branch)
out.Ref = sdk.OptionalString(branch)
}
return r
return out
}
// LabelIDsFor is the ids this issue's labels resolve to, in the issue's own
// order — what a create sends, and what a push PUTs at an issue whose labels
// have to be made to match afterwards.
//
// One answer to one question, exported so those two cannot derive it
// differently: an edit carries no labels at all, so every issue that already
// exists gets its label set through a PUT, and a PUT that disagreed with what a
// create would have sent would make a pushed issue and a re-pushed one two
// different things.
//
// nil for a caller that resolved no ids, and an empty list — never nil — for
// one that resolved some and matched none. The difference is a statement to the
// tracker: `[]` clears every label on the issue.
func LabelIDsFor(i *issue.Issue, opt RequestOptions) []int64 {
if opt.LabelIDs == nil {
return nil
}
ids := []int64{}
for _, name := range i.Labels {
if id, ok := opt.LabelIDs[name]; ok {
ids = append(ids, id)
}
}
return ids
}
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
// write. Mutates and returns it; `origin` is the one domain field this touches,
// and it touches it because "this work exists somewhere else now" is exactly
// what has just become true.
func ApplyRemote(i *issue.Issue, p *wire.Issue, repo wire.Repo, synced string) *issue.Issue {
func ApplyRemote(i *issue.Issue, p *sdk.Issue, repo wire.Repo, synced string) *issue.Issue {
if i.Extra == nil {
i.Extra = map[string]string{}
}
i.Origin = Origin
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: p.Number}.String()
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: int(p.Index)}.String()
i.Extra[URLKey] = p.HTMLURL
i.Extra[SyncedKey] = synced
if p.UpdatedAt != "" {
i.Extra[RemoteUpdatedKey] = p.UpdatedAt
if stamp := Stamp(p.Updated); stamp != "" {
i.Extra[RemoteUpdatedKey] = stamp
}
return i
}
+22
View File
@@ -1,3 +1,25 @@
// Package wire is the protocol's identifiers: the way this project addresses
// one repository and one issue, and nothing else.
//
// The JSON shapes used to live here too, because the transport and the bridge
// both had to name a Gitea issue and neither may import the other. They are
// code.gitea.io/sdk/gitea's now — one vocabulary, maintained by the people who
// maintain the server — and the reason the shapes were lifted out of the
// transport in the first place still holds: two structs for one payload drift,
// and the first field only one of them learns is a field the other silently
// drops.
//
// WHAT THE SDK HAS NO ANSWER FOR IS ADDRESSING. `42`, `#42`, `owner/repo#42`
// and an issue URL are four spellings of one thing, all four are what somebody
// has in hand, and the SDK takes an owner, a name and an int64 — it never
// parses. So the parsing stays, and so does the pair of types it produces: Repo
// and Key, the values that go into the ledger, into the `gitea:` metadata field
// and into every receipt.
//
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
// filesystem, no configuration, no SDK, and above all not internal/issue. An
// identifier that reached for any of those would drag every user of it into
// that layer. layering_test.go fails the moment it stops being true.
package wire
import (
+9 -9
View File
@@ -6,12 +6,12 @@ import (
"testing"
)
// The protocol is shared by two layers that may not import each other, and it
// can only be shared because it reaches for nothing itself: no domain, no
// configuration, no path resolution, no third party. One import from any of
// those would drag every user of this package into that layer — which is the
// whole reason these shapes were lifted out of the transport rather than left
// there for the bridge to reimplement.
// The identifiers are shared by two layers that may not import each other, and
// they can only be shared because they reach for nothing themselves: no domain,
// no configuration, no path resolution, no third party — the Gitea SDK
// included. One import from any of those would drag every user of this package
// into that layer, which is the whole reason an issue key is parsed here rather
// than wherever it is first needed.
//
// The dependency walk, so a helper pulled in three packages deep is caught as
// the same violation as one written at the top of a file.
@@ -28,7 +28,7 @@ func TestWireDependsOnNothing(t *testing.T) {
// because it has no domain name in front of it.
first, _, _ := strings.Cut(dep, "/")
if strings.Contains(first, ".") {
t.Errorf("the protocol imports %s — these are shapes and identifiers, and nothing else belongs here", dep)
t.Errorf("the protocol imports %s — these are identifiers, and nothing else belongs here", dep)
}
}
}
@@ -43,10 +43,10 @@ func TestWireReachesNeitherTheNetworkNorTheDisk(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
"net": "an HTTP call belongs in the transport",
"os": "a shape reads no file and no environment",
"os": "an identifier reads no file and no environment",
"os/exec": "nothing here shells out",
"io": "nothing here is a stream",
"time": "a timestamp crosses as the string the tracker sent",
"time": "an address has no timestamp in it",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
-59
View File
@@ -1,59 +0,0 @@
package wire
// The bodies that go up, and the shorthand that fills them.
//
// The omitted keys carry meaning of their own on a PATCH: a key that is absent
// leaves the tracker's value alone, and a key that is present overwrites it. So
// "no opinion" and "empty" must not marshal the same way, which is what every
// pointer and every omitempty below is for.
// IssueRequest is the body of a create or an edit.
//
// Every field is a pointer because Gitea reads an absent key as "no opinion"
// and a present one as "make it this", and the difference is not academic: an
// empty `ref` CLEARS the branch an issue is pinned to, and an empty `labels`
// clears its labels. A caller meaning to change only the state would do both by
// accident with plain zero values. Set fills a field; leaving it nil leaves the
// tracker's copy alone.
type IssueRequest struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
// Labels is a pointer because `[]` is a statement — it clears every label
// on the issue — while a caller that has not resolved label ids at all has
// no business making it. A plain slice with omitempty cannot say both.
Labels *[]int64 `json:"labels,omitempty"`
Assignees *[]string `json:"assignees,omitempty"`
// Milestone is a pointer for the same reason, and because 0 is Gitea's
// "detach from its milestone" — a value somebody may well mean.
Milestone *int64 `json:"milestone,omitempty"`
State *string `json:"state,omitempty"`
Ref *string `json:"ref,omitempty"`
}
// LabelRequest is the body of a label create or edit — everything a repository
// needs to make one label.
//
// Value fields, not pointers, and every one of them is sent: Gitea 1.26 patches
// only what it is given, but an older server reads an absent field as empty and
// blanks it. A label edit is rare enough that sending the unchanged name and
// description along costs nothing and removes a way to lose them.
//
// It goes up as a request body of its own because `tea labels create` could not
// set `exclusive` — the flag that makes `type/*` behave like a single choice —
// which is the whole reason label creation went through the API rather than a
// CLI wrapper.
//
// What a label MEANS — which namespaces are exclusive, what colour a severity
// is — is not decided here. This is the shape; the taxonomy is the domain's and
// the palette is the bridge's.
type LabelRequest struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Set is a pointer to v, for filling the optional fields of a request. Gitea
// reads an absent key as "no opinion" and a present one as "make it this", so
// those fields are pointers and this is the shorthand that fills them.
func Set[T any](v T) *T { return &v }
-155
View File
@@ -1,155 +0,0 @@
// 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"`
}