feat: LabelService, WebhookService, ContentService

Add three new services covering labels, webhooks, and file content
operations. LabelService handles repo and org labels without Resource
embedding due to heterogeneous paths. WebhookService embeds Resource
for standard CRUD on repo hooks plus action methods for test delivery
and org hooks. ContentService provides file CRUD and raw file retrieval.
Adds GetRaw method to Client for non-JSON responses.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-21 16:03:46 +00:00
parent fd0343e47e
commit de76399608
9 changed files with 838 additions and 6 deletions

View file

@ -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

60
contents.go Normal file
View file

@ -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)
}

227
contents_test.go Normal file
View file

@ -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)
}
}

View file

@ -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

76
labels.go Normal file
View file

@ -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
}

232
labels_test.go Normal file
View file

@ -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)
}
}

View file

@ -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{}

34
webhooks.go Normal file
View file

@ -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)
}

173
webhooks_test.go Normal file
View file

@ -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)
}
}