diff --git a/client.go b/client.go
index 36f14e4..715cd72 100644
--- a/client.go
+++ b/client.go
@@ -105,6 +105,50 @@ func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) erro
return c.do(ctx, http.MethodDelete, path, body, nil)
}
+// PostRaw performs a POST request with a JSON body and returns the raw
+// response body as bytes instead of JSON-decoding. Useful for endpoints
+// such as /markdown that return raw HTML text.
+func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error) {
+ url := c.baseURL + path
+
+ var bodyReader io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("forge: marshal body: %w", err)
+ }
+ bodyReader = bytes.NewReader(data)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("forge: create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "token "+c.token)
+ 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 nil, fmt.Errorf("forge: request POST %s: %w", path, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ return nil, c.parseError(resp, path)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("forge: read response body: %w", err)
+ }
+
+ return data, nil
+}
+
// GetRaw performs a GET request and returns the raw response body as bytes
// instead of JSON-decoding. Useful for endpoints that return raw file content.
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) {
diff --git a/commits.go b/commits.go
new file mode 100644
index 0000000..95fa8d2
--- /dev/null
+++ b/commits.go
@@ -0,0 +1,59 @@
+package forge
+
+import (
+ "context"
+ "fmt"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+// CommitService handles commit-related operations such as commit statuses
+// and git notes.
+// No Resource embedding — heterogeneous endpoints across status and note paths.
+type CommitService struct {
+ client *Client
+}
+
+func newCommitService(c *Client) *CommitService {
+ return &CommitService{client: c}
+}
+
+// GetCombinedStatus returns the combined status for a given ref (branch, tag, or SHA).
+func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*types.CombinedStatus, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, ref)
+ var out types.CombinedStatus
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// ListStatuses returns all commit statuses for a given ref.
+func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref string) ([]types.CommitStatus, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/statuses", owner, repo, ref)
+ var out []types.CommitStatus
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// CreateStatus creates a new commit status for the given SHA.
+func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha string, opts *types.CreateStatusOption) (*types.CommitStatus, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, sha)
+ var out types.CommitStatus
+ if err := s.client.Post(ctx, path, opts, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// GetNote returns the git note for a given commit SHA.
+func (s *CommitService) GetNote(ctx context.Context, owner, repo, sha string) (*types.Note, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/git/notes/%s", owner, repo, sha)
+ var out types.Note
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
diff --git a/commits_test.go b/commits_test.go
new file mode 100644
index 0000000..d96d467
--- /dev/null
+++ b/commits_test.go
@@ -0,0 +1,167 @@
+package forge
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+func TestCommitService_Good_ListStatuses(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.URL.Path != "/api/v1/repos/core/go-forge/commits/abc123/statuses" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode([]types.CommitStatus{
+ {ID: 1, Context: "ci/build", Description: "Build passed"},
+ {ID: 2, Context: "ci/test", Description: "Tests passed"},
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ statuses, err := f.Commits.ListStatuses(context.Background(), "core", "go-forge", "abc123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(statuses) != 2 {
+ t.Fatalf("got %d statuses, want 2", len(statuses))
+ }
+ if statuses[0].Context != "ci/build" {
+ t.Errorf("got context=%q, want %q", statuses[0].Context, "ci/build")
+ }
+ if statuses[1].Context != "ci/test" {
+ t.Errorf("got context=%q, want %q", statuses[1].Context, "ci/test")
+ }
+}
+
+func TestCommitService_Good_CreateStatus(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)
+ }
+ if r.URL.Path != "/api/v1/repos/core/go-forge/statuses/abc123" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ var opts types.CreateStatusOption
+ if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
+ t.Fatal(err)
+ }
+ if opts.Context != "ci/build" {
+ t.Errorf("got context=%q, want %q", opts.Context, "ci/build")
+ }
+ if opts.Description != "Build passed" {
+ t.Errorf("got description=%q, want %q", opts.Description, "Build passed")
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(types.CommitStatus{
+ ID: 1,
+ Context: "ci/build",
+ Description: "Build passed",
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ status, err := f.Commits.CreateStatus(context.Background(), "core", "go-forge", "abc123", &types.CreateStatusOption{
+ Context: "ci/build",
+ Description: "Build passed",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status.Context != "ci/build" {
+ t.Errorf("got context=%q, want %q", status.Context, "ci/build")
+ }
+ if status.ID != 1 {
+ t.Errorf("got id=%d, want 1", status.ID)
+ }
+}
+
+func TestCommitService_Good_GetNote(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.URL.Path != "/api/v1/repos/core/go-forge/git/notes/abc123" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.Note{
+ Message: "reviewed and approved",
+ Commit: &types.Commit{
+ SHA: "abc123",
+ },
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ note, err := f.Commits.GetNote(context.Background(), "core", "go-forge", "abc123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if note.Message != "reviewed and approved" {
+ t.Errorf("got message=%q, want %q", note.Message, "reviewed and approved")
+ }
+ if note.Commit.SHA != "abc123" {
+ t.Errorf("got commit sha=%q, want %q", note.Commit.SHA, "abc123")
+ }
+}
+
+func TestCommitService_Good_GetCombinedStatus(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.URL.Path != "/api/v1/repos/core/go-forge/statuses/main" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.CombinedStatus{
+ SHA: "abc123",
+ TotalCount: 2,
+ Statuses: []*types.CommitStatus{
+ {ID: 1, Context: "ci/build"},
+ {ID: 2, Context: "ci/test"},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ cs, err := f.Commits.GetCombinedStatus(context.Background(), "core", "go-forge", "main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cs.SHA != "abc123" {
+ t.Errorf("got sha=%q, want %q", cs.SHA, "abc123")
+ }
+ if cs.TotalCount != 2 {
+ t.Errorf("got total_count=%d, want 2", cs.TotalCount)
+ }
+ if len(cs.Statuses) != 2 {
+ t.Fatalf("got %d statuses, want 2", len(cs.Statuses))
+ }
+}
+
+func TestCommitService_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()
+
+ f := NewForge(srv.URL, "tok")
+ _, err := f.Commits.GetNote(context.Background(), "core", "go-forge", "nonexistent")
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !IsNotFound(err) {
+ t.Errorf("expected not-found error, got %v", err)
+ }
+}
diff --git a/forge.go b/forge.go
index 3dfd184..c65689a 100644
--- a/forge.go
+++ b/forge.go
@@ -21,6 +21,7 @@ type Forge struct {
Contents *ContentService
Wiki *WikiService
Misc *MiscService
+ Commits *CommitService
}
// NewForge creates a new Forge client.
@@ -42,8 +43,9 @@ func NewForge(url, token string, opts ...Option) *Forge {
f.Packages = newPackageService(c)
f.Actions = newActionsService(c)
f.Contents = newContentService(c)
- f.Wiki = &WikiService{}
- f.Misc = &MiscService{}
+ f.Wiki = newWikiService(c)
+ f.Misc = newMiscService(c)
+ f.Commits = newCommitService(c)
return f
}
diff --git a/misc.go b/misc.go
new file mode 100644
index 0000000..62b4a83
--- /dev/null
+++ b/misc.go
@@ -0,0 +1,87 @@
+package forge
+
+import (
+ "context"
+ "fmt"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+// MiscService handles miscellaneous Forgejo API endpoints such as
+// markdown rendering, licence templates, gitignore templates, and
+// server metadata.
+// No Resource embedding — heterogeneous read-only endpoints.
+type MiscService struct {
+ client *Client
+}
+
+func newMiscService(c *Client) *MiscService {
+ return &MiscService{client: c}
+}
+
+// RenderMarkdown renders markdown text to HTML. The response is raw HTML
+// text, not JSON.
+func (s *MiscService) RenderMarkdown(ctx context.Context, text, mode string) (string, error) {
+ body := types.MarkdownOption{Text: text, Mode: mode}
+ data, err := s.client.PostRaw(ctx, "/api/v1/markdown", body)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+// ListLicenses returns all available licence templates.
+func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplateListEntry, error) {
+ var out []types.LicensesTemplateListEntry
+ if err := s.client.Get(ctx, "/api/v1/licenses", &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// GetLicense returns a single licence template by name.
+func (s *MiscService) GetLicense(ctx context.Context, name string) (*types.LicenseTemplateInfo, error) {
+ path := fmt.Sprintf("/api/v1/licenses/%s", name)
+ var out types.LicenseTemplateInfo
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// ListGitignoreTemplates returns all available gitignore template names.
+func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, error) {
+ var out []string
+ if err := s.client.Get(ctx, "/api/v1/gitignore/templates", &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// GetGitignoreTemplate returns a single gitignore template by name.
+func (s *MiscService) GetGitignoreTemplate(ctx context.Context, name string) (*types.GitignoreTemplateInfo, error) {
+ path := fmt.Sprintf("/api/v1/gitignore/templates/%s", name)
+ var out types.GitignoreTemplateInfo
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// GetNodeInfo returns the NodeInfo metadata for the Forgejo instance.
+func (s *MiscService) GetNodeInfo(ctx context.Context) (*types.NodeInfo, error) {
+ var out types.NodeInfo
+ if err := s.client.Get(ctx, "/api/v1/nodeinfo", &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// GetVersion returns the server version.
+func (s *MiscService) GetVersion(ctx context.Context) (*types.ServerVersion, error) {
+ var out types.ServerVersion
+ if err := s.client.Get(ctx, "/api/v1/version", &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
diff --git a/misc_test.go b/misc_test.go
new file mode 100644
index 0000000..9d922bc
--- /dev/null
+++ b/misc_test.go
@@ -0,0 +1,227 @@
+package forge
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+func TestMiscService_Good_RenderMarkdown(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)
+ }
+ if r.URL.Path != "/api/v1/markdown" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ var opts types.MarkdownOption
+ if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
+ t.Fatal(err)
+ }
+ if opts.Text != "# Hello" {
+ t.Errorf("got text=%q, want %q", opts.Text, "# Hello")
+ }
+ if opts.Mode != "gfm" {
+ t.Errorf("got mode=%q, want %q", opts.Mode, "gfm")
+ }
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte("
Hello
\n"))
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ html, err := f.Misc.RenderMarkdown(context.Background(), "# Hello", "gfm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "Hello
\n"
+ if html != want {
+ t.Errorf("got %q, want %q", html, want)
+ }
+}
+
+func TestMiscService_Good_GetVersion(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.URL.Path != "/api/v1/version" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.ServerVersion{
+ Version: "1.21.0",
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ ver, err := f.Misc.GetVersion(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ver.Version != "1.21.0" {
+ t.Errorf("got version=%q, want %q", ver.Version, "1.21.0")
+ }
+}
+
+func TestMiscService_Good_ListLicenses(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.URL.Path != "/api/v1/licenses" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode([]types.LicensesTemplateListEntry{
+ {Key: "mit", Name: "MIT License"},
+ {Key: "gpl-3.0", Name: "GNU General Public License v3.0"},
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ licenses, err := f.Misc.ListLicenses(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(licenses) != 2 {
+ t.Fatalf("got %d licenses, want 2", len(licenses))
+ }
+ if licenses[0].Key != "mit" {
+ t.Errorf("got key=%q, want %q", licenses[0].Key, "mit")
+ }
+ if licenses[1].Key != "gpl-3.0" {
+ t.Errorf("got key=%q, want %q", licenses[1].Key, "gpl-3.0")
+ }
+}
+
+func TestMiscService_Good_GetLicense(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.URL.Path != "/api/v1/licenses/mit" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.LicenseTemplateInfo{
+ Key: "mit",
+ Name: "MIT License",
+ Body: "MIT License body text...",
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ lic, err := f.Misc.GetLicense(context.Background(), "mit")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lic.Key != "mit" {
+ t.Errorf("got key=%q, want %q", lic.Key, "mit")
+ }
+ if lic.Name != "MIT License" {
+ t.Errorf("got name=%q, want %q", lic.Name, "MIT License")
+ }
+}
+
+func TestMiscService_Good_ListGitignoreTemplates(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.URL.Path != "/api/v1/gitignore/templates" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode([]string{"Go", "Python", "Node"})
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ names, err := f.Misc.ListGitignoreTemplates(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 3 {
+ t.Fatalf("got %d templates, want 3", len(names))
+ }
+ if names[0] != "Go" {
+ t.Errorf("got [0]=%q, want %q", names[0], "Go")
+ }
+}
+
+func TestMiscService_Good_GetGitignoreTemplate(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.URL.Path != "/api/v1/gitignore/templates/Go" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.GitignoreTemplateInfo{
+ Name: "Go",
+ Source: "*.exe\n*.test\n/vendor/",
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ tmpl, err := f.Misc.GetGitignoreTemplate(context.Background(), "Go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tmpl.Name != "Go" {
+ t.Errorf("got name=%q, want %q", tmpl.Name, "Go")
+ }
+}
+
+func TestMiscService_Good_GetNodeInfo(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.URL.Path != "/api/v1/nodeinfo" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.NodeInfo{
+ Version: "2.1",
+ Software: &types.NodeInfoSoftware{
+ Name: "forgejo",
+ Version: "1.21.0",
+ },
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ info, err := f.Misc.GetNodeInfo(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Version != "2.1" {
+ t.Errorf("got version=%q, want %q", info.Version, "2.1")
+ }
+ if info.Software.Name != "forgejo" {
+ t.Errorf("got software name=%q, want %q", info.Software.Name, "forgejo")
+ }
+}
+
+func TestMiscService_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()
+
+ f := NewForge(srv.URL, "tok")
+ _, err := f.Misc.GetLicense(context.Background(), "nonexistent")
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !IsNotFound(err) {
+ t.Errorf("expected not-found error, got %v", err)
+ }
+}
diff --git a/services_stub.go b/services_stub.go
deleted file mode 100644
index d286184..0000000
--- a/services_stub.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package forge
-
-// Stub service types — replaced as each service is implemented.
-
-type WikiService struct{}
-type MiscService struct{}
diff --git a/wiki.go b/wiki.go
new file mode 100644
index 0000000..6c109e5
--- /dev/null
+++ b/wiki.go
@@ -0,0 +1,64 @@
+package forge
+
+import (
+ "context"
+ "fmt"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+// WikiService handles wiki page operations for a repository.
+// No Resource embedding — custom endpoints for wiki CRUD.
+type WikiService struct {
+ client *Client
+}
+
+func newWikiService(c *Client) *WikiService {
+ return &WikiService{client: c}
+}
+
+// ListPages returns all wiki page metadata for a repository.
+func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]types.WikiPageMetaData, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", owner, repo)
+ var out []types.WikiPageMetaData
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// GetPage returns a single wiki page by name.
+func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) (*types.WikiPage, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName)
+ var out types.WikiPage
+ if err := s.client.Get(ctx, path, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// CreatePage creates a new wiki page.
+func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", owner, repo)
+ var out types.WikiPage
+ if err := s.client.Post(ctx, path, opts, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// EditPage updates an existing wiki page.
+func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName)
+ var out types.WikiPage
+ if err := s.client.Patch(ctx, path, opts, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// DeletePage removes a wiki page.
+func (s *WikiService) DeletePage(ctx context.Context, owner, repo, pageName string) error {
+ path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName)
+ return s.client.Delete(ctx, path)
+}
diff --git a/wiki_test.go b/wiki_test.go
new file mode 100644
index 0000000..758e0fd
--- /dev/null
+++ b/wiki_test.go
@@ -0,0 +1,188 @@
+package forge
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "forge.lthn.ai/core/go-forge/types"
+)
+
+func TestWikiService_Good_ListPages(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.URL.Path != "/api/v1/repos/core/go-forge/wiki/pages" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode([]types.WikiPageMetaData{
+ {Title: "Home", SubURL: "Home"},
+ {Title: "Setup", SubURL: "Setup"},
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ pages, err := f.Wiki.ListPages(context.Background(), "core", "go-forge")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pages) != 2 {
+ t.Fatalf("got %d pages, want 2", len(pages))
+ }
+ if pages[0].Title != "Home" {
+ t.Errorf("got title=%q, want %q", pages[0].Title, "Home")
+ }
+ if pages[1].Title != "Setup" {
+ t.Errorf("got title=%q, want %q", pages[1].Title, "Setup")
+ }
+}
+
+func TestWikiService_Good_GetPage(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.URL.Path != "/api/v1/repos/core/go-forge/wiki/page/Home" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(types.WikiPage{
+ Title: "Home",
+ ContentBase64: "IyBXZWxjb21l",
+ SubURL: "Home",
+ CommitCount: 3,
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ page, err := f.Wiki.GetPage(context.Background(), "core", "go-forge", "Home")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if page.Title != "Home" {
+ t.Errorf("got title=%q, want %q", page.Title, "Home")
+ }
+ if page.ContentBase64 != "IyBXZWxjb21l" {
+ t.Errorf("got content=%q, want %q", page.ContentBase64, "IyBXZWxjb21l")
+ }
+ if page.CommitCount != 3 {
+ t.Errorf("got commit_count=%d, want 3", page.CommitCount)
+ }
+}
+
+func TestWikiService_Good_CreatePage(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)
+ }
+ if r.URL.Path != "/api/v1/repos/core/go-forge/wiki/new" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ var opts types.CreateWikiPageOptions
+ if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
+ t.Fatal(err)
+ }
+ if opts.Title != "Install" {
+ t.Errorf("got title=%q, want %q", opts.Title, "Install")
+ }
+ if opts.ContentBase64 != "IyBJbnN0YWxs" {
+ t.Errorf("got content=%q, want %q", opts.ContentBase64, "IyBJbnN0YWxs")
+ }
+ json.NewEncoder(w).Encode(types.WikiPage{
+ Title: "Install",
+ ContentBase64: "IyBJbnN0YWxs",
+ SubURL: "Install",
+ CommitCount: 1,
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ page, err := f.Wiki.CreatePage(context.Background(), "core", "go-forge", &types.CreateWikiPageOptions{
+ Title: "Install",
+ ContentBase64: "IyBJbnN0YWxs",
+ Message: "create install page",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if page.Title != "Install" {
+ t.Errorf("got title=%q, want %q", page.Title, "Install")
+ }
+ if page.CommitCount != 1 {
+ t.Errorf("got commit_count=%d, want 1", page.CommitCount)
+ }
+}
+
+func TestWikiService_Good_EditPage(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)
+ }
+ if r.URL.Path != "/api/v1/repos/core/go-forge/wiki/page/Home" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ var opts types.CreateWikiPageOptions
+ if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
+ t.Fatal(err)
+ }
+ json.NewEncoder(w).Encode(types.WikiPage{
+ Title: "Home",
+ ContentBase64: "dXBkYXRlZA==",
+ CommitCount: 4,
+ })
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ page, err := f.Wiki.EditPage(context.Background(), "core", "go-forge", "Home", &types.CreateWikiPageOptions{
+ ContentBase64: "dXBkYXRlZA==",
+ Message: "update home page",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if page.ContentBase64 != "dXBkYXRlZA==" {
+ t.Errorf("got content=%q, want %q", page.ContentBase64, "dXBkYXRlZA==")
+ }
+}
+
+func TestWikiService_Good_DeletePage(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)
+ }
+ if r.URL.Path != "/api/v1/repos/core/go-forge/wiki/page/Old" {
+ t.Errorf("wrong path: %s", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ err := f.Wiki.DeletePage(context.Background(), "core", "go-forge", "Old")
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWikiService_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": "page not found"})
+ }))
+ defer srv.Close()
+
+ f := NewForge(srv.URL, "tok")
+ _, err := f.Wiki.GetPage(context.Background(), "core", "go-forge", "nonexistent")
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !IsNotFound(err) {
+ t.Errorf("expected not-found error, got %v", err)
+ }
+}