[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate... #3

Closed
Virgil wants to merge 1 commit from agent/dx-audit-and-fix--1--review-claude-md into main
4 changed files with 188 additions and 2 deletions

View file

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Full-coverage Go client for the Forgejo API (~450 endpoints). Uses a generic `Resource[T,C,U]` pattern for CRUD operations and types generated from `swagger.v1.json`.
**Module:** `forge.lthn.ai/core/go-forge` (Go 1.26, zero dependencies)
**Module:** `forge.lthn.ai/core/go-forge` (Go 1.26, deps: `go-io`, `go-log`)
## Build & Test
@ -50,7 +50,9 @@ The library is a flat package (`package forge`) with a layered design:
## Coding Standards
- All methods accept `context.Context` as first parameter
- Errors wrapped as `*APIError` with StatusCode, Message, URL; use `IsNotFound()`, `IsForbidden()`, `IsConflict()` helpers
- **HTTP errors** wrapped as `*APIError` with StatusCode, Message, URL; use `IsNotFound()`, `IsForbidden()`, `IsConflict()` helpers
- **Internal errors** must use `coreerr.E()` from `go-log` (aliased as `coreerr`), never `fmt.Errorf` or `errors.New`
- **File I/O** must use `go-io` (aliased as `coreio`), not `os.ReadFile`/`os.WriteFile`/`os.MkdirAll`
- UK English in comments (organisation, colour, etc.)
- `Co-Authored-By: Virgil <virgil@lethean.io>` in commits
- `Client` uses functional options pattern (`WithHTTPClient`, `WithUserAgent`)

View file

@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@ -136,3 +137,128 @@ func TestClient_Good_Options(t *testing.T) {
t.Errorf("got user agent=%q", c.userAgent)
}
}
func TestAPIError_Good_Error(t *testing.T) {
err := &APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"}
got := err.Error()
if !strings.Contains(got, "404") || !strings.Contains(got, "not found") || !strings.Contains(got, "/api/v1/repos/x/y") {
t.Errorf("Error() = %q, want status code, message, and URL", got)
}
}
func TestClient_Good_IsConflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"message": "already exists"})
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
err := c.Post(context.Background(), "/api/v1/repos", nil, nil)
if !IsConflict(err) {
t.Fatalf("expected conflict, got %v", err)
}
}
func TestClient_Good_WithHTTPClient(t *testing.T) {
custom := &http.Client{}
c := NewClient("https://forge.lthn.ai", "tok", WithHTTPClient(custom))
if c.httpClient != custom {
t.Error("WithHTTPClient did not set custom client")
}
}
func TestClient_Good_RateLimit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", "99")
w.Header().Set("X-RateLimit-Reset", "1700000000")
json.NewEncoder(w).Encode(map[string]string{"login": "test"})
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
var out map[string]string
if err := c.Get(context.Background(), "/api/v1/user", &out); err != nil {
t.Fatal(err)
}
rl := c.RateLimit()
if rl.Limit != 100 {
t.Errorf("got Limit=%d, want 100", rl.Limit)
}
if rl.Remaining != 99 {
t.Errorf("got Remaining=%d, want 99", rl.Remaining)
}
if rl.Reset != 1700000000 {
t.Errorf("got Reset=%d, want 1700000000", rl.Reset)
}
}
func TestClient_Bad_IsForbidden(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{"message": "forbidden"})
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
err := c.Get(context.Background(), "/api/v1/admin", nil)
if !IsForbidden(err) {
t.Fatalf("expected forbidden, got %v", err)
}
}
func TestClient_Bad_ParseErrorPlainText(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("bad gateway"))
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
err := c.Get(context.Background(), "/api/v1/user", nil)
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
}
if apiErr.StatusCode != 502 {
t.Errorf("got status=%d, want 502", apiErr.StatusCode)
}
if apiErr.Message != "bad gateway" {
t.Errorf("got message=%q, want %q", apiErr.Message, "bad gateway")
}
}
func TestClient_Bad_ParseErrorEmptyBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
err := c.Get(context.Background(), "/api/v1/user", nil)
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
}
if apiErr.StatusCode != 503 {
t.Errorf("got status=%d, want 503", apiErr.StatusCode)
}
// Empty body falls back to http.StatusText
if apiErr.Message != "Service Unavailable" {
t.Errorf("got message=%q, want %q", apiErr.Message, "Service Unavailable")
}
}
func TestClient_Bad_IsNotFoundNonAPIError(t *testing.T) {
if IsNotFound(errors.New("random error")) {
t.Error("IsNotFound should return false for non-APIError")
}
if IsForbidden(errors.New("random error")) {
t.Error("IsForbidden should return false for non-APIError")
}
if IsConflict(errors.New("random error")) {
t.Error("IsConflict should return false for non-APIError")
}
}

View file

@ -72,3 +72,14 @@ func TestRepoService_Good_Fork(t *testing.T) {
t.Error("expected fork=true")
}
}
func TestForge_Good_Client(t *testing.T) {
f := NewForge("https://forge.lthn.ai", "tok")
c := f.Client()
if c == nil {
t.Fatal("Client() returned nil")
}
if c.baseURL != "https://forge.lthn.ai" {
t.Errorf("got baseURL=%q", c.baseURL)
}
}

View file

@ -153,3 +153,50 @@ func TestResource_Good_ListAll(t *testing.T) {
t.Errorf("got %d items, want 3", len(items))
}
}
func TestResource_Good_Iter(t *testing.T) {
page := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page++
w.Header().Set("X-Total-Count", "3")
if page == 1 {
json.NewEncoder(w).Encode([]testItem{{1, "a"}, {2, "b"}})
} else {
json.NewEncoder(w).Encode([]testItem{{3, "c"}})
}
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos")
var items []testItem
for item, err := range res.Iter(context.Background(), nil) {
if err != nil {
t.Fatal(err)
}
items = append(items, item)
}
if len(items) != 3 {
t.Errorf("got %d items, want 3", len(items))
}
}
func TestResource_Bad_IterError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"message": "fail"})
}))
defer srv.Close()
c := NewClient(srv.URL, "tok")
res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos")
for _, err := range res.Iter(context.Background(), nil) {
if err == nil {
t.Fatal("expected error from iterator")
}
return // got expected error
}
t.Fatal("iterator yielded nothing")
}