2550 lines
66 KiB
Markdown
2550 lines
66 KiB
Markdown
|
|
# 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Wave 2: Code Generator (Tasks 7-9)
|
||
|
|
|
||
|
|
### Task 7: Swagger spec parser
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cmd/forgegen/main.go`
|
||
|
|
- Create: `cmd/forgegen/parser.go`
|
||
|
|
- Create: `cmd/forgegen/parser_test.go`
|
||
|
|
|
||
|
|
The parser reads swagger.v1.json and extracts type definitions into an intermediate representation.
|
||
|
|
|
||
|
|
**Step 1: Write parser tests**
|
||
|
|
|
||
|
|
```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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Code generator — Go source emission
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cmd/forgegen/generator.go`
|
||
|
|
- Create: `cmd/forgegen/generator_test.go`
|
||
|
|
|
||
|
|
**Step 1: Write generator tests**
|
||
|
|
|
||
|
|
```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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 9: Generate types + verify compilation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `types/` directory with generated files
|
||
|
|
- Create: `types/generate.go` (go:generate directive)
|
||
|
|
|
||
|
|
**Step 1: Run the generator**
|
||
|
|
|
||
|
|
```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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Wave 3: Core Services (Tasks 10-13)
|
||
|
|
|
||
|
|
Each service follows the same pattern: embed `Resource[T,C,U]`, add action methods. The first service (Task 10) is fully detailed as a template. Subsequent services follow the same structure with less repetition.
|
||
|
|
|
||
|
|
### Task 10: Forge client + RepoService (template service)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `forge.go`
|
||
|
|
- Create: `repos.go`
|
||
|
|
- Create: `forge_test.go`
|
||
|
|
|
||
|
|
**Step 1: Write tests**
|
||
|
|
|
||
|
|
```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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 11: IssueService + PullService
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `issues.go`
|
||
|
|
- Create: `pulls.go`
|
||
|
|
- Create: `issues_test.go`
|
||
|
|
- Create: `pulls_test.go`
|
||
|
|
- Modify: `forge.go` (wire up services)
|
||
|
|
- Modify: `services_stub.go` (remove IssueService, PullService stubs)
|
||
|
|
|
||
|
|
Follow the same pattern as Task 10. Key points:
|
||
|
|
|
||
|
|
**IssueService** embeds `Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption]`.
|
||
|
|
Path: `/api/v1/repos/{owner}/{repo}/issues/{index}`
|
||
|
|
|
||
|
|
Action methods (9):
|
||
|
|
- `Pin(ctx, owner, repo, index)` — POST `.../issues/{index}/pin`
|
||
|
|
- `Unpin(ctx, owner, repo, index)` — DELETE `.../issues/{index}/pin`
|
||
|
|
- `SetDeadline(ctx, owner, repo, index, deadline)` — POST `.../issues/{index}/deadline`
|
||
|
|
- `AddReaction(ctx, owner, repo, index, reaction)` — POST `.../issues/{index}/reactions`
|
||
|
|
- `DeleteReaction(ctx, owner, repo, index, reaction)` — DELETE `.../issues/{index}/reactions`
|
||
|
|
- `StartStopwatch(ctx, owner, repo, index)` — POST `.../issues/{index}/stopwatch/start`
|
||
|
|
- `StopStopwatch(ctx, owner, repo, index)` — POST `.../issues/{index}/stopwatch/stop`
|
||
|
|
- `AddLabels(ctx, owner, repo, index, labelIDs)` — POST `.../issues/{index}/labels`
|
||
|
|
- `RemoveLabel(ctx, owner, repo, index, labelID)` — DELETE `.../issues/{index}/labels/{id}`
|
||
|
|
- `ListComments(ctx, owner, repo, index)` — GET `.../issues/{index}/comments`
|
||
|
|
- `CreateComment(ctx, owner, repo, index, body)` — POST `.../issues/{index}/comments`
|
||
|
|
|
||
|
|
**PullService** embeds `Resource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption]`.
|
||
|
|
Path: `/api/v1/repos/{owner}/{repo}/pulls/{index}`
|
||
|
|
|
||
|
|
Action methods (6):
|
||
|
|
- `Merge(ctx, owner, repo, index, method)` — POST `.../pulls/{index}/merge`
|
||
|
|
- `Update(ctx, owner, repo, index)` — POST `.../pulls/{index}/update`
|
||
|
|
- `ListReviews(ctx, owner, repo, index)` — GET `.../pulls/{index}/reviews`
|
||
|
|
- `SubmitReview(ctx, owner, repo, index, reviewID)` — POST `.../pulls/{index}/reviews/{id}`
|
||
|
|
- `DismissReview(ctx, owner, repo, index, reviewID, msg)` — POST `.../pulls/{index}/reviews/{id}/dismissals`
|
||
|
|
- `UndismissReview(ctx, owner, repo, index, reviewID)` — POST `.../pulls/{index}/reviews/{id}/undismissals`
|
||
|
|
|
||
|
|
Write tests for at least: List, Get, Create for each service + one action method each.
|
||
|
|
|
||
|
|
Run: `cd /Users/snider/Code/go-forge && go test ./... -v`
|
||
|
|
Commit: `git commit -m "feat: IssueService and PullService with actions"`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 12: OrgService + TeamService + UserService
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `orgs.go`, `teams.go`, `users.go`
|
||
|
|
- Create: `orgs_test.go`, `teams_test.go`, `users_test.go`
|
||
|
|
- Modify: `forge.go` (wire up)
|
||
|
|
- Modify: `services_stub.go` (remove stubs)
|
||
|
|
|
||
|
|
**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 <virgil@lethean.io>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Wave 6: Integration + Forge Repo Setup (Tasks 19-20)
|
||
|
|
|
||
|
|
### Task 19: Create Forge repo + push
|
||
|
|
|
||
|
|
**Step 1: Create repo on Forge**
|
||
|
|
|
||
|
|
Use the Forgejo API or web UI to create `core/go-forge` on `forge.lthn.ai`.
|
||
|
|
|
||
|
|
**Step 2: Add remote and push**
|
||
|
|
|
||
|
|
```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.)
|