Files
gitea-tea/internal/services/organizations.go
T
bircni 8bf979099b perf: drop wasted requests and reuse markdown renderers
pr edit with reviewers and other fields reads the pull request once,
org delete counts only repositories, org view counts concurrently,
team list skips the repository count of all-repository teams,
pr diff --web builds the URL locally, pr list re-reads pull requests
concurrently for review fields, and glamour renderers are built once
per width and color.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 18:44:29 +02:00

313 lines
12 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"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 IsNotFound(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
}
// 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
}
// 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{}, Classify(err, "organization", strconv.Quote(name), "")
}
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; a count the account cannot
// read is nil.
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
},
orgRepos(name),
}
totals := make([]*int, len(counts))
_ = concurrently(len(counts), func(i int) error {
totals[i], _ = listTotal(ctx, s.Client, counts[i])
return nil
})
if ctx.Err() != nil {
return OrganizationSize{}, ctx.Err()
}
return OrganizationSize{Members: totals[0], Teams: totals[1], Repositories: totals[2]}, nil
}
// RepositoryCount counts the repositories of an organization, or returns nil when the account
// cannot read the count.
func (s Organizations) RepositoryCount(ctx context.Context, name string) (*int, error) {
total, err := listTotal(ctx, s.Client, orgRepos(name))
if ctx.Err() != nil {
return nil, ctx.Err()
}
if err != nil {
return nil, nil
}
return total, nil
}
// orgRepos requests the first repository of an organization, to count them.
func orgRepos(name string) func(context.Context, *gitea.Client) (*gitea.Response, error) {
return func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
_, resp, err := c.Repositories.ListOrgRepos(ctx, name, gitea.ListOrgReposOptions{ListOptions: listOptions(1, 1)})
return resp, err
}
}
// 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{}, Classify(err, "organization", "", "")
}
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 {
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Organizations.DeleteOrg(ctx, name)
})
return Classify(err, "organization", strconv.Quote(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 IsNotFound(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)
})
}