Files
marketplace/cli/internal/mapping/mapping.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

115 lines
5.6 KiB
Go

// 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
// a request body. That purity is the point — it can be reasoned about and
// 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 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 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:
//
// domain Gitea note
// ----------------------------------------------------------------------
// id (slug) body marker <!-- kettle:id … -->, first line of the
// tracker-side body; stripped out of the
// local copy — see marker.go
// title title verbatim, both ways
// body body verbatim up, verbatim down except the
// marker and checkbox state
// state state open/closed, the same vocabulary
// labels labels[] names both ways; ids only on write
// assignees assignees[] logins
// milestone milestone.title resolved to an id on write
// depends — slugs; #N is translated at this edge
// — number, html_url lands in Extra as gitea:/url:
// — ref Extra as branch:; push fills it from git
//
// `depends:` is the authoritative graph and is always slugs. The body's
// `## Depends on` section is human prose and is passed through UNCHANGED in
// both directions: a pull seeds `depends:` from the `#N` it finds there, and a
// push never rewrites what the author wrote. Deliberate — a translator that
// edits prose churns the body on every round trip.
//
// The ONE thing this package adds to a body is the id marker, and it does so
// because the slug has to survive a push: push deletes the local file, so the
// tracker has to be the thing that remembers what the issue was called here.
package mapping
import (
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// Origin is what this bridge writes into the domain's `origin:` field. The
// domain records that an issue exists somewhere else; only this layer knows
// where, and what the handle beside it means.
const Origin = "gitea"
// The sync-owned metadata fields, named once. Every one of them is bookkeeping
// about a tracker, which is why the domain carries them verbatim in
// Issue.Extra and never reads them — the format's ownership table draws the
// same line. A field spelled in three call sites is a field that gets renamed
// in two.
const (
// GiteaKey is the handle in the tracker: owner/repo#42, a wire.Key written
// out. Cross-repo on purpose — a number alone is only unique inside one
// repository, and an issue that has been moved, or a store that has ever
// pointed at two repositories, needs the answer to say which.
GiteaKey = "gitea"
// URLKey is the issue's web address, for a receipt a human can click.
URLKey = "url"
// SyncedKey is when this copy was last written from or to the tracker —
// how old the working copy is, and nothing more.
SyncedKey = "synced"
// RemoteUpdatedKey is the tracker's own updated_at.
RemoteUpdatedKey = "remote-updated"
// CommentsKey is how many comments the tracker holds, so a reader knows a
// thread exists without fetching it.
CommentsKey = "comments"
// BranchKey is Gitea's `ref` — the branch an issue is pinned to. Its value
// is a git branch name and means exactly `ref`, which is what makes it a
// sync field rather than a domain one.
BranchKey = "branch"
)
// RemoteKeyOf is the handle an issue carries, and whether it carries one at
// all.
//
// ok is false for anything that is not a handle: an empty field on a
// never-pushed issue, a line somebody hand-edited, a key written by a format
// that predates this one — and a bare `#42`, which names a number without the
// repository that makes it mean something. Callers act on ok rather than on a
// zero number, because "#0" and "not synced" would otherwise be the same
// answer.
func RemoteKeyOf(i *issue.Issue) (key wire.Key, ok bool) {
k, err := wire.ParseKey(i.Extra[GiteaKey])
if err != nil || k.Repo.Zero() {
return wire.Key{}, false
}
return k, true
}
// NumberOf is the Gitea number of an already-synced issue; ok is false for one
// that has never been pushed.
func NumberOf(i *issue.Issue) (number int, ok bool) {
k, ok := RemoteKeyOf(i)
return k.Number, ok
}