docs: add human-friendly documentation
All checks were successful
Security Scan / security (push) Successful in 6s
Test / test (push) Successful in 1m8s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-11 13:02:40 +00:00
parent 72fb4e3b8e
commit 761493c7f5
3 changed files with 664 additions and 0 deletions

281
docs/architecture.md Normal file
View file

@ -0,0 +1,281 @@
---
title: Architecture
description: Internals of go-forge — the HTTP client, generic Resource pattern, pagination, code generation, and error handling.
---
# Architecture
This document explains how go-forge is structured internally. It covers the layered design, key types, data flow for a typical API call, and how types are generated from the Forgejo swagger specification.
## Layered design
go-forge is organised in three layers, each building on the one below:
```
┌─────────────────────────────────────────────────┐
│ Forge (top-level client) │
│ Aggregates 18 service structs │
├─────────────────────────────────────────────────┤
│ Service layer │
│ RepoService, IssueService, PullService, ... │
│ Embed Resource[T,C,U] or hold a *Client │
├─────────────────────────────────────────────────┤
│ Foundation layer │
│ Client (HTTP), Resource[T,C,U] (generics), │
│ Pagination, Params, Config │
└─────────────────────────────────────────────────┘
```
## Client — the HTTP layer
`Client` (`client.go`) is the lowest-level component. It handles:
- **Authentication** — every request includes an `Authorization: token <token>` header.
- **JSON marshalling** — request bodies are marshalled to JSON; responses are decoded from JSON.
- **Error parsing** — HTTP 4xx/5xx responses are converted to `*APIError` with status code, message, and URL.
- **Rate limit tracking**`X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers are captured after each request.
- **Context propagation** — all methods accept `context.Context` as their first parameter.
### Key methods
```go
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
func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error)
func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error)
```
The `Raw` variants return the response body as `[]byte` instead of decoding JSON. This is used by endpoints that return non-JSON content (e.g. the markdown rendering endpoint returns raw HTML).
### Options
The client supports functional options:
```go
f := forge.NewForge("https://forge.lthn.ai", "token",
forge.WithHTTPClient(customHTTPClient),
forge.WithUserAgent("my-agent/1.0"),
)
```
## APIError — structured error handling
All API errors are returned as `*APIError`:
```go
type APIError struct {
StatusCode int
Message string
URL string
}
```
Three helper functions allow classification without type-asserting:
```go
forge.IsNotFound(err) // 404
forge.IsForbidden(err) // 403
forge.IsConflict(err) // 409
```
These use `errors.As` internally, so they work correctly with wrapped errors.
## Params — path variable resolution
API paths contain `{placeholders}` (e.g. `/api/v1/repos/{owner}/{repo}`). The `Params` type is a `map[string]string` that resolves these:
```go
type Params map[string]string
path := forge.ResolvePath("/api/v1/repos/{owner}/{repo}", forge.Params{
"owner": "core",
"repo": "go-forge",
})
// Result: "/api/v1/repos/core/go-forge"
```
Values are URL-path-escaped to handle special characters safely.
## Resource[T, C, U] — the generic CRUD core
The heart of the library is `Resource[T, C, U]` (`resource.go`), a generic struct parameterised on three types:
- **T** — the resource type (e.g. `types.Repository`)
- **C** — the create-options type (e.g. `types.CreateRepoOption`)
- **U** — the update-options type (e.g. `types.EditRepoOption`)
It provides seven methods that map directly to REST operations:
| Method | HTTP verb | Description |
|-----------|-----------|------------------------------------------|
| `List` | GET | Single page of results with metadata |
| `ListAll` | GET | All results across all pages |
| `Iter` | GET | `iter.Seq2[T, error]` iterator over all |
| `Get` | GET | Single resource by path params |
| `Create` | POST | Create a new resource |
| `Update` | PATCH | Modify an existing resource |
| `Delete` | DELETE | Remove a resource |
Services embed this struct to inherit CRUD for free:
```go
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}",
),
}
}
```
Services that do not fit the CRUD pattern (e.g. `AdminService`, `LabelService`, `NotificationService`) hold a `*Client` directly and implement methods by hand.
## Pagination
`pagination.go` provides three generic functions for paginated endpoints:
### ListPage — single page
```go
func ListPage[T any](ctx context.Context, c *Client, path string, query map[string]string, opts ListOptions) (*PagedResult[T], error)
```
Returns a `PagedResult[T]` containing:
- `Items []T` — the results on this page
- `TotalCount int` — from the `X-Total-Count` response header
- `Page int` — the current page number
- `HasMore bool` — whether more pages exist
`ListOptions` controls the page number (1-based) and items per page:
```go
var DefaultList = ListOptions{Page: 1, Limit: 50}
```
### ListAll — all pages
```go
func ListAll[T any](ctx context.Context, c *Client, path string, query map[string]string) ([]T, error)
```
Fetches every page sequentially and returns the concatenated slice. Uses a page size of 50.
### ListIter — range-over-func iterator
```go
func ListIter[T any](ctx context.Context, c *Client, path string, query map[string]string) iter.Seq2[T, error]
```
Returns a Go 1.23+ range-over-func iterator that lazily fetches pages as you consume items:
```go
for repo, err := range f.Repos.IterOrgRepos(ctx, "core") {
if err != nil {
log.Fatal(err)
}
fmt.Println(repo.Name)
}
```
## Data flow of a typical API call
Here is the path a call like `f.Repos.Get(ctx, params)` takes:
```
1. Caller invokes f.Repos.Get(ctx, Params{"owner":"core", "repo":"go-forge"})
2. Resource.Get calls ResolvePath(r.path, params)
"/api/v1/repos/{owner}/{repo}" -> "/api/v1/repos/core/go-forge"
3. Resource.Get calls Client.Get(ctx, resolvedPath, &out)
4. Client.doJSON builds http.Request:
- Method: GET
- URL: baseURL + resolvedPath
- Headers: Authorization, Accept, User-Agent
5. Client.httpClient.Do(req) sends the request
6. Client reads response:
- Updates rate limit from headers
- If status >= 400: parseError -> return *APIError
- If status < 400: json.Decode into &out
7. Resource.Get returns (*T, error) to caller
```
## Code generation — types/
The `types/` package contains 229 Go types generated from Forgejo's `swagger.v1.json` specification. The code generator lives at `cmd/forgegen/`.
### Pipeline
```
swagger.v1.json --> parser.go --> GoType/GoField IR --> generator.go --> types/*.go
LoadSpec() ExtractTypes() Generate()
DetectCRUDPairs()
```
1. **parser.go**`LoadSpec()` reads the JSON spec. `ExtractTypes()` converts swagger definitions into an intermediate representation (`GoType` with `GoField` children). `DetectCRUDPairs()` finds matching `Create*Option`/`Edit*Option` pairs.
2. **generator.go**`Generate()` groups types by domain using prefix matching (the `typeGrouping` map), then renders each group into a separate `.go` file using `text/template`.
### Running the generator
```bash
go generate ./types/...
```
Or manually:
```bash
go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/
```
The `generate.go` file in `types/` contains the `//go:generate` directive:
```go
//go:generate go run ../cmd/forgegen/ -spec ../testdata/swagger.v1.json -out .
```
### Type grouping
Types are distributed across 36 files based on a name-prefix mapping. For example, types starting with `Repository` or `Repo` go into `repo.go`, types starting with `Issue` go into `issue.go`, and so on. The `classifyType()` function also strips `Create`/`Edit`/`Delete` prefixes and `Option` suffixes before matching, so `CreateRepoOption` lands in `repo.go` alongside `Repository`.
### Type mapping from swagger to Go
| Swagger type | Swagger format | Go type |
|---------------|----------------|-------------|
| string | (none) | `string` |
| string | date-time | `time.Time` |
| string | binary | `[]byte` |
| integer | int64 | `int64` |
| integer | int32 | `int32` |
| integer | (none) | `int` |
| number | float | `float32` |
| number | (none) | `float64` |
| boolean | — | `bool` |
| array | — | `[]T` |
| object | — | `map[string]any` |
| `$ref` | — | `*RefType` |
## Config resolution
`config.go` provides `ResolveConfig()` which resolves the Forgejo URL and API token with the following priority:
1. Explicit flag values (passed as function arguments)
2. Environment variables (`FORGE_URL`, `FORGE_TOKEN`)
3. Built-in defaults (`http://localhost:3000` for URL; no default for token)
`NewForgeFromConfig()` wraps this into a one-liner that returns an error if no token is available from any source.

232
docs/development.md Normal file
View file

@ -0,0 +1,232 @@
---
title: Development
description: Building, testing, linting, and contributing to go-forge.
---
# Development
This guide covers everything needed to build, test, and contribute to go-forge.
## Prerequisites
- **Go 1.26** or later
- **golangci-lint** (recommended for linting)
- A Forgejo instance and API token (only needed for manual/integration testing — the test suite uses `httptest` and requires no live server)
## Building
go-forge is a library, so there is nothing to compile for normal use. The only binary in the repository is the code generator:
```bash
go build ./cmd/forgegen/
```
The `core build` CLI can also produce cross-compiled binaries of the generator for distribution. Build configuration is in `.core/build.yaml`:
```bash
core build # Builds forgegen for all configured targets
```
## Running tests
All tests use the standard `testing` package with `net/http/httptest` for HTTP stubbing. No live Forgejo instance is required.
```bash
# Run the full suite
go test ./...
# Run a specific test by name
go test -v -run TestClient_Good_Get ./...
# Run tests with race detection
go test -race ./...
# Generate a coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
Alternatively, if you have the `core` CLI installed:
```bash
core go test
core go cov # Generate coverage
core go cov --open # Open coverage report in browser
```
### Test naming convention
Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern:
- **`_Good`** — Happy-path tests confirming correct behaviour.
- **`_Bad`** — Expected error conditions (e.g. 404, 500 responses).
- **`_Ugly`** — Edge cases, panics, and boundary conditions.
Examples:
```
TestClient_Good_Get
TestClient_Bad_ServerError
TestClient_Bad_NotFound
TestClient_Good_ContextCancellation
TestResource_Good_ListAll
```
## Linting
The project uses `golangci-lint` with the configuration in `.golangci.yml`:
```bash
golangci-lint run ./...
```
Or with the `core` CLI:
```bash
core go lint
```
Enabled linters: `govet`, `errcheck`, `staticcheck`, `unused`, `gosimple`, `ineffassign`, `typecheck`, `gocritic`, `gofmt`.
## Formatting and vetting
```bash
gofmt -w .
go vet ./...
```
Or:
```bash
core go fmt
core go vet
```
For a full quality-assurance pass (format, vet, lint, and test):
```bash
core go qa # Standard checks
core go qa full # Adds race detection, vulnerability scanning, and security checks
```
## Regenerating types
When the Forgejo API changes (after a Forgejo upgrade), regenerate the types:
1. Download the updated `swagger.v1.json` from your Forgejo instance at `/swagger.json` and place it in `testdata/swagger.v1.json`.
2. Run the generator:
```bash
go generate ./types/...
```
Or manually:
```bash
go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/
```
3. Review the diff. The generator produces deterministic output (types are sorted alphabetically within each file), so the diff will show only genuine API changes.
4. Run the test suite to confirm nothing is broken.
The `types/generate.go` file holds the `//go:generate` directive that wires everything together:
```go
//go:generate go run ../cmd/forgegen/ -spec ../testdata/swagger.v1.json -out .
```
## Adding a new service
To add coverage for a new Forgejo API domain:
1. **Create the service file** (e.g. `topics.go`). If the API follows a standard CRUD pattern, embed `Resource[T, C, U]`:
```go
type TopicService struct {
Resource[types.Topic, types.CreateTopicOption, types.EditTopicOption]
}
func newTopicService(c *Client) *TopicService {
return &TopicService{
Resource: *NewResource[types.Topic, types.CreateTopicOption, types.EditTopicOption](
c, "/api/v1/repos/{owner}/{repo}/topics/{topic}",
),
}
}
```
If the endpoints are heterogeneous, hold a `*Client` directly instead:
```go
type TopicService struct {
client *Client
}
```
2. **Add action methods** for any operations beyond standard CRUD:
```go
func (s *TopicService) ListRepoTopics(ctx context.Context, owner, repo string) ([]types.Topic, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
return ListAll[types.Topic](ctx, s.client, path, nil)
}
```
3. **Wire it up** in `forge.go`:
- Add a field to the `Forge` struct.
- Initialise it in `NewForge()`.
4. **Write tests** in `topics_test.go` using `httptest.NewServer` to stub the HTTP responses.
5. Every list method should provide both a `ListX` (returns `[]T`) and an `IterX` (returns `iter.Seq2[T, error]`) variant.
## Coding standards
- **UK English** in all comments and documentation: organisation, colour, centre, licence (noun).
- **`context.Context`** as the first parameter of every exported method.
- **Errors** wrapped as `*APIError` with `StatusCode`, `Message`, and `URL`.
- **Conventional commits**: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`.
- **Generated code** must not be edited by hand. All changes go through the swagger spec and the generator.
- **Licence**: EUPL-1.2. All contributions are licensed under the same terms.
## Commit messages
Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
feat: add topic service for repository topics
fix: handle empty response body in DeleteWithBody
docs: update architecture diagram for pagination
refactor: extract rate limit parsing into helper
chore: regenerate types from Forgejo 10.1 swagger spec
```
Include the co-author trailer:
```
Co-Authored-By: Virgil <virgil@lethean.io>
```
## Project structure at a glance
```
.
├── .core/
│ ├── build.yaml Build targets for the forgegen binary
│ └── release.yaml Release publishing configuration
├── .golangci.yml Linter configuration
├── cmd/forgegen/ Code generator (swagger -> Go types)
├── testdata/ swagger.v1.json spec file
├── types/ 229 generated types (36 files)
├── *.go Library source (client, services, pagination)
└── *_test.go Tests (httptest-based, no live server needed)
```

151
docs/index.md Normal file
View file

@ -0,0 +1,151 @@
---
title: go-forge
description: Full-coverage Go client for the Forgejo API with generics-based CRUD, pagination, and code-generated types.
---
# go-forge
`forge.lthn.ai/core/go-forge` is a Go client library for the [Forgejo](https://forgejo.org) REST API. It provides typed access to 18 API domains (repositories, issues, pull requests, organisations, and more) through a single top-level `Forge` client. Types are generated directly from Forgejo's `swagger.v1.json` specification, keeping the library in lockstep with the server.
**Module path:** `forge.lthn.ai/core/go-forge`
**Go version:** 1.26+
**Licence:** EUPL-1.2
## Quick start
```go
package main
import (
"context"
"fmt"
"log"
"forge.lthn.ai/core/go-forge"
)
func main() {
// Create a client with your Forgejo URL and API token.
f := forge.NewForge("https://forge.lthn.ai", "your-token")
ctx := context.Background()
// List repositories for an organisation (first page, 50 per page).
result, err := f.Repos.List(ctx, forge.Params{"org": "core"}, forge.DefaultList)
if err != nil {
log.Fatal(err)
}
for _, repo := range result.Items {
fmt.Println(repo.Name)
}
// Get a single repository.
repo, err := f.Repos.Get(ctx, forge.Params{"owner": "core", "repo": "go-forge"})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s — %s\n", repo.FullName, repo.Description)
}
```
### Configuration from environment
If you prefer to resolve the URL and token from environment variables rather than hard-coding them, use `NewForgeFromConfig`:
```go
// Priority: flags > env (FORGE_URL, FORGE_TOKEN) > defaults (http://localhost:3000)
f, err := forge.NewForgeFromConfig("", "", forge.WithUserAgent("my-tool/1.0"))
if err != nil {
log.Fatal(err) // no token configured
}
```
Environment variables:
| Variable | Purpose | Default |
|---------------|--------------------------------------|--------------------------|
| `FORGE_URL` | Base URL of the Forgejo instance | `http://localhost:3000` |
| `FORGE_TOKEN` | API token for authentication | (none -- required) |
## Package layout
```
go-forge/
├── client.go HTTP client, auth, error handling, rate limits
├── config.go Config resolution: flags > env > defaults
├── forge.go Top-level Forge struct aggregating all 18 services
├── resource.go Generic Resource[T, C, U] for CRUD operations
├── pagination.go ListPage, ListAll, ListIter — paginated requests
├── params.go Path variable resolution ({owner}/{repo} -> values)
├── repos.go RepoService — repositories, forks, transfers, mirrors
├── issues.go IssueService — issues, comments, labels, reactions
├── pulls.go PullService — pull requests, merges, reviews
├── orgs.go OrgService — organisations, members
├── users.go UserService — users, followers, stars
├── teams.go TeamService — teams, members, repositories
├── admin.go AdminService — site admin, cron, user management
├── branches.go BranchService — branches, branch protections
├── releases.go ReleaseService — releases, assets, tags
├── labels.go LabelService — repo and org labels
├── webhooks.go WebhookService — repo and org webhooks
├── notifications.go NotificationService — notifications, threads
├── packages.go PackageService — package registry
├── actions.go ActionsService — CI/CD secrets, variables, dispatches
├── contents.go ContentService — file read/write/delete
├── wiki.go WikiService — wiki pages
├── commits.go CommitService — statuses, notes
├── misc.go MiscService — markdown, licences, gitignore, version
├── types/ 229 generated Go types from swagger.v1.json
│ ├── generate.go go:generate directive
│ ├── repo.go Repository, CreateRepoOption, EditRepoOption, ...
│ ├── issue.go Issue, CreateIssueOption, ...
│ ├── pr.go PullRequest, CreatePullRequestOption, ...
│ └── ... (36 files total, grouped by domain)
├── cmd/forgegen/ Code generator: swagger spec -> types/*.go
│ ├── main.go CLI entry point
│ ├── parser.go Swagger spec parsing, type extraction, CRUD pair detection
│ └── generator.go Template-based Go source file generation
└── testdata/
└── swagger.v1.json Forgejo API specification (input for codegen)
```
## Services
The `Forge` struct exposes 18 service fields, each handling a different API domain:
| Service | Struct | Embedding | Domain |
|-----------------|---------------------|----------------------------------|--------------------------------------|
| `Repos` | `RepoService` | `Resource[Repository, ...]` | Repositories, forks, transfers |
| `Issues` | `IssueService` | `Resource[Issue, ...]` | Issues, comments, labels, reactions |
| `Pulls` | `PullService` | `Resource[PullRequest, ...]` | Pull requests, merges, reviews |
| `Orgs` | `OrgService` | `Resource[Organization, ...]` | Organisations, members |
| `Users` | `UserService` | `Resource[User, ...]` | Users, followers, stars |
| `Teams` | `TeamService` | `Resource[Team, ...]` | Teams, members, repos |
| `Admin` | `AdminService` | (standalone) | Site admin, cron, user management |
| `Branches` | `BranchService` | `Resource[Branch, ...]` | Branches, protections |
| `Releases` | `ReleaseService` | `Resource[Release, ...]` | Releases, assets, tags |
| `Labels` | `LabelService` | (standalone) | Repo and org labels |
| `Webhooks` | `WebhookService` | `Resource[Hook, ...]` | Repo and org webhooks |
| `Notifications` | `NotificationService` | (standalone) | Notifications, threads |
| `Packages` | `PackageService` | (standalone) | Package registry |
| `Actions` | `ActionsService` | (standalone) | CI/CD secrets, variables, dispatches |
| `Contents` | `ContentService` | (standalone) | File read/write/delete |
| `Wiki` | `WikiService` | (standalone) | Wiki pages |
| `Commits` | `CommitService` | (standalone) | Commit statuses, git notes |
| `Misc` | `MiscService` | (standalone) | Markdown, licences, gitignore, version |
Services that embed `Resource[T, C, U]` inherit `List`, `ListAll`, `Iter`, `Get`, `Create`, `Update`, and `Delete` methods automatically. Standalone services have hand-written methods because their API endpoints are heterogeneous and do not fit a uniform CRUD pattern.
## Dependencies
This module has **zero external dependencies**. It relies solely on the Go standard library (`net/http`, `encoding/json`, `context`, `iter`, etc.) and requires Go 1.26 or later.
```
module forge.lthn.ai/core/go-forge
go 1.26.0
```