1239fdee70
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>
115 lines
4.5 KiB
Go
115 lines
4.5 KiB
Go
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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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",
|
|
"net": "an HTTP call belongs in the transport",
|
|
"os": "a pure function reads no file and no environment",
|
|
"os/exec": "nothing here shells out",
|
|
"io/ioutil": "a pure function reads no file",
|
|
"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)
|
|
}
|
|
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
|
|
}
|