feat: BranchService and ReleaseService with protections and assets

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 15:59:43 +00:00
parent c6e9d83b08
commit fd0343e47e
6 changed files with 313 additions and 4 deletions

63
branches.go Normal file
View file

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

102
branches_test.go Normal file
View file

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

View file

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

59
releases.go Normal file
View file

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

87
releases_test.go Normal file
View file

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

View file

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