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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user