diff --git a/docs/plans/2026-02-21-go-forge-design.md b/docs/plans/2026-02-21-go-forge-design.md deleted file mode 100644 index b718629..0000000 --- a/docs/plans/2026-02-21-go-forge-design.md +++ /dev/null @@ -1,286 +0,0 @@ -# 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 deleted file mode 100644 index c6b8240..0000000 --- a/docs/plans/2026-02-21-go-forge-plan.md +++ /dev/null @@ -1,2549 +0,0 @@ -# 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.)