Full-coverage Forgejo API client (450 endpoints, 229 types). Generic Resource[T,C,U] for 91% CRUD + codegen from swagger.v1.json. 20-task plan across 6 waves. Co-Authored-By: Virgil <virgil@lethean.io>
66 KiB
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
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
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
// 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
git add -A
git commit -m "feat: scaffold go-forge repo with pinned swagger spec
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 2: HTTP Client
Files:
- Create:
client.go - Create:
client_test.go
Step 1: Write client tests
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
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
git add client.go client_test.go
git commit -m "feat: HTTP client with auth, context, error handling
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 3: Pagination
Files:
- Create:
pagination.go - Create:
pagination_test.go
Step 1: Write pagination tests
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
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
git add pagination.go pagination_test.go
git commit -m "feat: generic pagination with ListAll and ListPage
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 4: Params and path resolution
Files:
- Create:
params.go - Create:
params_test.go
Step 1: Write tests
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
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
git add params.go params_test.go
git commit -m "feat: path parameter resolution with URL encoding
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 5: Generic Resource[T, C, U]
Files:
- Create:
resource.go - Create:
resource_test.go
Step 1: Write resource tests
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
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
git add resource.go resource_test.go
git commit -m "feat: generic Resource[T,C,U] for CRUD operations
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 6: Config resolution (extracted from go-scm)
Files:
- Create:
config.go - Create:
config_test.go
Step 1: Write config tests
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
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
git add config.go config_test.go
git commit -m "feat: config resolution from env vars and flags
Co-Authored-By: Virgil <virgil@lethean.io>"
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
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
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
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
git add cmd/forgegen/
git commit -m "feat: swagger spec parser for type extraction
Co-Authored-By: Virgil <virgil@lethean.io>"
Task 8: Code generator — Go source emission
Files:
- Create:
cmd/forgegen/generator.go - Create:
cmd/forgegen/generator_test.go
Step 1: Write generator tests
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.
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
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 <virgil@lethean.io>"
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
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:
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
git add types/
git commit -m "feat: generate all 229 Forgejo API types from swagger spec
Co-Authored-By: Virgil <virgil@lethean.io>"
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
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
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
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:
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
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 <virgil@lethean.io>"
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}/pinUnpin(ctx, owner, repo, index)— DELETE.../issues/{index}/pinSetDeadline(ctx, owner, repo, index, deadline)— POST.../issues/{index}/deadlineAddReaction(ctx, owner, repo, index, reaction)— POST.../issues/{index}/reactionsDeleteReaction(ctx, owner, repo, index, reaction)— DELETE.../issues/{index}/reactionsStartStopwatch(ctx, owner, repo, index)— POST.../issues/{index}/stopwatch/startStopStopwatch(ctx, owner, repo, index)— POST.../issues/{index}/stopwatch/stopAddLabels(ctx, owner, repo, index, labelIDs)— POST.../issues/{index}/labelsRemoveLabel(ctx, owner, repo, index, labelID)— DELETE.../issues/{index}/labels/{id}ListComments(ctx, owner, repo, index)— GET.../issues/{index}/commentsCreateComment(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}/mergeUpdate(ctx, owner, repo, index)— POST.../pulls/{index}/updateListReviews(ctx, owner, repo, index)— GET.../pulls/{index}/reviewsSubmitReview(ctx, owner, repo, index, reviewID)— POST.../pulls/{index}/reviews/{id}DismissReview(ctx, owner, repo, index, reviewID, msg)— POST.../pulls/{index}/reviews/{id}/dismissalsUndismissReview(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/usersCreateUser(ctx, opts)— POST/api/v1/admin/usersEditUser(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}/renameListOrgs(ctx)— GET/api/v1/admin/orgsRunCron(ctx, task)— POST/api/v1/admin/cron/{task}ListCron(ctx)— GET/api/v1/admin/cronAdoptRepo(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/notificationsMarkRead(ctx)— PUT/api/v1/notificationsGetThread(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/markdownListLicenses(ctx)— GET/api/v1/licensesListGitignoreTemplates(ctx)— GET/api/v1/gitignore/templatesNodeInfo(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 realnewXxxService(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
git add -A
git commit -m "feat: wire all 17 services, remove stubs
Co-Authored-By: Virgil <virgil@lethean.io>"
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
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:
- Home — Overview, install, quick start
- Architecture — Generic Resource[T,C,U], codegen pipeline, service pattern
- Services — All 17 services with example usage
- Code Generation — How to regenerate types, upgrade Forgejo version
- Configuration — Env vars, config file, flags
- Error Handling — APIError, IsNotFound, IsForbidden
- 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:
cd /Users/snider/Code/go-forge && go test ./... -count=1— all passgo build ./...— compiles cleanlygo vet ./...— no issues- Verify
types/contains generated files withRepository,Issue,PullRequest, etc. - Verify
NewForge()creates client with all 17 services populated - Verify action methods exist (Fork, Merge, Pin, etc.)