From 33319590d40c6d6319c0fd37b466e7740f6f5c76 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 16:11:29 +0000 Subject: [PATCH] feat: WikiService, MiscService, CommitService Add three new services completing the final service layer: - WikiService: CRUD operations for repository wiki pages - MiscService: markdown rendering, licence/gitignore templates, nodeinfo, version - CommitService: commit statuses (list, create, combined) and git notes - PostRaw method on Client for endpoints returning raw text (e.g. /markdown) - Remove services_stub.go (all stubs now replaced with real implementations) - Wire Commits field into Forge struct Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- client.go | 44 +++++++++ commits.go | 59 ++++++++++++ commits_test.go | 167 ++++++++++++++++++++++++++++++++++ forge.go | 6 +- misc.go | 87 ++++++++++++++++++ misc_test.go | 227 +++++++++++++++++++++++++++++++++++++++++++++++ services_stub.go | 6 -- wiki.go | 64 +++++++++++++ wiki_test.go | 188 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 840 insertions(+), 8 deletions(-) create mode 100644 commits.go create mode 100644 commits_test.go create mode 100644 misc.go create mode 100644 misc_test.go delete mode 100644 services_stub.go create mode 100644 wiki.go create mode 100644 wiki_test.go 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) + } +}