// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package services import ( "context" "errors" "fmt" "net/http" "net/url" "sync" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" ) // Organization is the public command representation of a Gitea organization. type Organization struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Website string `json:"website"` Location string `json:"location"` Visibility string `json:"visibility"` RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"` } // organization returns the ID of owner when it is an organization; a missing organization // means a user owns the namespace. func organization(ctx context.Context, client *transport.Client, owner string) (id int64, isOrg bool, err error) { org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { return c.Organizations.GetOrg(ctx, owner) }) if statusNotFound(err) { return 0, false, nil } if err != nil { return 0, false, err } if org.ID < 1 { return 0, false, fmt.Errorf("cannot verify the identity of organization %s", owner) } return org.ID, true, nil } // statusNotFound reports whether err is a 404 response. func statusNotFound(err error) bool { failure, ok := errors.AsType[*transport.APIError](err) return ok && failure.Status == http.StatusNotFound } // OrganizationOptions permits explicit empty and false preference updates. type OrganizationOptions struct { Name *string `json:"username,omitempty"` FullName *string `json:"full_name,omitempty"` Description *string `json:"description,omitempty"` Website *string `json:"website,omitempty"` Location *string `json:"location,omitempty"` Visibility *string `json:"visibility,omitempty"` RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access,omitempty"` } // Organizations implements instance-scoped organization workflows. type Organizations struct{ Client *transport.Client } // List selects public organizations, your organizations, or a named user's organizations. func (s Organizations) List(ctx context.Context, mine bool, username string, limit, page int) (Page[Organization], error) { orgs, err := listPage(limit, page, func(limit, page int) ([]*gitea.Organization, http.Header, error) { orgs, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Organization, *gitea.Response, error) { opts := gitea.ListOrgsOptions{ListOptions: listOptions(limit, page)} switch { case mine: return c.Organizations.ListMyOrgs(ctx, opts) case username != "": return c.Organizations.ListUserOrgs(ctx, username, opts) } return c.Organizations.ListOrgs(ctx, opts) }) return orgs, headerOf(resp), err }) return convertPage(orgs, organizationFrom), err } // convertPage maps a page of SDK items onto tea results. func convertPage[S, T any](page Page[*S], convert func(*S) T) Page[T] { return Page[T]{Items: convertAll(page.Items, convert), More: page.More, Total: page.Total, Counted: page.Counted} } // View retrieves a named organization. func (s Organizations) View(ctx context.Context, name string) (Organization, error) { org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { return c.Organizations.GetOrg(ctx, name) }) if err != nil { return Organization{}, err } return organizationFrom(org), nil } // OrganizationSize counts what an organization holds; a count the account cannot read is nil. type OrganizationSize struct { Members, Teams, Repositories *int } // Size counts the members, teams and repositories of an organization. func (s Organizations) Size(ctx context.Context, name string) (OrganizationSize, error) { first := listOptions(1, 1) counts := []func(context.Context, *gitea.Client) (*gitea.Response, error){ func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { _, resp, err := c.Organizations.ListOrgMembership(ctx, name, gitea.ListOrgMembershipOption{ListOptions: first}) return resp, err }, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { _, resp, err := c.Organizations.ListOrgTeams(ctx, name, gitea.ListTeamsOptions{ListOptions: first}) return resp, err }, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { _, resp, err := c.Repositories.ListOrgRepos(ctx, name, gitea.ListOrgReposOptions{ListOptions: first}) return resp, err }, } totals := make([]*int, len(counts)) for i, count := range counts { total, err := listTotal(ctx, s.Client, count) if ctx.Err() != nil { return OrganizationSize{}, ctx.Err() } if err == nil { totals[i] = total } } return OrganizationSize{Members: totals[0], Teams: totals[1], Repositories: totals[2]}, nil } // listTotal returns the collection size a list request reports, or nil when the server does not count it. func listTotal(ctx context.Context, client *transport.Client, list func(context.Context, *gitea.Client) (*gitea.Response, error)) (*int, error) { _, resp, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (struct{}, *gitea.Response, error) { resp, err := list(ctx, c) return struct{}{}, resp, err }) if err != nil { return nil, err } if _, total, counted := pageHints(headerOf(resp)); counted { return &total, nil } return nil, nil } // Create creates an organization owned by the selected account. func (s Organizations) Create(ctx context.Context, opts OrganizationOptions) (Organization, error) { org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { return c.Organizations.CreateOrg(ctx, gitea.CreateOrgOption{ Name: deref(opts.Name), FullName: deref(opts.FullName), Description: deref(opts.Description), Website: deref(opts.Website), Location: deref(opts.Location), Visibility: gitea.VisibleType(deref(opts.Visibility)), RepoAdminChangeTeamAccess: deref(opts.RepoAdminChangeTeamAccess), }) }) if err != nil { return Organization{}, err } return organizationFrom(org), nil } // Edit changes only supplied organization preferences. func (s Organizations) Edit(ctx context.Context, name string, opts OrganizationOptions) (Organization, error) { var org gitea.Organization // TODO(sdk): EditOrgOption needs pointer fields so omitted preferences stay unchanged, and EditOrg should return the organization. if err := sendJSON(ctx, s.Client, http.MethodPatch, "orgs/"+url.PathEscape(name), opts, &org); err != nil { return Organization{}, err } return organizationFrom(&org), nil } // Delete deletes the named organization. func (s Organizations) Delete(ctx context.Context, name string) error { return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { return c.Organizations.DeleteOrg(ctx, name) }) } // OrganizationMember identifies a member without exposing private profile fields. type OrganizationMember struct { ID int64 `json:"id"` Username string `json:"username"` FullName string `json:"full_name"` // Visibility of an organization membership is public or concealed; team members have none. Visibility string `json:"visibility,omitempty"` } // Membership visibilities. const ( MembershipPublic = "public" MembershipConcealed = "concealed" ) // MemberListOptions selects a page of organization members. type MemberListOptions struct { // Public lists only public memberships, and Visibility reports the visibility of each one. // Gitea lists concealed members as public to administrators, so both check each membership on // its own, and Public drops the server's total, which counts concealed members too. Public, Visibility bool Limit, Page int } // Members lists the members of an organization. func (s Organizations) Members(ctx context.Context, name string, opts MemberListOptions) (Page[OrganizationMember], error) { users, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.User, http.Header, error) { users, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { list := gitea.ListOrgMembershipOption{ListOptions: listOptions(limit, page)} if opts.Public { return c.Organizations.ListPublicOrgMembership(ctx, name, list) } return c.Organizations.ListOrgMembership(ctx, name, list) }) return users, headerOf(resp), err }) members := convertPage(users, memberFrom) if err != nil || !(opts.Public || opts.Visibility) { return members, err } public := make([]bool, len(members.Items)) err = concurrently(len(members.Items), func(i int) (err error) { public[i], err = s.IsPublicMember(ctx, name, members.Items[i].Username) return err }) if err != nil { return Page[OrganizationMember]{}, err } shown := members.Items[:0] for i, member := range members.Items { member.Visibility = MembershipConcealed if public[i] { member.Visibility = MembershipPublic } if !opts.Public || public[i] { shown = append(shown, member) } } members.Items = shown members.Counted = members.Counted && !opts.Public return members, nil } // concurrentRequests bounds the requests that concurrently keeps in flight. const concurrentRequests = 8 // concurrently runs request for each index 0..n-1 with a few requests in flight, and returns their // errors joined. func concurrently(n int, request func(i int) error) error { errs := make([]error, n) slots := make(chan struct{}, concurrentRequests) var wg sync.WaitGroup for i := range n { slots <- struct{}{} wg.Go(func() { defer func() { <-slots }() errs[i] = request(i) }) } wg.Wait() return errors.Join(errs...) } // IsMember reports whether username belongs to the organization. func (s Organizations) IsMember(ctx context.Context, name, username string) (bool, error) { return s.check(ctx, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { _, resp, err := c.Organizations.CheckOrgMembership(ctx, name, username) return resp, err }) } // IsPublicMember reports whether username's membership of the organization is public. func (s Organizations) IsPublicMember(ctx context.Context, name, username string) (bool, error) { return s.check(ctx, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { _, resp, err := c.Organizations.CheckPublicOrgMembership(ctx, name, username) return resp, err }) } // check runs a membership check that answers no with a 404. func (s Organizations) check(ctx context.Context, op func(context.Context, *gitea.Client) (*gitea.Response, error)) (bool, error) { err := do(ctx, s.Client, op) if statusNotFound(err) { return false, nil } return err == nil, err } // RemoveMember removes an organization member and their organization access. func (s Organizations) RemoveMember(ctx context.Context, name, username string) error { return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { return c.Organizations.DeleteOrgMembership(ctx, name, username) }) } // SetMemberPublic changes public visibility of an existing membership. func (s Organizations) SetMemberPublic(ctx context.Context, name, username string, public bool) error { return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { return c.Organizations.SetPublicOrgMembership(ctx, name, username, public) }) }