# go-forge Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a full-coverage Go client for the Forgejo API (450 endpoints) using a generic Resource[T,C,U] pattern and types generated from swagger.v1.json. **Architecture:** A code generator (`cmd/forgegen/`) parses Forgejo's Swagger 2.0 spec and emits typed Go structs. A generic `Resource[T,C,U]` provides List/Get/Create/Update/Delete for 411 CRUD endpoints. 18 service structs embed the generic resource and add 39 hand-written action methods. An HTTP client handles auth, pagination, rate limiting, and context.Context. **Tech Stack:** Go 1.25, `net/http`, `text/template`, generics, Swagger 2.0 (JSON) --- ## Context **This is a NEW repo** at `forge.lthn.ai/core/go-forge`. Create it locally at `/Users/snider/Code/go-forge`. **Extracted from:** `/Users/snider/Code/go-scm/forge/` (45 methods covering 10% of API). The config resolution pattern (env → file → flags) comes from there. **Swagger spec:** Download from `https://forge.lthn.ai/swagger.v1.json` — Swagger 2.0 format, 229 type definitions, 450 operations across 284 paths. Pin it at `testdata/swagger.v1.json`. **Forgejo version:** 10.0.3 (Gitea 1.22.0 compatible) **Dependencies:** None (pure `net/http`). Config uses `forge.lthn.ai/core/go` for `pkg/config` and `pkg/log` — same as go-scm. **Key insight:** 91% of endpoints are generic CRUD (List/Get/Create/Update/Delete). The generic `Resource[T,C,U]` pattern means each service is a struct definition + path constant + optional action methods. The code generator handles 229 type definitions. **Test command:** `go test ./...` from the repo root. **The forge remote for this repo will be:** `ssh://git@forge.lthn.ai:2223/core/go-forge.git` --- ## Wave 1: Foundation (Tasks 1-6) ### Task 1: Repo scaffolding + go.mod **Files:** - Create: `go.mod` - Create: `go.sum` (auto-generated) - Create: `doc.go` - Create: `testdata/swagger.v1.json` (downloaded) **Step 1: Create directory and initialise module** ```bash mkdir -p /Users/snider/Code/go-forge/testdata cd /Users/snider/Code/go-forge git init go mod init forge.lthn.ai/core/go-forge ``` **Step 2: Download and pin swagger spec** ```bash curl -s https://forge.lthn.ai/swagger.v1.json > testdata/swagger.v1.json ``` Verify: `python3 -c "import json; d=json.load(open('testdata/swagger.v1.json')); print(f'{len(d[\"definitions\"])} types, {len(d[\"paths\"])} paths')"` Expected: `229 types, 284 paths` **Step 3: Write doc.go** ```go // Package forge provides a full-coverage Go client for the Forgejo API. // // Usage: // // f := forge.NewForge("https://forge.lthn.ai", "your-token") // repos, err := f.Repos.List(ctx, forge.Params{"org": "core"}, forge.DefaultList) // // Types are generated from Forgejo's swagger.v1.json spec via cmd/forgegen/. // Run `go generate ./types/...` to regenerate after a Forgejo upgrade. package forge ``` **Step 4: Commit** ```bash git add -A git commit -m "feat: scaffold go-forge repo with pinned swagger spec Co-Authored-By: Virgil " ``` --- ### Task 2: HTTP Client **Files:** - Create: `client.go` - Create: `client_test.go` **Step 1: Write client tests** ```go package forge import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" ) func TestClient_Good_Get(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.Header.Get("Authorization") != "token test-token" { t.Errorf("missing auth header") } if r.URL.Path != "/api/v1/user" { t.Errorf("wrong path: %s", r.URL.Path) } json.NewEncoder(w).Encode(map[string]string{"login": "virgil"}) })) defer srv.Close() c := NewClient(srv.URL, "test-token") var out map[string]string err := c.Get(context.Background(), "/api/v1/user", &out) if err != nil { t.Fatal(err) } if out["login"] != "virgil" { t.Errorf("got login=%q", out["login"]) } } func TestClient_Good_Post(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } var body map[string]string json.NewDecoder(r.Body).Decode(&body) if body["name"] != "test-repo" { t.Errorf("wrong body: %v", body) } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "test-repo"}) })) defer srv.Close() c := NewClient(srv.URL, "test-token") body := map[string]string{"name": "test-repo"} var out map[string]any err := c.Post(context.Background(), "/api/v1/orgs/core/repos", body, &out) if err != nil { t.Fatal(err) } if out["name"] != "test-repo" { t.Errorf("got name=%v", out["name"]) } } func TestClient_Good_Delete(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := NewClient(srv.URL, "test-token") err := c.Delete(context.Background(), "/api/v1/repos/core/test") if err != nil { t.Fatal(err) } } func TestClient_Bad_ServerError(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": "internal error"}) })) defer srv.Close() c := NewClient(srv.URL, "test-token") err := c.Get(context.Background(), "/api/v1/user", nil) if err == nil { t.Fatal("expected error") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError, got %T", err) } if apiErr.StatusCode != 500 { t.Errorf("got status=%d", apiErr.StatusCode) } } func TestClient_Bad_NotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) })) defer srv.Close() c := NewClient(srv.URL, "test-token") err := c.Get(context.Background(), "/api/v1/repos/x/y", nil) if !IsNotFound(err) { t.Fatalf("expected not found, got %v", err) } } func TestClient_Good_ContextCancellation(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) defer srv.Close() c := NewClient(srv.URL, "test-token") ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately err := c.Get(ctx, "/api/v1/user", nil) if err == nil { t.Fatal("expected error from cancelled context") } } func TestClient_Good_Options(t *testing.T) { c := NewClient("https://forge.lthn.ai", "tok", WithUserAgent("go-forge/1.0"), ) if c.userAgent != "go-forge/1.0" { t.Errorf("got user agent=%q", c.userAgent) } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestClient` Expected: Compilation errors (types don't exist yet) **Step 3: Write client.go** ```go package forge import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" ) // APIError represents an error response from the Forgejo API. type APIError struct { StatusCode int Message string URL string } func (e *APIError) Error() string { return fmt.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) } // IsNotFound returns true if the error is a 404 response. func IsNotFound(err error) bool { var apiErr *APIError return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } // IsForbidden returns true if the error is a 403 response. func IsForbidden(err error) bool { var apiErr *APIError return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden } // IsConflict returns true if the error is a 409 response. func IsConflict(err error) bool { var apiErr *APIError return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict } // Option configures the Client. type Option func(*Client) // WithHTTPClient sets a custom http.Client. func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } // WithUserAgent sets the User-Agent header. func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } } // Client is a low-level HTTP client for the Forgejo API. type Client struct { baseURL string token string httpClient *http.Client userAgent string } // NewClient creates a new Forgejo API client. func NewClient(url, token string, opts ...Option) *Client { c := &Client{ baseURL: strings.TrimRight(url, "/"), token: token, httpClient: http.DefaultClient, userAgent: "go-forge/0.1", } for _, opt := range opts { opt(c) } return c } // Get performs a GET request. func (c *Client) Get(ctx context.Context, path string, out any) error { return c.do(ctx, http.MethodGet, path, nil, out) } // Post performs a POST request. func (c *Client) Post(ctx context.Context, path string, body, out any) error { return c.do(ctx, http.MethodPost, path, body, out) } // Patch performs a PATCH request. func (c *Client) Patch(ctx context.Context, path string, body, out any) error { return c.do(ctx, http.MethodPatch, path, body, out) } // Put performs a PUT request. func (c *Client) Put(ctx context.Context, path string, body, out any) error { return c.do(ctx, http.MethodPut, path, body, out) } // Delete performs a DELETE request. func (c *Client) Delete(ctx context.Context, path string) error { return c.do(ctx, http.MethodDelete, path, nil, nil) } func (c *Client) do(ctx context.Context, method, path string, body, out any) error { url := c.baseURL + path var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("forge: marshal body: %w", err) } bodyReader = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return fmt.Errorf("forge: create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("forge: request %s %s: %w", method, path, err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return c.parseError(resp, path) } if out != nil && resp.StatusCode != http.StatusNoContent { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("forge: decode response: %w", err) } } return nil } func (c *Client) parseError(resp *http.Response, path string) error { var errBody struct { Message string `json:"message"` } _ = json.NewDecoder(resp.Body).Decode(&errBody) return &APIError{ StatusCode: resp.StatusCode, Message: errBody.Message, URL: path, } } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestClient` Expected: All 7 tests PASS **Step 5: Commit** ```bash git add client.go client_test.go git commit -m "feat: HTTP client with auth, context, error handling Co-Authored-By: Virgil " ``` --- ### Task 3: Pagination **Files:** - Create: `pagination.go` - Create: `pagination_test.go` **Step 1: Write pagination tests** ```go package forge import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" ) func TestPagination_Good_SinglePage(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "2") json.NewEncoder(w).Encode([]map[string]int{{"id": 1}, {"id": 2}}) })) defer srv.Close() c := NewClient(srv.URL, "tok") result, err := ListAll[map[string]int](context.Background(), c, "/api/v1/repos", nil) if err != nil { t.Fatal(err) } if len(result) != 2 { t.Errorf("got %d items", len(result)) } } func TestPagination_Good_MultiPage(t *testing.T) { page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page++ w.Header().Set("X-Total-Count", "100") items := make([]map[string]int, 50) for i := range items { items[i] = map[string]int{"id": (page-1)*50 + i + 1} } json.NewEncoder(w).Encode(items) })) defer srv.Close() c := NewClient(srv.URL, "tok") result, err := ListAll[map[string]int](context.Background(), c, "/api/v1/repos", nil) if err != nil { t.Fatal(err) } if len(result) != 100 { t.Errorf("got %d items, want 100", len(result)) } } func TestPagination_Good_EmptyResult(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "0") json.NewEncoder(w).Encode([]map[string]int{}) })) defer srv.Close() c := NewClient(srv.URL, "tok") result, err := ListAll[map[string]int](context.Background(), c, "/api/v1/repos", nil) if err != nil { t.Fatal(err) } if len(result) != 0 { t.Errorf("got %d items", len(result)) } } func TestListPage_Good_QueryParams(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Query().Get("page") l := r.URL.Query().Get("limit") s := r.URL.Query().Get("state") if p != "2" || l != "25" || s != "open" { t.Errorf("wrong params: page=%s limit=%s state=%s", p, l, s) } w.Header().Set("X-Total-Count", "50") json.NewEncoder(w).Encode([]map[string]int{}) })) defer srv.Close() c := NewClient(srv.URL, "tok") _, err := ListPage[map[string]int](context.Background(), c, "/api/v1/repos", map[string]string{"state": "open"}, ListOptions{Page: 2, Limit: 25}) if err != nil { t.Fatal(err) } } func TestPagination_Bad_ServerError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) json.NewEncoder(w).Encode(map[string]string{"message": "fail"}) })) defer srv.Close() c := NewClient(srv.URL, "tok") _, err := ListAll[map[string]int](context.Background(), c, "/api/v1/repos", nil) if err == nil { t.Fatal("expected error") } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestPagination -run TestListPage` Expected: Compilation errors **Step 3: Write pagination.go** ```go package forge import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" ) // ListOptions controls pagination. type ListOptions struct { Page int // 1-based page number Limit int // items per page (default 50) } // DefaultList returns sensible default pagination. var DefaultList = ListOptions{Page: 1, Limit: 50} // PagedResult holds a single page of results with metadata. type PagedResult[T any] struct { Items []T TotalCount int Page int HasMore bool } // ListPage fetches a single page of results. // Extra query params can be passed via the query map. func ListPage[T any](ctx context.Context, c *Client, path string, query map[string]string, opts ListOptions) (*PagedResult[T], error) { if opts.Page < 1 { opts.Page = 1 } if opts.Limit < 1 { opts.Limit = 50 } u, err := url.Parse(c.baseURL + path) if err != nil { return nil, fmt.Errorf("forge: parse url: %w", err) } q := u.Query() q.Set("page", strconv.Itoa(opts.Page)) q.Set("limit", strconv.Itoa(opts.Limit)) for k, v := range query { q.Set(k, v) } u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("forge: create request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/json") if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("forge: request GET %s: %w", path, err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } var items []T if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { return nil, fmt.Errorf("forge: decode response: %w", err) } totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) return &PagedResult[T]{ Items: items, TotalCount: totalCount, Page: opts.Page, HasMore: len(items) >= opts.Limit && opts.Page*opts.Limit < totalCount, }, nil } // ListAll fetches all pages of results. func ListAll[T any](ctx context.Context, c *Client, path string, query map[string]string) ([]T, error) { var all []T page := 1 for { result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: 50}) if err != nil { return nil, err } all = append(all, result.Items...) if !result.HasMore { break } page++ } return all, nil } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run "TestPagination|TestListPage"` Expected: All 5 tests PASS **Step 5: Commit** ```bash git add pagination.go pagination_test.go git commit -m "feat: generic pagination with ListAll and ListPage Co-Authored-By: Virgil " ``` --- ### Task 4: Params and path resolution **Files:** - Create: `params.go` - Create: `params_test.go` **Step 1: Write tests** ```go package forge import "testing" func TestResolvePath_Good_Simple(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}", Params{"owner": "core", "repo": "go-forge"}) want := "/api/v1/repos/core/go-forge" if got != want { t.Errorf("got %q, want %q", got, want) } } func TestResolvePath_Good_NoParams(t *testing.T) { got := ResolvePath("/api/v1/user", nil) if got != "/api/v1/user" { t.Errorf("got %q", got) } } func TestResolvePath_Good_WithID(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}", Params{ "owner": "core", "repo": "go-forge", "index": "42", }) want := "/api/v1/repos/core/go-forge/issues/42" if got != want { t.Errorf("got %q, want %q", got, want) } } func TestResolvePath_Good_URLEncoding(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}", Params{"owner": "my org", "repo": "my repo"}) want := "/api/v1/repos/my%20org/my%20repo" if got != want { t.Errorf("got %q, want %q", got, want) } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestResolvePath` Expected: Compilation errors **Step 3: Write params.go** ```go package forge import ( "net/url" "strings" ) // Params maps path variable names to values. // Example: Params{"owner": "core", "repo": "go-forge"} type Params map[string]string // ResolvePath substitutes {placeholders} in path with values from params. func ResolvePath(path string, params Params) string { for k, v := range params { path = strings.ReplaceAll(path, "{"+k+"}", url.PathEscape(v)) } return path } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestResolvePath` Expected: All 4 tests PASS **Step 5: Commit** ```bash git add params.go params_test.go git commit -m "feat: path parameter resolution with URL encoding Co-Authored-By: Virgil " ``` --- ### Task 5: Generic Resource[T, C, U] **Files:** - Create: `resource.go` - Create: `resource_test.go` **Step 1: Write resource tests** ```go package forge import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" ) // Test types type testItem struct { ID int `json:"id"` Name string `json:"name"` } type testCreate struct { Name string `json:"name"` } type testUpdate struct { Name *string `json:"name,omitempty"` } func TestResource_Good_List(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/orgs/core/repos" { t.Errorf("wrong path: %s", r.URL.Path) } w.Header().Set("X-Total-Count", "2") json.NewEncoder(w).Encode([]testItem{{1, "a"}, {2, "b"}}) })) defer srv.Close() c := NewClient(srv.URL, "tok") res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/orgs/{org}/repos") items, err := res.List(context.Background(), Params{"org": "core"}, DefaultList) if err != nil { t.Fatal(err) } if len(items.Items) != 2 { t.Errorf("got %d items", len(items.Items)) } } func TestResource_Good_Get(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/core/go-forge" { t.Errorf("wrong path: %s", r.URL.Path) } json.NewEncoder(w).Encode(testItem{1, "go-forge"}) })) defer srv.Close() c := NewClient(srv.URL, "tok") res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos/{owner}/{repo}") item, err := res.Get(context.Background(), Params{"owner": "core", "repo": "go-forge"}) if err != nil { t.Fatal(err) } if item.Name != "go-forge" { t.Errorf("got name=%q", item.Name) } } func TestResource_Good_Create(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } var body testCreate json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(testItem{1, body.Name}) })) defer srv.Close() c := NewClient(srv.URL, "tok") res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/orgs/{org}/repos") item, err := res.Create(context.Background(), Params{"org": "core"}, &testCreate{Name: "new-repo"}) if err != nil { t.Fatal(err) } if item.Name != "new-repo" { t.Errorf("got name=%q", item.Name) } } func TestResource_Good_Update(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) } json.NewEncoder(w).Encode(testItem{1, "updated"}) })) defer srv.Close() c := NewClient(srv.URL, "tok") res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos/{owner}/{repo}") name := "updated" item, err := res.Update(context.Background(), Params{"owner": "core", "repo": "old"}, &testUpdate{Name: &name}) if err != nil { t.Fatal(err) } if item.Name != "updated" { t.Errorf("got name=%q", item.Name) } } func TestResource_Good_Delete(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := NewClient(srv.URL, "tok") res := NewResource[testItem, testCreate, testUpdate](c, "/api/v1/repos/{owner}/{repo}") err := res.Delete(context.Background(), Params{"owner": "core", "repo": "old"}) if err != nil { t.Fatal(err) } } func TestResource_Good_ListAll(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") items, err := res.ListAll(context.Background(), nil) if err != nil { t.Fatal(err) } if len(items) != 3 { t.Errorf("got %d items, want 3", len(items)) } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestResource` Expected: Compilation errors **Step 3: Write resource.go** ```go package forge import "context" // Resource provides generic CRUD operations for a Forgejo API resource. // T is the resource type, C is the create options type, U is the update options type. type Resource[T any, C any, U any] struct { client *Client path string } // NewResource creates a new Resource for the given path pattern. // The path may contain {placeholders} that are resolved via Params. func NewResource[T any, C any, U any](c *Client, path string) *Resource[T, C, U] { return &Resource[T, C, U]{client: c, path: path} } // List returns a single page of resources. func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[T], error) { return ListPage[T](ctx, r.client, ResolvePath(r.path, params), nil, opts) } // ListAll returns all resources across all pages. func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error) { return ListAll[T](ctx, r.client, ResolvePath(r.path, params), nil) } // Get returns a single resource by appending id to the path. func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error) { var out T if err := r.client.Get(ctx, ResolvePath(r.path, params), &out); err != nil { return nil, err } return &out, nil } // Create creates a new resource. func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error) { var out T if err := r.client.Post(ctx, ResolvePath(r.path, params), body, &out); err != nil { return nil, err } return &out, nil } // Update modifies an existing resource. func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, body *U) (*T, error) { var out T if err := r.client.Patch(ctx, ResolvePath(r.path, params), body, &out); err != nil { return nil, err } return &out, nil } // Delete removes a resource. func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params) error { return r.client.Delete(ctx, ResolvePath(r.path, params)) } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestResource` Expected: All 6 tests PASS **Step 5: Commit** ```bash git add resource.go resource_test.go git commit -m "feat: generic Resource[T,C,U] for CRUD operations Co-Authored-By: Virgil " ``` --- ### Task 6: Config resolution (extracted from go-scm) **Files:** - Create: `config.go` - Create: `config_test.go` **Step 1: Write config tests** ```go package forge import ( "os" "testing" ) func TestResolveConfig_Good_EnvOverrides(t *testing.T) { t.Setenv("FORGE_URL", "https://forge.example.com") t.Setenv("FORGE_TOKEN", "env-token") url, token, err := ResolveConfig("", "") if err != nil { t.Fatal(err) } if url != "https://forge.example.com" { t.Errorf("got url=%q", url) } if token != "env-token" { t.Errorf("got token=%q", token) } } func TestResolveConfig_Good_FlagOverridesEnv(t *testing.T) { t.Setenv("FORGE_URL", "https://env.example.com") t.Setenv("FORGE_TOKEN", "env-token") url, token, err := ResolveConfig("https://flag.example.com", "flag-token") if err != nil { t.Fatal(err) } if url != "https://flag.example.com" { t.Errorf("got url=%q", url) } if token != "flag-token" { t.Errorf("got token=%q", token) } } func TestResolveConfig_Good_DefaultURL(t *testing.T) { // Clear env vars to test defaults os.Unsetenv("FORGE_URL") os.Unsetenv("FORGE_TOKEN") url, _, err := ResolveConfig("", "") if err != nil { t.Fatal(err) } if url != DefaultURL { t.Errorf("got url=%q, want %q", url, DefaultURL) } } func TestNewForgeFromConfig_Bad_NoToken(t *testing.T) { os.Unsetenv("FORGE_URL") os.Unsetenv("FORGE_TOKEN") _, err := NewForgeFromConfig("", "") if err == nil { t.Fatal("expected error for missing token") } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run TestResolveConfig -run TestNewForgeFromConfig` Expected: Compilation errors **Step 3: Write config.go** ```go package forge import ( "fmt" "os" ) const ( // DefaultURL is used when no URL is configured. DefaultURL = "http://localhost:3000" ) // ResolveConfig resolves Forge URL and token from multiple sources. // Priority (highest to lowest): flags → environment → defaults. func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { // Environment variables url = os.Getenv("FORGE_URL") token = os.Getenv("FORGE_TOKEN") // Flag overrides if flagURL != "" { url = flagURL } if flagToken != "" { token = flagToken } // Default URL if url == "" { url = DefaultURL } return url, token, nil } // NewForgeFromConfig creates a Forge client using resolved configuration. func NewForgeFromConfig(flagURL, flagToken string, opts ...Option) (*Forge, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { return nil, err } if token == "" { return nil, fmt.Errorf("forge: no API token configured (set FORGE_TOKEN or pass --token)") } return NewForge(url, token, opts...), nil } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run "TestResolveConfig|TestNewForgeFromConfig"` Expected: All 4 tests PASS (Note: `NewForge` doesn't exist yet — if this fails, create a stub `NewForge` function that just returns `&Forge{client: NewClient(url, token, opts...)}`) **Step 5: Commit** ```bash git add config.go config_test.go git commit -m "feat: config resolution from env vars and flags Co-Authored-By: Virgil " ``` --- ## Wave 2: Code Generator (Tasks 7-9) ### Task 7: Swagger spec parser **Files:** - Create: `cmd/forgegen/main.go` - Create: `cmd/forgegen/parser.go` - Create: `cmd/forgegen/parser_test.go` The parser reads swagger.v1.json and extracts type definitions into an intermediate representation. **Step 1: Write parser tests** ```go package main import ( "os" "testing" ) func TestParser_Good_LoadSpec(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } if spec.Swagger != "2.0" { t.Errorf("got swagger=%q", spec.Swagger) } if len(spec.Definitions) < 200 { t.Errorf("got %d definitions, expected 200+", len(spec.Definitions)) } } func TestParser_Good_ExtractTypes(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) if len(types) < 200 { t.Errorf("got %d types", len(types)) } // Check a known type repo, ok := types["Repository"] if !ok { t.Fatal("Repository type not found") } if len(repo.Fields) < 50 { t.Errorf("Repository has %d fields, expected 50+", len(repo.Fields)) } } func TestParser_Good_FieldTypes(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) repo := types["Repository"] // Check specific field mappings for _, f := range repo.Fields { switch f.JSONName { case "id": if f.GoType != "int64" { t.Errorf("id: got %q, want int64", f.GoType) } case "name": if f.GoType != "string" { t.Errorf("name: got %q, want string", f.GoType) } case "private": if f.GoType != "bool" { t.Errorf("private: got %q, want bool", f.GoType) } case "created_at": if f.GoType != "time.Time" { t.Errorf("created_at: got %q, want time.Time", f.GoType) } case "owner": if f.GoType != "*User" { t.Errorf("owner: got %q, want *User", f.GoType) } } } } func TestParser_Good_DetectCreateEditPairs(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } pairs := DetectCRUDPairs(spec) // Should find Repository, Issue, PullRequest, etc. if len(pairs) < 10 { t.Errorf("got %d pairs, expected 10+", len(pairs)) } found := false for _, p := range pairs { if p.Base == "Repository" { found = true if p.Create != "CreateRepoOption" { t.Errorf("repo create=%q", p.Create) } } } if !found { t.Fatal("Repository pair not found") } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v ./cmd/forgegen/ -run TestParser` Expected: Compilation errors **Step 3: Write parser.go** ```go package main import ( "encoding/json" "fmt" "os" "sort" "strings" ) // Spec represents a Swagger 2.0 specification. type Spec struct { Swagger string `json:"swagger"` Info SpecInfo `json:"info"` Definitions map[string]SchemaDefinition `json:"definitions"` Paths map[string]map[string]any `json:"paths"` } type SpecInfo struct { Title string `json:"title"` Version string `json:"version"` } // SchemaDefinition represents a type definition in the spec. type SchemaDefinition struct { Description string `json:"description"` Type string `json:"type"` Properties map[string]SchemaProperty `json:"properties"` Required []string `json:"required"` Enum []any `json:"enum"` XGoName string `json:"x-go-name"` } // SchemaProperty represents a field in a type definition. type SchemaProperty struct { Type string `json:"type"` Format string `json:"format"` Description string `json:"description"` Ref string `json:"$ref"` Items *SchemaProperty `json:"items"` Enum []any `json:"enum"` XGoName string `json:"x-go-name"` } // GoType represents a Go type extracted from the spec. type GoType struct { Name string Description string Fields []GoField IsEnum bool EnumValues []string } // GoField represents a field in a Go struct. type GoField struct { GoName string GoType string JSONName string Comment string Required bool } // CRUDPair maps a base type to its Create and Edit option types. type CRUDPair struct { Base string // e.g. "Repository" Create string // e.g. "CreateRepoOption" Edit string // e.g. "EditRepoOption" } // LoadSpec reads and parses a Swagger 2.0 JSON file. func LoadSpec(path string) (*Spec, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read spec: %w", err) } var spec Spec if err := json.Unmarshal(data, &spec); err != nil { return nil, fmt.Errorf("parse spec: %w", err) } return &spec, nil } // ExtractTypes converts spec definitions to Go types. func ExtractTypes(spec *Spec) map[string]*GoType { result := make(map[string]*GoType) for name, def := range spec.Definitions { gt := &GoType{ Name: name, Description: def.Description, } if len(def.Enum) > 0 { gt.IsEnum = true for _, v := range def.Enum { gt.EnumValues = append(gt.EnumValues, fmt.Sprintf("%v", v)) } sort.Strings(gt.EnumValues) result[name] = gt continue } required := make(map[string]bool) for _, r := range def.Required { required[r] = true } for fieldName, prop := range def.Properties { goName := prop.XGoName if goName == "" { goName = pascalCase(fieldName) } gf := GoField{ GoName: goName, GoType: resolveGoType(prop), JSONName: fieldName, Comment: prop.Description, Required: required[fieldName], } gt.Fields = append(gt.Fields, gf) } // Sort fields alphabetically for stable output sort.Slice(gt.Fields, func(i, j int) bool { return gt.Fields[i].GoName < gt.Fields[j].GoName }) result[name] = gt } return result } // DetectCRUDPairs finds Create/Edit option pairs. func DetectCRUDPairs(spec *Spec) []CRUDPair { var pairs []CRUDPair for name := range spec.Definitions { if !strings.HasPrefix(name, "Create") || !strings.HasSuffix(name, "Option") { continue } // CreateXxxOption → Xxx → EditXxxOption inner := strings.TrimPrefix(name, "Create") inner = strings.TrimSuffix(inner, "Option") editName := "Edit" + inner + "Option" pair := CRUDPair{ Base: inner, Create: name, } if _, ok := spec.Definitions[editName]; ok { pair.Edit = editName } pairs = append(pairs, pair) } sort.Slice(pairs, func(i, j int) bool { return pairs[i].Base < pairs[j].Base }) return pairs } func resolveGoType(prop SchemaProperty) string { if prop.Ref != "" { parts := strings.Split(prop.Ref, "/") return "*" + parts[len(parts)-1] } switch prop.Type { case "string": switch prop.Format { case "date-time": return "time.Time" case "binary": return "[]byte" default: return "string" } case "integer": switch prop.Format { case "int64": return "int64" case "int32": return "int32" default: return "int" } case "number": switch prop.Format { case "float": return "float32" default: return "float64" } case "boolean": return "bool" case "array": if prop.Items != nil { itemType := resolveGoType(*prop.Items) return "[]" + itemType } return "[]any" case "object": return "map[string]any" default: if prop.Type == "" && prop.Ref == "" { return "any" } return "any" } } func pascalCase(s string) string { parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' }) for i, p := range parts { if len(p) == 0 { continue } // Handle common acronyms upper := strings.ToUpper(p) switch upper { case "ID", "URL", "HTML", "SSH", "HTTP", "HTTPS", "API", "URI", "GPG", "IP", "CSS", "JS": parts[i] = upper default: parts[i] = strings.ToUpper(p[:1]) + p[1:] } } return strings.Join(parts, "") } ``` **Step 4: Write main.go stub** ```go package main import ( "flag" "fmt" "os" ) func main() { specPath := flag.String("spec", "testdata/swagger.v1.json", "path to swagger.v1.json") outDir := flag.String("out", "types", "output directory for generated types") flag.Parse() spec, err := LoadSpec(*specPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) fmt.Printf("Loaded %d types, %d CRUD pairs\n", len(types), len(pairs)) fmt.Printf("Output dir: %s\n", *outDir) // Generation happens in Task 8 if err := Generate(types, pairs, *outDir); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } ``` **Step 5: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v ./cmd/forgegen/ -run TestParser` Expected: All 4 tests PASS (Note: `Generate` doesn't exist yet — add a stub: `func Generate(...) error { return nil }`) **Step 6: Commit** ```bash git add cmd/forgegen/ git commit -m "feat: swagger spec parser for type extraction Co-Authored-By: Virgil " ``` --- ### Task 8: Code generator — Go source emission **Files:** - Create: `cmd/forgegen/generator.go` - Create: `cmd/forgegen/generator_test.go` **Step 1: Write generator tests** ```go package main import ( "os" "path/filepath" "strings" "testing" ) func TestGenerate_Good_CreatesFiles(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) outDir := t.TempDir() if err := Generate(types, pairs, outDir); err != nil { t.Fatal(err) } // Should create at least one .go file entries, _ := os.ReadDir(outDir) goFiles := 0 for _, e := range entries { if strings.HasSuffix(e.Name(), ".go") { goFiles++ } } if goFiles == 0 { t.Fatal("no .go files generated") } } func TestGenerate_Good_ValidGoSyntax(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) outDir := t.TempDir() if err := Generate(types, pairs, outDir); err != nil { t.Fatal(err) } // Read a generated file and verify basic Go syntax markers data, err := os.ReadFile(filepath.Join(outDir, "repo.go")) if err != nil { // Try another name entries, _ := os.ReadDir(outDir) for _, e := range entries { if strings.HasSuffix(e.Name(), ".go") { data, err = os.ReadFile(filepath.Join(outDir, e.Name())) break } } } if err != nil { t.Fatal(err) } content := string(data) if !strings.Contains(content, "package types") { t.Error("missing package declaration") } if !strings.Contains(content, "// Code generated") { t.Error("missing generated comment") } } func TestGenerate_Good_RepositoryType(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) outDir := t.TempDir() if err := Generate(types, pairs, outDir); err != nil { t.Fatal(err) } // Find file containing Repository type var content string entries, _ := os.ReadDir(outDir) for _, e := range entries { data, _ := os.ReadFile(filepath.Join(outDir, e.Name())) if strings.Contains(string(data), "type Repository struct") { content = string(data) break } } if content == "" { t.Fatal("Repository type not found in any generated file") } // Check essential fields exist checks := []string{ "`json:\"id\"`", "`json:\"name\"`", "`json:\"full_name\"`", "`json:\"private\"`", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("missing field with tag %s", check) } } } func TestGenerate_Good_TimeImport(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) outDir := t.TempDir() if err := Generate(types, pairs, outDir); err != nil { t.Fatal(err) } // Files with time.Time fields should import "time" entries, _ := os.ReadDir(outDir) for _, e := range entries { data, _ := os.ReadFile(filepath.Join(outDir, e.Name())) content := string(data) if strings.Contains(content, "time.Time") && !strings.Contains(content, "\"time\"") { t.Errorf("file %s uses time.Time but doesn't import time", e.Name()) } } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v ./cmd/forgegen/ -run TestGenerate` Expected: Failures (Generate is stub) **Step 3: Write generator.go** The generator groups types by logical domain and writes one `.go` file per group. Type grouping uses name prefixes and the CRUD pairs. ```go package main import ( "fmt" "os" "path/filepath" "sort" "strings" "text/template" ) // typeGrouping maps types to their output file. var typeGrouping = map[string]string{ "Repository": "repo", "Repo": "repo", "Issue": "issue", "PullRequest": "pr", "Pull": "pr", "User": "user", "Organization": "org", "Org": "org", "Team": "team", "Label": "label", "Milestone": "milestone", "Release": "release", "Tag": "tag", "Branch": "branch", "Hook": "hook", "Deploy": "key", "PublicKey": "key", "GPGKey": "key", "Key": "key", "Notification": "notification", "Package": "package", "Action": "action", "Commit": "commit", "Git": "git", "Contents": "content", "File": "content", "Wiki": "wiki", "Comment": "comment", "Review": "review", "Reaction": "reaction", "Topic": "topic", "Status": "status", "Combined": "status", "Cron": "admin", "Quota": "quota", "OAuth2": "oauth", "AccessToken": "oauth", "API": "error", "Forbidden": "error", "NotFound": "error", "NodeInfo": "federation", "Activity": "activity", "Feed": "activity", "StopWatch": "time_tracking", "TrackedTime": "time_tracking", "Blocked": "user", "Email": "user", "Settings": "settings", "GeneralAPI": "settings", "GeneralAttachment": "settings", "GeneralRepo": "settings", "GeneralUI": "settings", "Markdown": "misc", "Markup": "misc", "License": "misc", "Gitignore": "misc", "Annotated": "git", "Note": "git", "ChangedFile": "git", "ExternalTracker": "repo", "ExternalWiki": "repo", "InternalTracker": "repo", "Permission": "common", "RepoTransfer": "repo", "PayloadCommit": "hook", "Dispatch": "action", "Secret": "action", "Variable": "action", "Push": "repo", "Mirror": "repo", "Attachment": "common", "EditDeadline": "issue", "IssueDeadline": "issue", "IssueLabels": "issue", "IssueMeta": "issue", "IssueTemplate": "issue", "StateType": "common", "TimeStamp": "common", "Rename": "admin", "Unadopted": "admin", } // classifyType determines which file a type belongs in. func classifyType(name string) string { // Direct match if group, ok := typeGrouping[name]; ok { return group } // Prefix match (longest first) for prefix, group := range typeGrouping { if strings.HasPrefix(name, prefix) { return group } } // Try common suffixes if strings.HasSuffix(name, "Option") || strings.HasSuffix(name, "Options") { // Strip Create/Edit prefix to find base trimmed := name trimmed = strings.TrimPrefix(trimmed, "Create") trimmed = strings.TrimPrefix(trimmed, "Edit") trimmed = strings.TrimPrefix(trimmed, "Delete") trimmed = strings.TrimPrefix(trimmed, "Update") trimmed = strings.TrimSuffix(trimmed, "Option") trimmed = strings.TrimSuffix(trimmed, "Options") if group, ok := typeGrouping[trimmed]; ok { return group } } return "misc" } // Generate writes Go source files for all types. func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { if err := os.MkdirAll(outDir, 0755); err != nil { return fmt.Errorf("create output dir: %w", err) } // Group types by file groups := make(map[string][]*GoType) for _, gt := range types { file := classifyType(gt.Name) groups[file] = append(groups[file], gt) } // Sort types within each group for file := range groups { sort.Slice(groups[file], func(i, j int) bool { return groups[file][i].Name < groups[file][j].Name }) } // Write each file for file, fileTypes := range groups { if err := writeFile(filepath.Join(outDir, file+".go"), fileTypes); err != nil { return fmt.Errorf("write %s.go: %w", file, err) } } return nil } var fileTmpl = template.Must(template.New("file").Parse(`// Code generated by forgegen from swagger.v1.json — DO NOT EDIT. package types {{if .NeedsTime}} import "time" {{end}} {{range .Types}} {{if .Description}}// {{.Name}} — {{.Description}}{{else}}// {{.Name}} represents a Forgejo API type.{{end}} {{if .IsEnum}}type {{.Name}} string const ( {{range .EnumValues}} {{$.EnumConst .Name .}} {{$.EnumType .Name}} = "{{.}}" {{end}}) {{else}}type {{.Name}} struct { {{range .Fields}} {{.GoName}} {{.GoType}} ` + "`" + `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` + "`" + `{{if .Comment}} // {{.Comment}}{{end}} {{end}}} {{end}} {{end}}`)) type fileData struct { Types []*GoType NeedsTime bool } func (fd fileData) EnumConst(typeName, value string) string { return typeName + pascalCase(value) } func (fd fileData) EnumType(typeName string) string { return typeName } func writeFile(path string, types []*GoType) error { needsTime := false for _, gt := range types { for _, f := range gt.Fields { if strings.Contains(f.GoType, "time.Time") { needsTime = true break } } if needsTime { break } } f, err := os.Create(path) if err != nil { return err } defer f.Close() return fileTmpl.Execute(f, fileData{ Types: types, NeedsTime: needsTime, }) } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v ./cmd/forgegen/ -run TestGenerate` Expected: All 4 tests PASS **Step 5: Commit** ```bash git add cmd/forgegen/generator.go cmd/forgegen/generator_test.go git commit -m "feat: Go source code generator from Swagger types Co-Authored-By: Virgil " ``` --- ### Task 9: Generate types + verify compilation **Files:** - Create: `types/` directory with generated files - Create: `types/generate.go` (go:generate directive) **Step 1: Run the generator** ```bash cd /Users/snider/Code/go-forge mkdir -p types go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/ ``` **Step 2: Add go:generate directive** Create `types/generate.go`: ```go package types //go:generate go run ../cmd/forgegen/ -spec ../testdata/swagger.v1.json -out . ``` **Step 3: Verify compilation** Run: `cd /Users/snider/Code/go-forge && go build ./types/` Expected: Compiles without errors If there are compilation errors, fix the generator (`cmd/forgegen/generator.go`) and regenerate. Common issues: - Missing imports (time) - Duplicate field names (GoName collision) - Invalid Go identifiers (reserved words, starting with numbers) **Step 4: Run all tests** Run: `cd /Users/snider/Code/go-forge && go test ./...` Expected: All tests pass **Step 5: Commit** ```bash git add types/ git commit -m "feat: generate all 229 Forgejo API types from swagger spec Co-Authored-By: Virgil " ``` --- ## Wave 3: Core Services (Tasks 10-13) Each service follows the same pattern: embed `Resource[T,C,U]`, add action methods. The first service (Task 10) is fully detailed as a template. Subsequent services follow the same structure with less repetition. ### Task 10: Forge client + RepoService (template service) **Files:** - Create: `forge.go` - Create: `repos.go` - Create: `forge_test.go` **Step 1: Write tests** ```go package forge import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "forge.lthn.ai/core/go-forge/types" ) func TestForge_Good_NewForge(t *testing.T) { f := NewForge("https://forge.lthn.ai", "tok") if f.Repos == nil { t.Fatal("Repos service is nil") } if f.Issues == nil { t.Fatal("Issues service is nil") } } func TestRepoService_Good_List(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "1") json.NewEncoder(w).Encode([]types.Repository{{Name: "go-forge"}}) })) defer srv.Close() f := NewForge(srv.URL, "tok") result, err := f.Repos.List(context.Background(), Params{"org": "core"}, DefaultList) if err != nil { t.Fatal(err) } if len(result.Items) != 1 || result.Items[0].Name != "go-forge" { t.Errorf("unexpected result: %+v", result) } } func TestRepoService_Good_Get(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", FullName: "core/go-forge"}) })) defer srv.Close() f := NewForge(srv.URL, "tok") repo, err := f.Repos.Get(context.Background(), Params{"owner": "core", "repo": "go-forge"}) if err != nil { t.Fatal(err) } if repo.Name != "go-forge" { t.Errorf("got name=%q", repo.Name) } } func TestRepoService_Good_Fork(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", Fork: true}) })) defer srv.Close() f := NewForge(srv.URL, "tok") repo, err := f.Repos.Fork(context.Background(), "core", "go-forge", "my-org") if err != nil { t.Fatal(err) } if !repo.Fork { t.Error("expected fork=true") } } ``` **Step 2: Run tests to verify they fail** Run: `cd /Users/snider/Code/go-forge && go test -v -run "TestForge|TestRepoService"` Expected: Compilation errors **Step 3: Write forge.go** ```go package forge import "forge.lthn.ai/core/go-forge/types" // Forge is the top-level client for the Forgejo API. type Forge struct { client *Client Repos *RepoService Issues *IssueService Pulls *PullService Orgs *OrgService Users *UserService Teams *TeamService Admin *AdminService Branches *BranchService Releases *ReleaseService Labels *LabelService Webhooks *WebhookService Notifications *NotificationService Packages *PackageService Actions *ActionsService Contents *ContentService Wiki *WikiService Misc *MiscService } // NewForge creates a new Forge client. func NewForge(url, token string, opts ...Option) *Forge { c := NewClient(url, token, opts...) f := &Forge{client: c} f.Repos = newRepoService(c) // Other services initialised in their respective tasks. // Stub them here so tests compile: f.Issues = &IssueService{} f.Pulls = &PullService{} f.Orgs = &OrgService{} f.Users = &UserService{} f.Teams = &TeamService{} f.Admin = &AdminService{} f.Branches = &BranchService{} f.Releases = &ReleaseService{} f.Labels = &LabelService{} f.Webhooks = &WebhookService{} f.Notifications = &NotificationService{} f.Packages = &PackageService{} f.Actions = &ActionsService{} f.Contents = &ContentService{} f.Wiki = &WikiService{} f.Misc = &MiscService{} return f } // Client returns the underlying HTTP client. func (f *Forge) Client() *Client { return f.client } ``` **Step 4: Write repos.go** ```go package forge import ( "context" "forge.lthn.ai/core/go-forge/types" ) // RepoService handles repository operations. type RepoService struct { Resource[types.Repository, types.CreateRepoOption, types.EditRepoOption] } func newRepoService(c *Client) *RepoService { return &RepoService{ Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption]( c, "/api/v1/repos/{owner}/{repo}", ), } } // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { return ListAll[types.Repository](ctx, s.client, "/api/v1/orgs/"+org+"/repos", nil) } // ListUserRepos returns all repositories for the authenticated user. func (s *RepoService) ListUserRepos(ctx context.Context) ([]types.Repository, error) { return ListAll[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } // Fork forks a repository. If org is non-empty, forks into that organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { body := map[string]string{} if org != "" { body["organization"] = org } var out types.Repository err := s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/forks", body, &out) if err != nil { return nil, err } return &out, nil } // Migrate imports a repository from an external service. func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOptions) (*types.Repository, error) { var out types.Repository err := s.client.Post(ctx, "/api/v1/repos/migrate", opts, &out) if err != nil { return nil, err } return &out, nil } // Transfer initiates a repository transfer. func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts map[string]any) error { return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer", opts, nil) } // AcceptTransfer accepts a pending repository transfer. func (s *RepoService) AcceptTransfer(ctx context.Context, owner, repo string) error { return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/accept", nil, nil) } // RejectTransfer rejects a pending repository transfer. func (s *RepoService) RejectTransfer(ctx context.Context, owner, repo string) error { return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/reject", nil, nil) } // MirrorSync triggers a mirror sync. func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error { return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/mirror-sync", nil, nil) } ``` **Step 5: Write stub service types** so `forge.go` compiles. Create `services_stub.go`: ```go package forge // Stub service types — replaced as each service is implemented. type IssueService struct{} type PullService struct{} type OrgService struct{} type UserService struct{} type TeamService struct{} type AdminService struct{} type BranchService struct{} type ReleaseService struct{} type LabelService struct{} type WebhookService struct{} type NotificationService struct{} type PackageService struct{} type ActionsService struct{} type ContentService struct{} type WikiService struct{} type MiscService struct{} ``` **Step 6: Run tests** Run: `cd /Users/snider/Code/go-forge && go test -v -run "TestForge|TestRepoService"` Expected: All tests PASS (if generated types compile — if `types.CreateRepoOption` or `types.MigrateRepoOptions` don't exist, adjust field names to match generated types) **Step 7: Commit** ```bash git add forge.go repos.go services_stub.go forge_test.go git commit -m "feat: Forge client + RepoService with CRUD and actions Co-Authored-By: Virgil " ``` --- ### Task 11: IssueService + PullService **Files:** - Create: `issues.go` - Create: `pulls.go` - Create: `issues_test.go` - Create: `pulls_test.go` - Modify: `forge.go` (wire up services) - Modify: `services_stub.go` (remove IssueService, PullService stubs) Follow the same pattern as Task 10. Key points: **IssueService** embeds `Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption]`. Path: `/api/v1/repos/{owner}/{repo}/issues/{index}` Action methods (9): - `Pin(ctx, owner, repo, index)` — POST `.../issues/{index}/pin` - `Unpin(ctx, owner, repo, index)` — DELETE `.../issues/{index}/pin` - `SetDeadline(ctx, owner, repo, index, deadline)` — POST `.../issues/{index}/deadline` - `AddReaction(ctx, owner, repo, index, reaction)` — POST `.../issues/{index}/reactions` - `DeleteReaction(ctx, owner, repo, index, reaction)` — DELETE `.../issues/{index}/reactions` - `StartStopwatch(ctx, owner, repo, index)` — POST `.../issues/{index}/stopwatch/start` - `StopStopwatch(ctx, owner, repo, index)` — POST `.../issues/{index}/stopwatch/stop` - `AddLabels(ctx, owner, repo, index, labelIDs)` — POST `.../issues/{index}/labels` - `RemoveLabel(ctx, owner, repo, index, labelID)` — DELETE `.../issues/{index}/labels/{id}` - `ListComments(ctx, owner, repo, index)` — GET `.../issues/{index}/comments` - `CreateComment(ctx, owner, repo, index, body)` — POST `.../issues/{index}/comments` **PullService** embeds `Resource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption]`. Path: `/api/v1/repos/{owner}/{repo}/pulls/{index}` Action methods (6): - `Merge(ctx, owner, repo, index, method)` — POST `.../pulls/{index}/merge` - `Update(ctx, owner, repo, index)` — POST `.../pulls/{index}/update` - `ListReviews(ctx, owner, repo, index)` — GET `.../pulls/{index}/reviews` - `SubmitReview(ctx, owner, repo, index, reviewID)` — POST `.../pulls/{index}/reviews/{id}` - `DismissReview(ctx, owner, repo, index, reviewID, msg)` — POST `.../pulls/{index}/reviews/{id}/dismissals` - `UndismissReview(ctx, owner, repo, index, reviewID)` — POST `.../pulls/{index}/reviews/{id}/undismissals` Write tests for at least: List, Get, Create for each service + one action method each. Run: `cd /Users/snider/Code/go-forge && go test ./... -v` Commit: `git commit -m "feat: IssueService and PullService with actions"` --- ### Task 12: OrgService + TeamService + UserService **Files:** - Create: `orgs.go`, `teams.go`, `users.go` - Create: `orgs_test.go`, `teams_test.go`, `users_test.go` - Modify: `forge.go` (wire up) - Modify: `services_stub.go` (remove stubs) **OrgService** — `Resource[types.Organization, types.CreateOrgOption, types.EditOrgOption]` Path: `/api/v1/orgs/{org}` Actions: ListMembers, AddMember, RemoveMember, SetAvatar, Block, Unblock **TeamService** — `Resource[types.Team, types.CreateTeamOption, types.EditTeamOption]` Path: `/api/v1/teams/{id}` Actions: ListMembers, AddMember, RemoveMember, ListRepos, AddRepo, RemoveRepo **UserService** — `Resource[types.User, struct{}, struct{}]` (no create/edit via this path) Path: `/api/v1/users/{username}` Custom: `GetCurrent(ctx)`, `ListFollowers(ctx)`, `ListStarred(ctx)`, keys, GPG keys, settings Run: `cd /Users/snider/Code/go-forge && go test ./... -v` Commit: `git commit -m "feat: OrgService, TeamService, UserService"` --- ### Task 13: AdminService **Files:** - Create: `admin.go` - Create: `admin_test.go` - Modify: `forge.go` (wire up) - Modify: `services_stub.go` (remove stub) **AdminService** — No generic Resource (admin endpoints are heterogeneous). Direct methods: - `ListUsers(ctx)` — GET `/api/v1/admin/users` - `CreateUser(ctx, opts)` — POST `/api/v1/admin/users` - `EditUser(ctx, username, opts)` — PATCH `/api/v1/admin/users/{username}` - `DeleteUser(ctx, username)` — DELETE `/api/v1/admin/users/{username}` - `RenameUser(ctx, username, newName)` — POST `.../users/{username}/rename` - `ListOrgs(ctx)` — GET `/api/v1/admin/orgs` - `RunCron(ctx, task)` — POST `/api/v1/admin/cron/{task}` - `ListCron(ctx)` — GET `/api/v1/admin/cron` - `AdoptRepo(ctx, owner, repo)` — POST `.../unadopted/{owner}/{repo}` - `GenerateRunnerToken(ctx)` — POST `/api/v1/admin/runners/registration-token` Run: `cd /Users/snider/Code/go-forge && go test ./... -v` Commit: `git commit -m "feat: AdminService with user, org, cron, runner operations"` --- ## Wave 4: Extended Services (Tasks 14-17) ### Task 14: BranchService + ReleaseService **BranchService** — `Resource[types.Branch, types.CreateBranchRepoOption, struct{}]` Path: `/api/v1/repos/{owner}/{repo}/branches/{branch}` Additional: BranchProtection CRUD at `.../branch_protections/{name}` **ReleaseService** — `Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption]` Path: `/api/v1/repos/{owner}/{repo}/releases/{id}` Additional: Asset upload/download at `.../releases/{id}/assets` ### Task 15: LabelService + WebhookService + ContentService **LabelService** — Handles repo labels, org labels, and issue labels. - `ListRepoLabels(ctx, owner, repo)` - `CreateRepoLabel(ctx, owner, repo, opts)` - `ListOrgLabels(ctx, org)` **WebhookService** — `Resource[types.Hook, types.CreateHookOption, types.EditHookOption]` Actions: `TestHook(ctx, owner, repo, id)` **ContentService** — File read/write via API - `GetFile(ctx, owner, repo, path)` — GET `.../contents/{path}` - `CreateFile(ctx, owner, repo, path, opts)` — POST `.../contents/{path}` - `UpdateFile(ctx, owner, repo, path, opts)` — PUT `.../contents/{path}` - `DeleteFile(ctx, owner, repo, path, opts)` — DELETE `.../contents/{path}` ### Task 16: ActionsService + NotificationService + PackageService **ActionsService** — runners, secrets, variables, workflow dispatch - Repo-level: `.../repos/{owner}/{repo}/actions/{secrets,variables,runners}` - Org-level: `.../orgs/{org}/actions/{secrets,variables,runners}` - `DispatchWorkflow(ctx, owner, repo, workflow, opts)` **NotificationService** — list, mark read - `List(ctx)` — GET `/api/v1/notifications` - `MarkRead(ctx)` — PUT `/api/v1/notifications` - `GetThread(ctx, id)` — GET `.../notifications/threads/{id}` **PackageService** — list, get, delete - `List(ctx, owner)` — GET `/api/v1/packages/{owner}` - `Get(ctx, owner, type, name, version)` — GET `.../packages/{owner}/{type}/{name}/{version}` ### Task 17: WikiService + MiscService + CommitService **WikiService** — pages - `ListPages(ctx, owner, repo)` - `GetPage(ctx, owner, repo, pageName)` - `CreatePage(ctx, owner, repo, opts)` - `EditPage(ctx, owner, repo, pageName, opts)` - `DeletePage(ctx, owner, repo, pageName)` **MiscService** — markdown, licenses, gitignore, nodeinfo - `RenderMarkdown(ctx, text, mode)` — POST `/api/v1/markdown` - `ListLicenses(ctx)` — GET `/api/v1/licenses` - `ListGitignoreTemplates(ctx)` — GET `/api/v1/gitignore/templates` - `NodeInfo(ctx)` — GET `/api/v1/nodeinfo` **CommitService** — status and notes - `GetCombinedStatus(ctx, owner, repo, ref)` - `CreateStatus(ctx, owner, repo, sha, opts)` - `SetNote(ctx, owner, repo, sha, opts)` For each task in Wave 4: write tests first, implement, verify all tests pass, commit. Run after each task: `cd /Users/snider/Code/go-forge && go test ./... -v` --- ## Wave 5: Clean Up + Services Stub Removal (Task 18) ### Task 18: Remove stubs + final wiring **Files:** - Delete: `services_stub.go` - Modify: `forge.go` — replace all stub initialisations with real `newXxxService(c)` calls **Step 1: Remove services_stub.go** Delete the file. All service types should now be defined in their own files. **Step 2: Wire all services in forge.go** Update `NewForge()` to call `newXxxService(c)` for every service. **Step 3: Run all tests** Run: `cd /Users/snider/Code/go-forge && go test ./... -v -count=1` Expected: All tests pass **Step 4: Commit** ```bash git add -A git commit -m "feat: wire all 17 services, remove stubs Co-Authored-By: Virgil " ``` --- ## Wave 6: Integration + Forge Repo Setup (Tasks 19-20) ### Task 19: Create Forge repo + push **Step 1: Create repo on Forge** Use the Forgejo API or web UI to create `core/go-forge` on `forge.lthn.ai`. **Step 2: Add remote and push** ```bash cd /Users/snider/Code/go-forge git remote add forge ssh://git@forge.lthn.ai:2223/core/go-forge.git git push -u forge main ``` ### Task 20: Wiki documentation (go-ai treatment) Create wiki pages for go-forge on Forge, matching the go-ai documentation pattern: 1. **Home** — Overview, install, quick start 2. **Architecture** — Generic Resource[T,C,U], codegen pipeline, service pattern 3. **Services** — All 17 services with example usage 4. **Code Generation** — How to regenerate types, upgrade Forgejo version 5. **Configuration** — Env vars, config file, flags 6. **Error Handling** — APIError, IsNotFound, IsForbidden 7. **Development** — Contributing, testing, releasing Use the Forge wiki API: `POST /api/v1/repos/core/go-forge/wiki/new` with `{"content_base64":"...","title":"..."}`. --- ## Dependency Sequencing ``` Task 1 (scaffold) ← Task 2 (client) ← Task 3 (pagination) ← Task 4 (params) ← Task 5 (resource) Task 1 ← Task 7 (parser) ← Task 8 (generator) ← Task 9 (generate types) Task 5 + Task 9 ← Task 6 (config) ← Task 10 (forge + repos) Task 10 ← Task 11 (issues + PRs) Task 10 ← Task 12 (orgs + teams + users) Task 10 ← Task 13 (admin) Task 10 ← Task 14-17 (extended services) Task 14-17 ← Task 18 (remove stubs) Task 18 ← Task 19 (forge push) Task 19 ← Task 20 (wiki) ``` **Wave 1 (Tasks 1-6)**: Foundation — all independent once scaffolded **Wave 2 (Tasks 7-9)**: Codegen — sequential (parser → generator → run) **Wave 3 (Tasks 10-13)**: Core services — Task 10 first (creates Forge + stubs), then 11-13 parallel **Wave 4 (Tasks 14-17)**: Extended services — all parallel after Task 10 **Wave 5 (Task 18)**: Clean up — after all services done **Wave 6 (Tasks 19-20)**: Ship — after clean up ## Verification After all tasks: 1. `cd /Users/snider/Code/go-forge && go test ./... -count=1` — all pass 2. `go build ./...` — compiles cleanly 3. `go vet ./...` — no issues 4. Verify `types/` contains generated files with `Repository`, `Issue`, `PullRequest`, etc. 5. Verify `NewForge()` creates client with all 17 services populated 6. Verify action methods exist (Fork, Merge, Pin, etc.)