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