mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
fix(team): take team repositories as arguments and address teams by name
team repo add 5 org/repo was rejected, and team repo add 5 silently used
the repository of the current directory and failed with an internal
error. team repo add and remove now take the repositories as arguments,
resolve bare names inside the team's organization, refuse repositories of
other owners and --repo, and never discover a repository.
Teams can be addressed as ORG/TEAM by name or ID as well as by their
global ID. team list drops the always-empty ORG column and shows the
derived access ("write (code, issues, pulls)" instead of none), member
and repository counts; team view shows units, members and repositories.
--permission read or write now grants every repository unit on both
create and edit, and edit no longer shows the create default. team
create takes the name as ORG/NAME or a second argument. Member and
repository changes skip users and repositories already in the wanted
state with a warning, name them in confirmations and print ✓ lines. Every
command has examples, and teams, members, users, repositories and --unit
values complete.
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
7 files changed
+1035
-236
No files matched your search
+175
-23
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
@@ -26,6 +28,72 @@ type Team struct {
|
||||
Units map[string]string `json:"units"`
|
||||
}
|
||||
|
||||
// TeamUnits are the repository units a team grants access to, in the order Gitea shows them.
|
||||
var TeamUnits = []string{"repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.wiki", "repo.projects", "repo.packages", "repo.actions"}
|
||||
|
||||
// ExternalTeamUnits link to external trackers and wikis, which teams can at most read.
|
||||
var ExternalTeamUnits = []string{"repo.ext_issues", "repo.ext_wiki"}
|
||||
|
||||
// UnitAccess lists the access levels a team can have to unit, or nil for an unknown unit.
|
||||
func UnitAccess(unit string) []string {
|
||||
switch {
|
||||
case slices.Contains(TeamUnits, unit):
|
||||
return []string{"none", "read", "write"}
|
||||
case slices.Contains(ExternalTeamUnits, unit):
|
||||
return []string{"none", "read"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnitPolicy grants access, read or write, to every unit, and read to the external units.
|
||||
func UnitPolicy(access string) map[string]string {
|
||||
units := make(map[string]string, len(TeamUnits)+len(ExternalTeamUnits))
|
||||
for _, unit := range TeamUnits {
|
||||
units[unit] = access
|
||||
}
|
||||
for _, unit := range ExternalTeamUnits {
|
||||
units[unit] = "read"
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// Access summarizes what the team may do in its repositories: owner or admin; the one level it
|
||||
// grants, such as write, followed by the units when it does not grant all of them, as in
|
||||
// "write (code, issues, pulls)"; none; or custom when it grants different levels.
|
||||
func (t Team) Access() string {
|
||||
if t.Permission == "owner" || t.Permission == "admin" {
|
||||
return t.Permission
|
||||
}
|
||||
var level string
|
||||
var granted []string
|
||||
for _, unit := range TeamUnits {
|
||||
access := t.Units[unit]
|
||||
if access == "" || access == "none" {
|
||||
continue
|
||||
}
|
||||
if level != "" && access != level {
|
||||
return "custom"
|
||||
}
|
||||
level = access
|
||||
granted = append(granted, strings.TrimPrefix(unit, "repo."))
|
||||
}
|
||||
switch {
|
||||
case level == "":
|
||||
return "none"
|
||||
case len(granted) == len(TeamUnits):
|
||||
return level
|
||||
}
|
||||
return level + " (" + strings.Join(granted, ", ") + ")"
|
||||
}
|
||||
|
||||
// Slug names the team as ORG/TEAM, or by name alone when its organization is unknown.
|
||||
func (t Team) Slug() string {
|
||||
if t.Organization == "" {
|
||||
return t.Name
|
||||
}
|
||||
return t.Organization + "/" + t.Name
|
||||
}
|
||||
|
||||
func teamFrom(team *gitea.Team) Team {
|
||||
result := Team{
|
||||
ID: team.ID, Name: team.Name, Description: team.Description, Permission: string(team.Permission),
|
||||
@@ -56,19 +124,30 @@ type TeamOptions struct {
|
||||
type Teams struct{ Client *transport.Client }
|
||||
|
||||
// List lists teams in one organization.
|
||||
func (s Teams) List(ctx context.Context, org string, limit, page int) ([]Team, error) {
|
||||
teams, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Team, *gitea.Response, error) {
|
||||
return c.Organizations.ListOrgTeams(ctx, org, gitea.ListTeamsOptions{ListOptions: listOptions(limit, page)})
|
||||
func (s Teams) List(ctx context.Context, org string, limit, page int) (Page[Team], error) {
|
||||
teams, err := listPage(limit, page, func(limit, page int) ([]*gitea.Team, http.Header, error) {
|
||||
teams, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Team, *gitea.Response, error) {
|
||||
return c.Organizations.ListOrgTeams(ctx, org, gitea.ListTeamsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
return teams, headerOf(resp), err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return inOrganization(convertPage(teams, teamFrom), org), err
|
||||
}
|
||||
|
||||
// inOrganization sets the organization of teams that the server listed without it.
|
||||
func inOrganization(teams Page[Team], org string) Page[Team] {
|
||||
for i := range teams.Items {
|
||||
teams.Items[i].Organization = org
|
||||
}
|
||||
return convertAll(teams, teamFrom), nil
|
||||
return teams
|
||||
}
|
||||
|
||||
// ByName indexes every team of an organization by lower-case name, which Gitea keeps unique.
|
||||
func (s Teams) ByName(ctx context.Context, org string) (map[string]Team, error) {
|
||||
teams, err := listAll(func(page int) ([]Team, error) { return s.List(ctx, org, 50, page) })
|
||||
teams, err := listAll(func(page int) ([]Team, error) {
|
||||
teams, err := s.List(ctx, org, 50, page)
|
||||
return teams.Items, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -79,17 +158,64 @@ func (s Teams) ByName(ctx context.Context, org string) (map[string]Team, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Find returns the team of org that team addresses by name or ID, as NameOrID matches them.
|
||||
func (s Teams) Find(ctx context.Context, org, team string) (Team, error) {
|
||||
query := team
|
||||
if _, err := strconv.ParseInt(team, 10, 64); err == nil {
|
||||
query = ""
|
||||
}
|
||||
teams, err := listAll(func(page int) ([]Team, error) {
|
||||
teams, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Team, *gitea.Response, error) {
|
||||
return c.Organizations.SearchOrgTeams(ctx, org, &gitea.SearchTeamsOptions{ListOptions: listOptions(50, page), Query: query})
|
||||
})
|
||||
return convertAll(teams, teamFrom), err
|
||||
})
|
||||
if err != nil {
|
||||
return Team{}, Classify(err, "organization", strconv.Quote(org), "")
|
||||
}
|
||||
found, err := NameOrID[Team]{Kind: "team", Scope: org, ID: func(t Team) int64 { return t.ID }, Name: func(t Team) string { return t.Name }}.Find(teams, team)
|
||||
found.Organization = org
|
||||
return found, err
|
||||
}
|
||||
|
||||
// View retrieves a team and its current permissions.
|
||||
func (s Teams) View(ctx context.Context, id int64) (Team, error) {
|
||||
team, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Team, *gitea.Response, error) {
|
||||
return c.Organizations.GetTeam(ctx, id)
|
||||
})
|
||||
if err != nil {
|
||||
return Team{}, err
|
||||
return Team{}, Classify(err, "team", strconv.FormatInt(id, 10), "")
|
||||
}
|
||||
return teamFrom(team), nil
|
||||
}
|
||||
|
||||
// TeamSize counts the members and repositories of a team; a count the server omits is nil.
|
||||
type TeamSize struct {
|
||||
Members, Repositories *int
|
||||
}
|
||||
|
||||
// Sizes counts the members and repositories of each team.
|
||||
func (s Teams) Sizes(ctx context.Context, teams []Team) ([]TeamSize, error) {
|
||||
sizes := make([]TeamSize, len(teams))
|
||||
err := concurrently(len(teams), func(i int) error {
|
||||
id, first := teams[i].ID, listOptions(1, 1)
|
||||
members, err := listTotal(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||
_, resp, err := c.Organizations.ListTeamMembers(ctx, id, gitea.ListTeamMembersOptions{ListOptions: first})
|
||||
return resp, err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repositories, err := listTotal(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||
_, resp, err := c.Organizations.ListTeamRepositories(ctx, id, gitea.ListTeamRepositoriesOptions{ListOptions: first})
|
||||
return resp, err
|
||||
})
|
||||
sizes[i] = TeamSize{Members: members, Repositories: repositories}
|
||||
return err
|
||||
})
|
||||
return sizes, err
|
||||
}
|
||||
|
||||
// Create creates an organization team with explicit initial permissions.
|
||||
func (s Teams) Create(ctx context.Context, org string, opts TeamOptions) (Team, error) {
|
||||
team, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Team, *gitea.Response, error) {
|
||||
@@ -101,7 +227,9 @@ func (s Teams) Create(ctx context.Context, org string, opts TeamOptions) (Team,
|
||||
if err != nil {
|
||||
return Team{}, err
|
||||
}
|
||||
return teamFrom(team), nil
|
||||
created := teamFrom(team)
|
||||
created.Organization = org
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// Edit merges Units into the current unit policy unless ReplaceUnits is set, and preserves unit
|
||||
@@ -144,43 +272,67 @@ func (s Teams) Delete(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
// Members lists members of a team.
|
||||
func (s Teams) Members(ctx context.Context, id int64, limit, page int) ([]OrganizationMember, error) {
|
||||
users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) {
|
||||
return c.Organizations.ListTeamMembers(ctx, id, gitea.ListTeamMembersOptions{ListOptions: listOptions(limit, page)})
|
||||
func (s Teams) Members(ctx context.Context, id int64, limit, page int) (Page[OrganizationMember], error) {
|
||||
users, err := listPage(limit, 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) {
|
||||
return c.Organizations.ListTeamMembers(ctx, id, gitea.ListTeamMembersOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
return users, headerOf(resp), err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return convertPage(users, memberFrom), err
|
||||
}
|
||||
|
||||
// IsMember reports whether username belongs to the team.
|
||||
func (s Teams) IsMember(ctx context.Context, id int64, username string) (bool, error) {
|
||||
_, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) {
|
||||
return c.Organizations.GetTeamMember(ctx, id, username)
|
||||
})
|
||||
if statusNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return convertAll(users, memberFrom), nil
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SetMember adds or removes one team membership.
|
||||
func (s Teams) SetMember(ctx context.Context, id int64, username string, add bool) error {
|
||||
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||
if add {
|
||||
return c.Organizations.AddTeamMember(ctx, id, username)
|
||||
}
|
||||
return c.Organizations.RemoveTeamMember(ctx, id, username)
|
||||
})
|
||||
return Classify(err, "user", strconv.Quote(username), "")
|
||||
}
|
||||
|
||||
// Repositories lists repositories accessible to a team.
|
||||
func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) ([]Repository, error) {
|
||||
repos, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) {
|
||||
return c.Organizations.ListTeamRepositories(ctx, id, gitea.ListTeamRepositoriesOptions{ListOptions: listOptions(limit, page)})
|
||||
func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) (Page[Repository], error) {
|
||||
repos, err := listPage(limit, page, func(limit, page int) ([]*gitea.Repository, http.Header, error) {
|
||||
repos, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) {
|
||||
return c.Organizations.ListTeamRepositories(ctx, id, gitea.ListTeamRepositoriesOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
return repos, headerOf(resp), err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return convertPage(repos, repositoryFrom), err
|
||||
}
|
||||
|
||||
// HasRepository reports whether the team has access to the owner/name repository.
|
||||
func (s Teams) HasRepository(ctx context.Context, id int64, repository string) (bool, error) {
|
||||
_, err := repoCall(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
||||
return c.Organizations.GetTeamRepository(ctx, id, owner, name)
|
||||
})
|
||||
if statusNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return convertAll(repos, repositoryFrom), nil
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SetRepository adds or removes team access to a repository.
|
||||
func (s Teams) SetRepository(ctx context.Context, id int64, repository string, add bool) error {
|
||||
return repoDo(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||
err := repoDo(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||
if add {
|
||||
return c.Organizations.AddTeamRepository(ctx, id, owner, name)
|
||||
}
|
||||
return c.Organizations.RemoveTeamRepository(ctx, id, owner, name)
|
||||
})
|
||||
return Classify(err, "repository", repository, "")
|
||||
}
|
||||
@@ -102,10 +102,10 @@ func TestTeamAccessRequests(t *testing.T) {
|
||||
service := Teams{Client: client}
|
||||
members, err := service.Members(context.Background(), 3, 5, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []OrganizationMember{{ID: 2, Username: "bob"}}, members)
|
||||
require.Equal(t, []OrganizationMember{{ID: 2, Username: "bob"}}, members.Items)
|
||||
repos, err := service.Repositories(context.Background(), 3, 5, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://gitea.test/acme/project", repos[0].URL)
|
||||
require.Equal(t, "https://gitea.test/acme/project", repos.Items[0].URL)
|
||||
require.NoError(t, service.SetMember(context.Background(), 3, "bob", false))
|
||||
require.NoError(t, service.SetRepository(context.Background(), 3, "acme/project", true))
|
||||
require.Equal(t, []string{
|
||||
@@ -118,3 +118,50 @@ func TestTeamFromKeepsAbsentValuesEmpty(t *testing.T) {
|
||||
item := teamFrom(decode[gitea.Team](t, `{"id":1,"name":"Owners","permission":"owner","organization":null,"units_map":null}`))
|
||||
require.Equal(t, Team{ID: 1, Name: "Owners", Permission: "owner", Units: map[string]string{}}, item)
|
||||
}
|
||||
|
||||
func TestTeamAccessSummarizesUnits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
permission string
|
||||
units map[string]string
|
||||
want string
|
||||
}{
|
||||
{"owner", UnitPolicy("write"), "owner"},
|
||||
{"admin", nil, "admin"},
|
||||
{"none", UnitPolicy("write"), "write"},
|
||||
{"none", UnitPolicy("read"), "read"},
|
||||
{"none", map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write", "repo.wiki": "none", "repo.ext_wiki": "read"}, "write (code, issues, pulls)"},
|
||||
{"none", map[string]string{"repo.code": "write", "repo.wiki": "read"}, "custom"},
|
||||
{"none", map[string]string{"repo.ext_issues": "read"}, "none"},
|
||||
} {
|
||||
require.Equal(t, tc.want, Team{Permission: tc.permission, Units: tc.units}.Access(), "%v", tc.units)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamFindSearchesTheOrganization(t *testing.T) {
|
||||
var queries []string
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/orgs/nope/teams/search" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/api/v1/orgs/acme/teams/search", r.URL.Path)
|
||||
queries = append(queries, r.URL.Query().Get("q"))
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":[]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":[{"id":2,"name":"dev"},{"id":3,"name":"Dev"},{"id":4,"name":"devops"}]}`))
|
||||
}))
|
||||
service := Teams{Client: client}
|
||||
team, err := service.Find(context.Background(), "acme", "devops")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Team{ID: 4, Name: "devops", Organization: "acme", Units: map[string]string{}}, team)
|
||||
team, err = service.Find(context.Background(), "acme", "3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Dev", team.Name)
|
||||
_, err = service.Find(context.Background(), "acme", "DEV")
|
||||
require.EqualError(t, err, `team "DEV" is ambiguous in acme (IDs 2, 3); pass an ID`)
|
||||
_, err = service.Find(context.Background(), "nope", "dev")
|
||||
require.EqualError(t, err, `organization "nope" not found`)
|
||||
require.Equal(t, []string{"devops", "devops", "", "", "DEV", "DEV"}, queries)
|
||||
}
|
||||
Reference in new issue
Block a user