go/docs/plans/2026-02-21-go-forge-plan.md
Snider 2aff7a3503 docs: add go-forge design and implementation plan
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>
2026-02-21 15:18:27 +00:00

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}/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)

OrgServiceResource[types.Organization, types.CreateOrgOption, types.EditOrgOption] Path: /api/v1/orgs/{org} Actions: ListMembers, AddMember, RemoveMember, SetAvatar, Block, Unblock

TeamServiceResource[types.Team, types.CreateTeamOption, types.EditTeamOption] Path: /api/v1/teams/{id} Actions: ListMembers, AddMember, RemoveMember, ListRepos, AddRepo, RemoveRepo

UserServiceResource[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

BranchServiceResource[types.Branch, types.CreateBranchRepoOption, struct{}] Path: /api/v1/repos/{owner}/{repo}/branches/{branch} Additional: BranchProtection CRUD at .../branch_protections/{name}

ReleaseServiceResource[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)

WebhookServiceResource[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

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:

  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.)