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
+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
}