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

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

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

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

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

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

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

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

144 lines
5.5 KiB
Go

package mapping
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"
)
// domain -> Gitea.
// RequestOptions are what the transport resolved before the call: label names
// are ids by then, and a milestone title is a number.
//
// Both are lookups against one repository, which is why they cannot be done
// 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 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` on an edit. An edit that means to open or
// close says so; a create takes the tracker's default.
IncludeState bool
}
// 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,
// prepended (never appended) so the tracker remembers the slug after push has
// deleted the local file. FromPayload takes it straight back off, so the body
// still round-trips byte for byte.
//
// 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 {
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 {
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 != "" {
out.Ref = sdk.OptionalString(branch)
}
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 *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: int(p.Index)}.String()
i.Extra[URLKey] = p.HTMLURL
i.Extra[SyncedKey] = synced
if stamp := Stamp(p.Updated); stamp != "" {
i.Extra[RemoteUpdatedKey] = stamp
}
return i
}