diff --git a/client.go b/client.go index d215255..36f14e4 100644 --- a/client.go +++ b/client.go @@ -105,6 +105,39 @@ func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) erro return c.do(ctx, http.MethodDelete, path, body, 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) { + url := c.baseURL + path + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("forge: create request: %w", err) + } + + req.Header.Set("Authorization", "token "+c.token) + 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) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("forge: read response body: %w", err) + } + + return data, nil +} + func (c *Client) do(ctx context.Context, method, path string, body, out any) error { url := c.baseURL + path diff --git a/contents.go b/contents.go new file mode 100644 index 0000000..6e74973 --- /dev/null +++ b/contents.go @@ -0,0 +1,60 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// ContentService handles file read/write operations via the Forgejo API. +// No Resource embedding — paths vary by operation. +type ContentService struct { + client *Client +} + +func newContentService(c *Client) *ContentService { + return &ContentService{client: c} +} + +// GetFile returns metadata and content for a file in a repository. +func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + var out types.ContentsResponse + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateFile creates a new file in a repository. +func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath string, opts *types.CreateFileOptions) (*types.FileResponse, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + var out types.FileResponse + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateFile updates an existing file in a repository. +func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath string, opts *types.UpdateFileOptions) (*types.FileResponse, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + var out types.FileResponse + if err := s.client.Put(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteFile deletes a file from a repository. Uses DELETE with a JSON body. +func (s *ContentService) DeleteFile(ctx context.Context, owner, repo, filepath string, opts *types.DeleteFileOptions) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + return s.client.DeleteWithBody(ctx, path, opts) +} + +// GetRawFile returns the raw file content as bytes. +func (s *ContentService) GetRawFile(ctx context.Context, owner, repo, filepath string) ([]byte, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/raw/%s", owner, repo, filepath) + return s.client.GetRaw(ctx, path) +} diff --git a/contents_test.go b/contents_test.go new file mode 100644 index 0000000..4208b2b --- /dev/null +++ b/contents_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 TestContentService_Good_GetFile(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/contents/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.ContentsResponse{ + Name: "README.md", + Path: "README.md", + Type: "file", + Encoding: "base64", + Content: "IyBnby1mb3JnZQ==", + SHA: "abc123", + Size: 12, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + file, err := f.Contents.GetFile(context.Background(), "core", "go-forge", "README.md") + if err != nil { + t.Fatal(err) + } + if file.Name != "README.md" { + t.Errorf("got name=%q, want %q", file.Name, "README.md") + } + if file.Type != "file" { + t.Errorf("got type=%q, want %q", file.Type, "file") + } + if file.SHA != "abc123" { + t.Errorf("got sha=%q, want %q", file.SHA, "abc123") + } + if file.Content != "IyBnby1mb3JnZQ==" { + t.Errorf("got content=%q, want %q", file.Content, "IyBnby1mb3JnZQ==") + } +} + +func TestContentService_Good_CreateFile(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/contents/docs/new.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateFileOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.ContentBase64 != "bmV3IGZpbGU=" { + t.Errorf("got content=%q, want %q", opts.ContentBase64, "bmV3IGZpbGU=") + } + json.NewEncoder(w).Encode(types.FileResponse{ + Content: &types.ContentsResponse{ + Name: "new.md", + Path: "docs/new.md", + Type: "file", + SHA: "def456", + }, + Commit: &types.FileCommitResponse{ + SHA: "commit789", + Message: "create docs/new.md", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Contents.CreateFile(context.Background(), "core", "go-forge", "docs/new.md", &types.CreateFileOptions{ + ContentBase64: "bmV3IGZpbGU=", + Message: "create docs/new.md", + }) + if err != nil { + t.Fatal(err) + } + if resp.Content.Name != "new.md" { + t.Errorf("got name=%q, want %q", resp.Content.Name, "new.md") + } + if resp.Content.SHA != "def456" { + t.Errorf("got sha=%q, want %q", resp.Content.SHA, "def456") + } + if resp.Commit.Message != "create docs/new.md" { + t.Errorf("got commit message=%q, want %q", resp.Commit.Message, "create docs/new.md") + } +} + +func TestContentService_Good_UpdateFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/contents/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.UpdateFileOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.SHA != "abc123" { + t.Errorf("got sha=%q, want %q", opts.SHA, "abc123") + } + json.NewEncoder(w).Encode(types.FileResponse{ + Content: &types.ContentsResponse{ + Name: "README.md", + Path: "README.md", + Type: "file", + SHA: "updated456", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Contents.UpdateFile(context.Background(), "core", "go-forge", "README.md", &types.UpdateFileOptions{ + ContentBase64: "dXBkYXRlZA==", + SHA: "abc123", + Message: "update README", + }) + if err != nil { + t.Fatal(err) + } + if resp.Content.SHA != "updated456" { + t.Errorf("got sha=%q, want %q", resp.Content.SHA, "updated456") + } +} + +func TestContentService_Good_DeleteFile(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/contents/old.txt" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.DeleteFileOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.SHA != "sha123" { + t.Errorf("got sha=%q, want %q", opts.SHA, "sha123") + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.FileDeleteResponse{}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Contents.DeleteFile(context.Background(), "core", "go-forge", "old.txt", &types.DeleteFileOptions{ + SHA: "sha123", + Message: "remove old file", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestContentService_Good_GetRawFile(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/raw/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("# go-forge\n\nA Go client for Forgejo.")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + data, err := f.Contents.GetRawFile(context.Background(), "core", "go-forge", "README.md") + if err != nil { + t.Fatal(err) + } + want := "# go-forge\n\nA Go client for Forgejo." + if string(data) != want { + t.Errorf("got %q, want %q", string(data), want) + } +} + +func TestContentService_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": "file not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Contents.GetFile(context.Background(), "core", "go-forge", "nonexistent.md") + if err == nil { + t.Fatal("expected error, got nil") + } + if !IsNotFound(err) { + t.Errorf("expected not-found error, got %v", err) + } +} + +func TestContentService_Bad_GetRawNotFound(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": "file not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Contents.GetRawFile(context.Background(), "core", "go-forge", "nonexistent.md") + 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 49d6bf1..0980813 100644 --- a/forge.go +++ b/forge.go @@ -36,12 +36,12 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Admin = newAdminService(c) f.Branches = newBranchService(c) f.Releases = newReleaseService(c) - f.Labels = &LabelService{} - f.Webhooks = &WebhookService{} + f.Labels = newLabelService(c) + f.Webhooks = newWebhookService(c) f.Notifications = &NotificationService{} f.Packages = &PackageService{} f.Actions = &ActionsService{} - f.Contents = &ContentService{} + f.Contents = newContentService(c) f.Wiki = &WikiService{} f.Misc = &MiscService{} return f diff --git a/labels.go b/labels.go new file mode 100644 index 0000000..6a61260 --- /dev/null +++ b/labels.go @@ -0,0 +1,76 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// LabelService handles repository labels, organisation labels, and issue labels. +// No Resource embedding — paths are heterogeneous. +type LabelService struct { + client *Client +} + +func newLabelService(c *Client) *LabelService { + return &LabelService{client: c} +} + +// ListRepoLabels returns all labels for a repository. +func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + return ListAll[types.Label](ctx, s.client, path, nil) +} + +// GetRepoLabel returns a single label by ID. +func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + var out types.Label + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateRepoLabel creates a new label in a repository. +func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, opts *types.CreateLabelOption) (*types.Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + var out types.Label + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditRepoLabel updates an existing label in a repository. +func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id int64, opts *types.EditLabelOption) (*types.Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + var out types.Label + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteRepoLabel deletes a label from a repository. +func (s *LabelService) DeleteRepoLabel(ctx context.Context, owner, repo string, id int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + return s.client.Delete(ctx, path) +} + +// ListOrgLabels returns all labels for an organisation. +func (s *LabelService) ListOrgLabels(ctx context.Context, org string) ([]types.Label, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + return ListAll[types.Label](ctx, s.client, path, nil) +} + +// CreateOrgLabel creates a new label in an organisation. +func (s *LabelService) CreateOrgLabel(ctx context.Context, org string, opts *types.CreateLabelOption) (*types.Label, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + var out types.Label + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/labels_test.go b/labels_test.go new file mode 100644 index 0000000..d461be6 --- /dev/null +++ b/labels_test.go @@ -0,0 +1,232 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestLabelService_Good_ListRepoLabels(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/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Label{ + {ID: 1, Name: "bug", Color: "#d73a4a"}, + {ID: 2, Name: "feature", Color: "#0075ca"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + labels, err := f.Labels.ListRepoLabels(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(labels) != 2 { + t.Errorf("got %d labels, want 2", len(labels)) + } + if labels[0].Name != "bug" { + t.Errorf("got name=%q, want %q", labels[0].Name, "bug") + } + if labels[1].Color != "#0075ca" { + t.Errorf("got colour=%q, want %q", labels[1].Color, "#0075ca") + } +} + +func TestLabelService_Good_CreateRepoLabel(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/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateLabelOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Name != "enhancement" { + t.Errorf("got name=%q, want %q", opts.Name, "enhancement") + } + if opts.Color != "#a2eeef" { + t.Errorf("got colour=%q, want %q", opts.Color, "#a2eeef") + } + json.NewEncoder(w).Encode(types.Label{ + ID: 3, + Name: opts.Name, + Color: opts.Color, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + label, err := f.Labels.CreateRepoLabel(context.Background(), "core", "go-forge", &types.CreateLabelOption{ + Name: "enhancement", + Color: "#a2eeef", + }) + if err != nil { + t.Fatal(err) + } + if label.ID != 3 { + t.Errorf("got id=%d, want 3", label.ID) + } + if label.Name != "enhancement" { + t.Errorf("got name=%q, want %q", label.Name, "enhancement") + } +} + +func TestLabelService_Good_GetRepoLabel(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/labels/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Label{ID: 1, Name: "bug", Color: "#d73a4a"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + label, err := f.Labels.GetRepoLabel(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if label.Name != "bug" { + t.Errorf("got name=%q, want %q", label.Name, "bug") + } +} + +func TestLabelService_Good_EditRepoLabel(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/labels/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditLabelOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + json.NewEncoder(w).Encode(types.Label{ID: 1, Name: opts.Name, Color: opts.Color}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + label, err := f.Labels.EditRepoLabel(context.Background(), "core", "go-forge", 1, &types.EditLabelOption{ + Name: "critical-bug", + Color: "#ff0000", + }) + if err != nil { + t.Fatal(err) + } + if label.Name != "critical-bug" { + t.Errorf("got name=%q, want %q", label.Name, "critical-bug") + } +} + +func TestLabelService_Good_DeleteRepoLabel(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/labels/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Labels.DeleteRepoLabel(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } +} + +func TestLabelService_Good_ListOrgLabels(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/orgs/myorg/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Label{ + {ID: 10, Name: "org-wide", Color: "#333333"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + labels, err := f.Labels.ListOrgLabels(context.Background(), "myorg") + if err != nil { + t.Fatal(err) + } + if len(labels) != 1 { + t.Errorf("got %d labels, want 1", len(labels)) + } + if labels[0].Name != "org-wide" { + t.Errorf("got name=%q, want %q", labels[0].Name, "org-wide") + } +} + +func TestLabelService_Good_CreateOrgLabel(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/orgs/myorg/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateLabelOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + json.NewEncoder(w).Encode(types.Label{ID: 11, Name: opts.Name, Color: opts.Color}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + label, err := f.Labels.CreateOrgLabel(context.Background(), "myorg", &types.CreateLabelOption{ + Name: "priority", + Color: "#e4e669", + }) + if err != nil { + t.Fatal(err) + } + if label.ID != 11 { + t.Errorf("got id=%d, want 11", label.ID) + } + if label.Name != "priority" { + t.Errorf("got name=%q, want %q", label.Name, "priority") + } +} + +func TestLabelService_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": "label not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Labels.GetRepoLabel(context.Background(), "core", "go-forge", 999) + 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 index c0b0b75..5c42cdd 100644 --- a/services_stub.go +++ b/services_stub.go @@ -2,11 +2,8 @@ package forge // Stub service types — replaced as each service is implemented. -type LabelService struct{} -type WebhookService struct{} type NotificationService struct{} type PackageService struct{} type ActionsService struct{} -type ContentService struct{} type WikiService struct{} type MiscService struct{} diff --git a/webhooks.go b/webhooks.go new file mode 100644 index 0000000..4c802a3 --- /dev/null +++ b/webhooks.go @@ -0,0 +1,34 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// WebhookService handles webhook (hook) operations within a repository. +// Embeds Resource for standard CRUD on /api/v1/repos/{owner}/{repo}/hooks/{id}. +type WebhookService struct { + Resource[types.Hook, types.CreateHookOption, types.EditHookOption] +} + +func newWebhookService(c *Client) *WebhookService { + return &WebhookService{ + Resource: *NewResource[types.Hook, types.CreateHookOption, types.EditHookOption]( + c, "/api/v1/repos/{owner}/{repo}/hooks/{id}", + ), + } +} + +// TestHook triggers a test delivery for a webhook. +func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/tests", owner, repo, id) + return s.client.Post(ctx, path, nil, nil) +} + +// ListOrgHooks returns all webhooks for an organisation. +func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types.Hook, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/hooks", org) + return ListAll[types.Hook](ctx, s.client, path, nil) +} diff --git a/webhooks_test.go b/webhooks_test.go new file mode 100644 index 0000000..5368152 --- /dev/null +++ b/webhooks_test.go @@ -0,0 +1,173 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestWebhookService_Good_List(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) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Hook{ + {ID: 1, Type: "forgejo", Active: true, URL: "https://example.com/hook1"}, + {ID: 2, Type: "forgejo", Active: false, URL: "https://example.com/hook2"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Webhooks.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, DefaultList) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 { + t.Errorf("got %d items, want 2", len(result.Items)) + } + if result.Items[0].URL != "https://example.com/hook1" { + t.Errorf("got url=%q, want %q", result.Items[0].URL, "https://example.com/hook1") + } +} + +func TestWebhookService_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.URL.Path != "/api/v1/repos/core/go-forge/hooks/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 1, + Type: "forgejo", + Active: true, + URL: "https://example.com/hook1", + Events: []string{"push", "pull_request"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "id": "1"}) + if err != nil { + t.Fatal(err) + } + if hook.ID != 1 { + t.Errorf("got id=%d, want 1", hook.ID) + } + if hook.URL != "https://example.com/hook1" { + t.Errorf("got url=%q, want %q", hook.URL, "https://example.com/hook1") + } + if len(hook.Events) != 2 { + t.Errorf("got %d events, want 2", len(hook.Events)) + } +} + +func TestWebhookService_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 opts types.CreateHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Type != "forgejo" { + t.Errorf("got type=%q, want %q", opts.Type, "forgejo") + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 3, + Type: opts.Type, + Active: opts.Active, + Events: opts.Events, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.Create(context.Background(), Params{"owner": "core", "repo": "go-forge"}, &types.CreateHookOption{ + Type: "forgejo", + Active: true, + Events: []string{"push"}, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 3 { + t.Errorf("got id=%d, want 3", hook.ID) + } + if hook.Type != "forgejo" { + t.Errorf("got type=%q, want %q", hook.Type, "forgejo") + } +} + +func TestWebhookService_Good_TestHook(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/hooks/1/tests" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Webhooks.TestHook(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } +} + +func TestWebhookService_Good_ListOrgHooks(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/orgs/myorg/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Hook{ + {ID: 10, Type: "forgejo", Active: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hooks, err := f.Webhooks.ListOrgHooks(context.Background(), "myorg") + if err != nil { + t.Fatal(err) + } + if len(hooks) != 1 { + t.Errorf("got %d hooks, want 1", len(hooks)) + } + if hooks[0].ID != 10 { + t.Errorf("got id=%d, want 10", hooks[0].ID) + } +} + +func TestWebhookService_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": "hook not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Webhooks.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "id": "999"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !IsNotFound(err) { + t.Errorf("expected not-found error, got %v", err) + } +}