From df75d3281d16b7390ae93a90d1cb0cc369a95ce1 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 15:53:29 +0000 Subject: [PATCH] feat: OrgService, TeamService, UserService Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- forge.go | 8 ++--- orgs.go | 50 +++++++++++++++++++++++++++ orgs_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ services_stub.go | 3 -- teams.go | 63 ++++++++++++++++++++++++++++++++++ teams_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++++ users.go | 72 +++++++++++++++++++++++++++++++++++++++ users_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 orgs.go create mode 100644 orgs_test.go create mode 100644 teams.go create mode 100644 teams_test.go create mode 100644 users.go create mode 100644 users_test.go diff --git a/forge.go b/forge.go index 97199cd..4fc08e5 100644 --- a/forge.go +++ b/forge.go @@ -30,11 +30,9 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Repos = newRepoService(c) f.Issues = newIssueService(c) f.Pulls = newPullService(c) - // Other services initialised in their respective tasks. - // Stub them here so tests compile: - f.Orgs = &OrgService{} - f.Users = &UserService{} - f.Teams = &TeamService{} + f.Orgs = newOrgService(c) + f.Users = newUserService(c) + f.Teams = newTeamService(c) f.Admin = &AdminService{} f.Branches = &BranchService{} f.Releases = &ReleaseService{} diff --git a/orgs.go b/orgs.go new file mode 100644 index 0000000..0c3f180 --- /dev/null +++ b/orgs.go @@ -0,0 +1,50 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// OrgService handles organisation operations. +type OrgService struct { + Resource[types.Organization, types.CreateOrgOption, types.EditOrgOption] +} + +func newOrgService(c *Client) *OrgService { + return &OrgService{ + Resource: *NewResource[types.Organization, types.CreateOrgOption, types.EditOrgOption]( + c, "/api/v1/orgs/{org}", + ), + } +} + +// ListMembers returns all members of an organisation. +func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/members", org) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// AddMember adds a user to an organisation. +func (s *OrgService) AddMember(ctx context.Context, org, username string) error { + path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + return s.client.Put(ctx, path, nil, nil) +} + +// RemoveMember removes a user from an organisation. +func (s *OrgService) RemoveMember(ctx context.Context, org, username string) error { + path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + return s.client.Delete(ctx, path) +} + +// ListUserOrgs returns all organisations for a user. +func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error) { + path := fmt.Sprintf("/api/v1/users/%s/orgs", username) + return ListAll[types.Organization](ctx, s.client, path, nil) +} + +// ListMyOrgs returns all organisations for the authenticated user. +func (s *OrgService) ListMyOrgs(ctx context.Context) ([]types.Organization, error) { + return ListAll[types.Organization](ctx, s.client, "/api/v1/user/orgs", nil) +} diff --git a/orgs_test.go b/orgs_test.go new file mode 100644 index 0000000..57d3ad2 --- /dev/null +++ b/orgs_test.go @@ -0,0 +1,88 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestOrgService_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.Organization{ + {ID: 1, Name: "core"}, + {ID: 2, Name: "labs"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Orgs.ListAll(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Errorf("got %d items, want 2", len(result)) + } + if result[0].Name != "core" { + t.Errorf("got name=%q, want %q", result[0].Name, "core") + } +} + +func TestOrgService_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/orgs/core" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Organization{ID: 1, Name: "core"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + org, err := f.Orgs.Get(context.Background(), Params{"org": "core"}) + if err != nil { + t.Fatal(err) + } + if org.Name != "core" { + t.Errorf("got name=%q, want %q", org.Name, "core") + } +} + +func TestOrgService_Good_ListMembers(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/core/members" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + members, err := f.Orgs.ListMembers(context.Background(), "core") + if err != nil { + t.Fatal(err) + } + if len(members) != 2 { + t.Errorf("got %d members, want 2", len(members)) + } + if members[0].UserName != "alice" { + t.Errorf("got username=%q, want %q", members[0].UserName, "alice") + } +} diff --git a/services_stub.go b/services_stub.go index 5978a4d..8aa386c 100644 --- a/services_stub.go +++ b/services_stub.go @@ -2,9 +2,6 @@ package forge // Stub service types — replaced as each service is implemented. -type OrgService struct{} -type UserService struct{} -type TeamService struct{} type AdminService struct{} type BranchService struct{} type ReleaseService struct{} diff --git a/teams.go b/teams.go new file mode 100644 index 0000000..e677495 --- /dev/null +++ b/teams.go @@ -0,0 +1,63 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// TeamService handles team operations. +type TeamService struct { + Resource[types.Team, types.CreateTeamOption, types.EditTeamOption] +} + +func newTeamService(c *Client) *TeamService { + return &TeamService{ + Resource: *NewResource[types.Team, types.CreateTeamOption, types.EditTeamOption]( + c, "/api/v1/teams/{id}", + ), + } +} + +// ListMembers returns all members of a team. +func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error) { + path := fmt.Sprintf("/api/v1/teams/%d/members", teamID) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// AddMember adds a user to a team. +func (s *TeamService) AddMember(ctx context.Context, teamID int64, username string) error { + path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + return s.client.Put(ctx, path, nil, nil) +} + +// RemoveMember removes a user from a team. +func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error { + path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + return s.client.Delete(ctx, path) +} + +// ListRepos returns all repositories managed by a team. +func (s *TeamService) ListRepos(ctx context.Context, teamID int64) ([]types.Repository, error) { + path := fmt.Sprintf("/api/v1/teams/%d/repos", teamID) + return ListAll[types.Repository](ctx, s.client, path, nil) +} + +// AddRepo adds a repository to a team. +func (s *TeamService) AddRepo(ctx context.Context, teamID int64, org, repo string) error { + path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + return s.client.Put(ctx, path, nil, nil) +} + +// RemoveRepo removes a repository from a team. +func (s *TeamService) RemoveRepo(ctx context.Context, teamID int64, org, repo string) error { + path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + return s.client.Delete(ctx, path) +} + +// ListOrgTeams returns all teams in an organisation. +func (s *TeamService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/teams", org) + return ListAll[types.Team](ctx, s.client, path, nil) +} diff --git a/teams_test.go b/teams_test.go new file mode 100644 index 0000000..cc93e48 --- /dev/null +++ b/teams_test.go @@ -0,0 +1,81 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestTeamService_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/teams/42" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Team{ID: 42, Name: "developers"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + team, err := f.Teams.Get(context.Background(), Params{"id": "42"}) + if err != nil { + t.Fatal(err) + } + if team.Name != "developers" { + t.Errorf("got name=%q, want %q", team.Name, "developers") + } +} + +func TestTeamService_Good_ListMembers(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/teams/42/members" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + members, err := f.Teams.ListMembers(context.Background(), 42) + if err != nil { + t.Fatal(err) + } + if len(members) != 2 { + t.Errorf("got %d members, want 2", len(members)) + } + if members[0].UserName != "alice" { + t.Errorf("got username=%q, want %q", members[0].UserName, "alice") + } +} + +func TestTeamService_Good_AddMember(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/teams/42/members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Teams.AddMember(context.Background(), 42, "alice") + if err != nil { + t.Fatal(err) + } +} diff --git a/users.go b/users.go new file mode 100644 index 0000000..7da6db4 --- /dev/null +++ b/users.go @@ -0,0 +1,72 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// UserService handles user operations. +type UserService struct { + Resource[types.User, struct{}, struct{}] +} + +func newUserService(c *Client) *UserService { + return &UserService{ + Resource: *NewResource[types.User, struct{}, struct{}]( + c, "/api/v1/users/{username}", + ), + } +} + +// GetCurrent returns the authenticated user. +func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { + var out types.User + if err := s.client.Get(ctx, "/api/v1/user", &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListFollowers returns all followers of a user. +func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { + path := fmt.Sprintf("/api/v1/users/%s/followers", username) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// ListFollowing returns all users that a user is following. +func (s *UserService) ListFollowing(ctx context.Context, username string) ([]types.User, error) { + path := fmt.Sprintf("/api/v1/users/%s/following", username) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// Follow follows a user as the authenticated user. +func (s *UserService) Follow(ctx context.Context, username string) error { + path := fmt.Sprintf("/api/v1/user/following/%s", username) + return s.client.Put(ctx, path, nil, nil) +} + +// Unfollow unfollows a user as the authenticated user. +func (s *UserService) Unfollow(ctx context.Context, username string) error { + path := fmt.Sprintf("/api/v1/user/following/%s", username) + return s.client.Delete(ctx, path) +} + +// ListStarred returns all repositories starred by a user. +func (s *UserService) ListStarred(ctx context.Context, username string) ([]types.Repository, error) { + path := fmt.Sprintf("/api/v1/users/%s/starred", username) + return ListAll[types.Repository](ctx, s.client, path, nil) +} + +// Star stars a repository as the authenticated user. +func (s *UserService) Star(ctx context.Context, owner, repo string) error { + path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + return s.client.Put(ctx, path, nil, nil) +} + +// Unstar unstars a repository as the authenticated user. +func (s *UserService) Unstar(ctx context.Context, owner, repo string) error { + path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + return s.client.Delete(ctx, path) +} diff --git a/users_test.go b/users_test.go new file mode 100644 index 0000000..63faae7 --- /dev/null +++ b/users_test.go @@ -0,0 +1,84 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestUserService_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/users/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.User{ID: 1, UserName: "alice"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + user, err := f.Users.Get(context.Background(), Params{"username": "alice"}) + if err != nil { + t.Fatal(err) + } + if user.UserName != "alice" { + t.Errorf("got username=%q, want %q", user.UserName, "alice") + } +} + +func TestUserService_Good_GetCurrent(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/user" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.User{ID: 1, UserName: "me"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + user, err := f.Users.GetCurrent(context.Background()) + if err != nil { + t.Fatal(err) + } + if user.UserName != "me" { + t.Errorf("got username=%q, want %q", user.UserName, "me") + } +} + +func TestUserService_Good_ListFollowers(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/users/alice/followers" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{ + {ID: 2, UserName: "bob"}, + {ID: 3, UserName: "charlie"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + followers, err := f.Users.ListFollowers(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(followers) != 2 { + t.Errorf("got %d followers, want 2", len(followers)) + } + if followers[0].UserName != "bob" { + t.Errorf("got username=%q, want %q", followers[0].UserName, "bob") + } +}