diff --git a/branches.go b/branches.go new file mode 100644 index 0000000..116c346 --- /dev/null +++ b/branches.go @@ -0,0 +1,63 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// BranchService handles branch operations within a repository. +type BranchService struct { + Resource[types.Branch, types.CreateBranchRepoOption, struct{}] +} + +func newBranchService(c *Client) *BranchService { + return &BranchService{ + Resource: *NewResource[types.Branch, types.CreateBranchRepoOption, struct{}]( + c, "/api/v1/repos/{owner}/{repo}/branches/{branch}", + ), + } +} + +// ListBranchProtections returns all branch protections for a repository. +func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + return ListAll[types.BranchProtection](ctx, s.client, path, nil) +} + +// GetBranchProtection returns a single branch protection by name. +func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, name string) (*types.BranchProtection, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + var out types.BranchProtection + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateBranchProtection creates a new branch protection rule. +func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo string, opts *types.CreateBranchProtectionOption) (*types.BranchProtection, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + var out types.BranchProtection + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditBranchProtection updates an existing branch protection rule. +func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, name string, opts *types.EditBranchProtectionOption) (*types.BranchProtection, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + var out types.BranchProtection + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteBranchProtection deletes a branch protection rule. +func (s *BranchService) DeleteBranchProtection(ctx context.Context, owner, repo, name string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + return s.client.Delete(ctx, path) +} diff --git a/branches_test.go b/branches_test.go new file mode 100644 index 0000000..7e4a2d1 --- /dev/null +++ b/branches_test.go @@ -0,0 +1,102 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestBranchService_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.Branch{ + {Name: "main", Protected: true}, + {Name: "develop", Protected: false}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Branches.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].Name != "main" { + t.Errorf("got name=%q, want %q", result.Items[0].Name, "main") + } +} + +func TestBranchService_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/branches/main" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Branch{Name: "main", Protected: true}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + branch, err := f.Branches.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "branch": "main"}) + if err != nil { + t.Fatal(err) + } + if branch.Name != "main" { + t.Errorf("got name=%q, want %q", branch.Name, "main") + } + if !branch.Protected { + t.Error("expected branch to be protected") + } +} + +func TestBranchService_Good_CreateProtection(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/branch_protections" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateBranchProtectionOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.RuleName != "main" { + t.Errorf("got rule_name=%q, want %q", opts.RuleName, "main") + } + json.NewEncoder(w).Encode(types.BranchProtection{ + RuleName: "main", + EnablePush: true, + RequiredApprovals: 2, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + bp, err := f.Branches.CreateBranchProtection(context.Background(), "core", "go-forge", &types.CreateBranchProtectionOption{ + RuleName: "main", + EnablePush: true, + RequiredApprovals: 2, + }) + if err != nil { + t.Fatal(err) + } + if bp.RuleName != "main" { + t.Errorf("got rule_name=%q, want %q", bp.RuleName, "main") + } + if bp.RequiredApprovals != 2 { + t.Errorf("got required_approvals=%d, want 2", bp.RequiredApprovals) + } +} diff --git a/forge.go b/forge.go index 3a851d2..49d6bf1 100644 --- a/forge.go +++ b/forge.go @@ -34,8 +34,8 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Users = newUserService(c) f.Teams = newTeamService(c) f.Admin = newAdminService(c) - f.Branches = &BranchService{} - f.Releases = &ReleaseService{} + f.Branches = newBranchService(c) + f.Releases = newReleaseService(c) f.Labels = &LabelService{} f.Webhooks = &WebhookService{} f.Notifications = &NotificationService{} diff --git a/releases.go b/releases.go new file mode 100644 index 0000000..bfaeb1f --- /dev/null +++ b/releases.go @@ -0,0 +1,59 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// ReleaseService handles release operations within a repository. +type ReleaseService struct { + Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption] +} + +func newReleaseService(c *Client) *ReleaseService { + return &ReleaseService{ + Resource: *NewResource[types.Release, types.CreateReleaseOption, types.EditReleaseOption]( + c, "/api/v1/repos/{owner}/{repo}/releases/{id}", + ), + } +} + +// GetByTag returns a release by its tag name. +func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) (*types.Release, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + var out types.Release + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteByTag deletes a release by its tag name. +func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + return s.client.Delete(ctx, path) +} + +// ListAssets returns all assets for a release. +func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, releaseID int64) ([]types.Attachment, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + return ListAll[types.Attachment](ctx, s.client, path, nil) +} + +// GetAsset returns a single asset for a release. +func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) (*types.Attachment, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + var out types.Attachment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteAsset deletes a single asset from a release. +func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + return s.client.Delete(ctx, path) +} diff --git a/releases_test.go b/releases_test.go new file mode 100644 index 0000000..ef7f933 --- /dev/null +++ b/releases_test.go @@ -0,0 +1,87 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestReleaseService_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.Release{ + {ID: 1, TagName: "v1.0.0", Title: "Release 1.0"}, + {ID: 2, TagName: "v2.0.0", Title: "Release 2.0"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Releases.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].TagName != "v1.0.0" { + t.Errorf("got tag=%q, want %q", result.Items[0].TagName, "v1.0.0") + } +} + +func TestReleaseService_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/releases/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Release{ID: 1, TagName: "v1.0.0", Title: "Release 1.0"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + release, err := f.Releases.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "id": "1"}) + if err != nil { + t.Fatal(err) + } + if release.TagName != "v1.0.0" { + t.Errorf("got tag=%q, want %q", release.TagName, "v1.0.0") + } + if release.Title != "Release 1.0" { + t.Errorf("got title=%q, want %q", release.Title, "Release 1.0") + } +} + +func TestReleaseService_Good_GetByTag(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/releases/tags/v1.0.0" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Release{ID: 1, TagName: "v1.0.0", Title: "Release 1.0"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + release, err := f.Releases.GetByTag(context.Background(), "core", "go-forge", "v1.0.0") + if err != nil { + t.Fatal(err) + } + if release.TagName != "v1.0.0" { + t.Errorf("got tag=%q, want %q", release.TagName, "v1.0.0") + } + if release.ID != 1 { + t.Errorf("got id=%d, want 1", release.ID) + } +} diff --git a/services_stub.go b/services_stub.go index a3074a1..c0b0b75 100644 --- a/services_stub.go +++ b/services_stub.go @@ -2,8 +2,6 @@ package forge // Stub service types — replaced as each service is implemented. -type BranchService struct{} -type ReleaseService struct{} type LabelService struct{} type WebhookService struct{} type NotificationService struct{}