feat: OrgService, TeamService, UserService

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:53:29 +00:00
parent f8c6090227
commit df75d3281d
8 changed files with 441 additions and 8 deletions

View file

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

50
orgs.go Normal file
View file

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

88
orgs_test.go Normal file
View file

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

View file

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

63
teams.go Normal file
View file

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

81
teams_test.go Normal file
View file

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

72
users.go Normal file
View file

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

84
users_test.go Normal file
View file

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