mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11: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
@@ -562,17 +562,6 @@ const (
|
|||||||
ssh-key list
|
ssh-key list
|
||||||
ssh-key view
|
ssh-key view
|
||||||
status
|
status
|
||||||
team create
|
|
||||||
team delete
|
|
||||||
team edit
|
|
||||||
team list
|
|
||||||
team member add
|
|
||||||
team member list
|
|
||||||
team member remove
|
|
||||||
team repo add
|
|
||||||
team repo list
|
|
||||||
team repo remove
|
|
||||||
team view
|
|
||||||
time create
|
time create
|
||||||
time delete
|
time delete
|
||||||
time list
|
time list
|
||||||
@@ -620,9 +609,6 @@ const (
|
|||||||
search
|
search
|
||||||
secret
|
secret
|
||||||
ssh-key
|
ssh-key
|
||||||
team
|
|
||||||
team member
|
|
||||||
team repo
|
|
||||||
time
|
time
|
||||||
variable
|
variable
|
||||||
webhook
|
webhook
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ func (a *application) completeTeams() cobra.CompletionFunc {
|
|||||||
teams := a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
|
teams := a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
|
||||||
org, _, _ := strings.Cut(toComplete, "/")
|
org, _, _ := strings.Cut(toComplete, "/")
|
||||||
items, err := services.Teams{Client: t.client}.List(ctx, org, completionLimit, 1)
|
items, err := services.Teams{Client: t.client}.List(ctx, org, completionLimit, 1)
|
||||||
return describe(items, func(item services.Team) (string, string) {
|
return describe(items.Items, func(item services.Team) (string, string) {
|
||||||
return org + "/" + item.Name, strings.TrimSpace(item.Description + " (ID " + strconv.FormatInt(item.ID, 10) + ")")
|
return org + "/" + item.Name, strings.TrimSpace(item.Description + " (ID " + strconv.FormatInt(item.ID, 10) + ")")
|
||||||
}), err
|
}), err
|
||||||
})
|
})
|
||||||
|
|||||||
+671
-192
@@ -4,8 +4,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -15,230 +18,706 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var teamUnitNames = map[string]bool{"repo.code": true, "repo.issues": true, "repo.pulls": true, "repo.ext_issues": true, "repo.wiki": true, "repo.ext_wiki": true, "repo.releases": true, "repo.projects": true, "repo.packages": true, "repo.actions": true}
|
// teamArguments describes the <team> argument in help.
|
||||||
|
const teamArguments = `A team can be supplied as ORG/TEAM, such as acme/developers, where TEAM is the team's
|
||||||
|
name or ID, or as its numeric ID alone.`
|
||||||
|
|
||||||
|
// teamRef is a parsed <team> argument: ORG/TEAM, or a team ID.
|
||||||
|
type teamRef struct {
|
||||||
|
org, name string
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTeam(raw string) (teamRef, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if org, name, scoped := strings.Cut(raw, "/"); scoped {
|
||||||
|
if org != "" && name != "" && !strings.Contains(name, "/") {
|
||||||
|
return teamRef{org: org, name: name}, nil
|
||||||
|
}
|
||||||
|
} else if id, err := strconv.ParseInt(strings.TrimPrefix(raw, "#"), 10, 64); err == nil && id > 0 {
|
||||||
|
return teamRef{id: id}, nil
|
||||||
|
}
|
||||||
|
return teamRef{}, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid team %q: use ORG/TEAM, such as acme/developers, or a team ID", raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// find looks the team up on the instance.
|
||||||
|
func (r teamRef) find(ctx context.Context, service services.Teams) (services.Team, error) {
|
||||||
|
if r.org != "" {
|
||||||
|
return service.Find(ctx, r.org, r.name)
|
||||||
|
}
|
||||||
|
return service.View(ctx, r.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// team resolves the target and the team that raw addresses.
|
||||||
|
func (a *application) team(cmd *cobra.Command, raw string) (target, services.Teams, services.Team, error) {
|
||||||
|
ref, err := parseTeam(raw)
|
||||||
|
if err != nil {
|
||||||
|
return target{}, services.Teams{}, services.Team{}, err
|
||||||
|
}
|
||||||
|
t, err := a.resolve(cmd.Context(), false)
|
||||||
|
if err != nil {
|
||||||
|
return t, services.Teams{}, services.Team{}, err
|
||||||
|
}
|
||||||
|
service := services.Teams{Client: t.client}
|
||||||
|
team, err := ref.find(cmd.Context(), service)
|
||||||
|
return t, service, team, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamURL is the page of a team on its instance.
|
||||||
|
func teamURL(t target, team services.Team) string {
|
||||||
|
return webURL(t, "org", team.Organization, "teams", strings.ToLower(team.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeTeamThen completes a team, then the values that next lists for that team.
|
||||||
|
func (a *application) completeTeamThen(next func(ctx context.Context, t target, team services.Team) ([]cobra.Completion, error)) cobra.CompletionFunc {
|
||||||
|
teams := a.completeTeams()
|
||||||
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return teams(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
||||||
|
ref, err := parseTeam(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
team, err := ref.find(ctx, services.Teams{Client: t.client})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return next(ctx, t, team)
|
||||||
|
})(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) teamCommand() *cobra.Command {
|
func (a *application) teamCommand() *cobra.Command {
|
||||||
group := &cobra.Command{Use: "team", Short: "Manage organization teams and access"}
|
group := &cobra.Command{Use: "team", Short: "Manage organization teams and access", Long: `Manage the teams of an organization: what they may do in its repositories, who belongs to
|
||||||
|
them, and which repositories they can reach.
|
||||||
|
|
||||||
|
` + teamArguments}
|
||||||
|
group.AddCommand(a.teamListCommand(), a.teamViewCommand(), a.teamCreateCommand(), a.teamEditCommand(), a.teamDeleteCommand(), a.teamMemberCommand(), a.teamRepoCommand())
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) teamListCommand() *cobra.Command {
|
||||||
var limit, page int
|
var limit, page int
|
||||||
list := &cobra.Command{Use: "list <org>", Short: "List organization teams", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
cmd := &cobra.Command{
|
||||||
target, err := a.resolve(cmd.Context(), false)
|
Use: "list <org>", Short: "List organization teams", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(),
|
||||||
if err != nil {
|
Long: "List the teams of an organization with their access to its repositories and their sizes.",
|
||||||
return err
|
Example: `$ tea team list acme
|
||||||
}
|
# print the team names
|
||||||
items, err := (services.Teams{Client: target.client}).List(cmd.Context(), args[0], limit, page)
|
$ tea team list acme --jq '.[].name'`,
|
||||||
if err != nil {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeList(a, cmd, items, teamRow)
|
|
||||||
}}
|
|
||||||
pageFlags(list, &limit, &page)
|
|
||||||
group.AddCommand(list)
|
|
||||||
for _, verb := range []string{"view", "create", "edit", "delete"} {
|
|
||||||
var name, description, permission string
|
|
||||||
var createRepo, allRepos, force, replaceUnits bool
|
|
||||||
var units map[string]string
|
|
||||||
command := &cobra.Command{Use: verb + " <id>", Short: titleCase(verb) + " an organization team", Args: argsExact(1)}
|
|
||||||
switch verb {
|
|
||||||
case "create":
|
|
||||||
command.Use = "create <org>"
|
|
||||||
case "delete":
|
|
||||||
command.Annotations = textOutput("team deletion does not return a resource")
|
|
||||||
command.Flags().BoolVar(&force, "force", false, "Allow deleting the organization's owner team")
|
|
||||||
}
|
|
||||||
if verb == "create" || verb == "edit" {
|
|
||||||
command.Flags().StringVar(&name, "name", "", "Team name")
|
|
||||||
command.Flags().StringVar(&description, "description", "", "Team description")
|
|
||||||
command.Flags().StringVar(&permission, "permission", "read", "Default access: read, write, admin")
|
|
||||||
unitUsage := "Unit permissions, for example repo.code=write,repo.issues=read"
|
|
||||||
if verb == "edit" {
|
|
||||||
unitUsage = "Unit permissions to change, for example repo.wiki=write; merged into the current policy"
|
|
||||||
command.Flags().BoolVar(&replaceUnits, "unit-replace", false, "Replace the whole unit policy with --unit; omitted units lose access")
|
|
||||||
}
|
|
||||||
command.Flags().StringToStringVar(&units, "unit", nil, unitUsage)
|
|
||||||
command.Flags().BoolVar(&createRepo, "can-create-repo", false, "Allow creating organization repositories")
|
|
||||||
command.Flags().BoolVar(&allRepos, "all-repositories", false, "Access every organization repository")
|
|
||||||
}
|
|
||||||
command.RunE = func(cmd *cobra.Command, args []string) error {
|
|
||||||
var id int64
|
|
||||||
var err error
|
|
||||||
opts := services.TeamOptions{}
|
|
||||||
if verb != "create" {
|
|
||||||
id, err = number(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verb == "create" || verb == "edit" {
|
|
||||||
if err := oneOf("permission", permission, "read", "write", "admin"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if replaceUnits && !cmd.Flags().Changed("unit") {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--unit-replace requires --unit"))
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("unit") && (len(units) == 0 || permission == "admin") {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--unit needs a nonempty policy and cannot restrict administrator access"))
|
|
||||||
}
|
|
||||||
for unit, access := range units {
|
|
||||||
if !teamUnitNames[unit] || (access != "none" && access != "read" && access != "write") {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--unit requires repo.* keys with none, read, or write access"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verb == "create" {
|
|
||||||
if _, err := a.require(cmd, &name, "name"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
opts.Name = &name
|
|
||||||
opts.Permission = &permission
|
|
||||||
opts.CanCreateRepo = &createRepo
|
|
||||||
opts.AllRepositories = &allRepos
|
|
||||||
if !cmd.Flags().Changed("unit") {
|
|
||||||
opts.Units = map[string]string{"repo.code": permission, "repo.issues": permission, "repo.pulls": permission}
|
|
||||||
if permission == "admin" {
|
|
||||||
opts.Units = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("name") {
|
|
||||||
if strings.TrimSpace(name) == "" {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("team name cannot be empty"))
|
|
||||||
}
|
|
||||||
opts.Name = &name
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("description") {
|
|
||||||
opts.Description = &description
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("permission") {
|
|
||||||
opts.Permission = &permission
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("unit") {
|
|
||||||
opts.Units = units
|
|
||||||
opts.ReplaceUnits = replaceUnits
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("can-create-repo") {
|
|
||||||
opts.CanCreateRepo = &createRepo
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("all-repositories") {
|
|
||||||
opts.AllRepositories = &allRepos
|
|
||||||
}
|
|
||||||
if verb == "edit" && opts.Name == nil && opts.Description == nil && opts.Permission == nil && opts.Units == nil && opts.CanCreateRepo == nil && opts.AllRepositories == nil {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("supply team fields to edit"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
target, err := a.resolve(cmd.Context(), false)
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
org := args[0]
|
||||||
service := services.Teams{Client: target.client}
|
service := services.Teams{Client: target.client}
|
||||||
var item services.Team
|
teams, err := service.List(cmd.Context(), org, limit, page)
|
||||||
switch verb {
|
|
||||||
case "view":
|
|
||||||
item, err = service.View(cmd.Context(), id)
|
|
||||||
case "create":
|
|
||||||
item, err = service.Create(cmd.Context(), args[0], opts)
|
|
||||||
case "edit":
|
|
||||||
item, err = service.Edit(cmd.Context(), id, opts)
|
|
||||||
case "delete":
|
|
||||||
return a.deleteTeam(cmd, service, id, force)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeItem(a, cmd, item, teamRow)
|
sizes := map[int64]services.TeamSize{}
|
||||||
}
|
if a.options.json == "" {
|
||||||
group.AddCommand(command)
|
counted, err := service.Sizes(cmd.Context(), teams.Items)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, team := range teams.Items {
|
||||||
|
sizes[team.ID] = counted[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeTable(a, cmd, teams.Items, listSpec[services.Team]{
|
||||||
|
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name"}, {Header: "Permission", Flex: true}, {Header: "Members"}, {Header: "Repos"}},
|
||||||
|
Row: func(team services.Team) []terminal.Cell {
|
||||||
|
size, repos := sizes[team.ID], "all"
|
||||||
|
if !team.AllRepositories {
|
||||||
|
repos = countText(size.Repositories)
|
||||||
|
}
|
||||||
|
return []terminal.Cell{{Text: strconv.FormatInt(team.ID, 10), Link: teamURL(target, team)}, {Text: team.Name, Bold: true}, {Text: team.Access()}, {Text: countText(size.Members)}, {Text: repos}}
|
||||||
|
},
|
||||||
|
Context: "teams in " + org, Empty: "no teams in " + org,
|
||||||
|
}, servicePage(teams, page))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
group.AddCommand(a.teamAccessCommand(false), a.teamAccessCommand(true))
|
pageFlags(cmd, &limit, &page)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// countText shows a count, or nothing when it is unknown.
|
||||||
|
func countText(n *int) string {
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(*n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listedNames shows the first names of a listing and how many more there are.
|
||||||
|
func listedNames[T any](page services.Page[T], name func(T) string) string {
|
||||||
|
names := make([]string, len(page.Items))
|
||||||
|
for i, item := range page.Items {
|
||||||
|
names[i] = name(item)
|
||||||
|
}
|
||||||
|
text := strings.Join(names, ", ")
|
||||||
|
switch {
|
||||||
|
case len(names) == 0:
|
||||||
|
text = "none"
|
||||||
|
case page.Counted && page.Total > len(names):
|
||||||
|
text += fmt.Sprintf(" and %d more", page.Total-len(names))
|
||||||
|
case page.More:
|
||||||
|
text += " and more"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamViewNames is how many members and repositories a team view lists.
|
||||||
|
const teamViewNames = 20
|
||||||
|
|
||||||
|
func (a *application) teamViewCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "view <team>", Short: "View a team", Args: argsExact(1), ValidArgsFunction: a.completeTeams(),
|
||||||
|
Long: "View a team: its access to each repository unit, its members and its repositories.",
|
||||||
|
Example: `$ tea team view acme/developers
|
||||||
|
$ tea team view 2 --json units`,
|
||||||
|
Annotations: map[string]string{"tea.arguments": teamArguments},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
target, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.options.json != "" {
|
||||||
|
return a.writeJSON(cmd, team)
|
||||||
|
}
|
||||||
|
members, err := service.Members(cmd.Context(), team.ID, teamViewNames, 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repos, err := service.Repositories(cmd.Context(), team.ID, teamViewNames, 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeView(a, cmd, team, func(team services.Team) terminal.View {
|
||||||
|
var units []string
|
||||||
|
for _, unit := range slices.Concat(services.TeamUnits, services.ExternalTeamUnits) {
|
||||||
|
if access := team.Units[unit]; access != "" && access != "none" {
|
||||||
|
units = append(units, strings.TrimPrefix(unit, "repo.")+": "+access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories := listedNames(repos, func(repo services.Repository) string { return repo.FullName })
|
||||||
|
if team.AllRepositories {
|
||||||
|
repositories = "all repositories of " + team.Organization
|
||||||
|
}
|
||||||
|
return terminal.View{
|
||||||
|
Title: team.Slug(), ID: fmt.Sprintf("(ID %d)", team.ID), Badges: []terminal.Cell{{Text: team.Access(), Bold: true}},
|
||||||
|
Fields: []terminal.Field{
|
||||||
|
{Label: "Access", Value: team.Access(), PipedOnly: true},
|
||||||
|
{Label: "Units", Value: strings.Join(units, ", ")},
|
||||||
|
{Label: "Members", Value: listedNames(members, func(member services.OrganizationMember) string { return member.Username })},
|
||||||
|
{Label: "Repositories", Value: repositories},
|
||||||
|
{Label: "Can create repos", Value: yesNo(team.CanCreateRepo)},
|
||||||
|
},
|
||||||
|
Body: team.Description, Kind: "team", URL: teamURL(target, team),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamPermissions = []string{"read", "write", "admin"}
|
||||||
|
|
||||||
|
// teamFlags are the fields that team create and edit set.
|
||||||
|
type teamFlags struct {
|
||||||
|
name, description, permission string
|
||||||
|
units map[string]string
|
||||||
|
createRepo, allRepos bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// register adds the flags to cmd. Edit flags have no defaults, so that only what is passed changes.
|
||||||
|
func (f *teamFlags) register(cmd *cobra.Command, create bool) {
|
||||||
|
cmd.Flags().StringVar(&f.name, "name", "", "Team name")
|
||||||
|
cmd.Flags().StringVarP(&f.description, "description", "d", "", "Team description")
|
||||||
|
initial, permissionUsage := "", "Access to every repository unit; admin also manages repositories"
|
||||||
|
if create {
|
||||||
|
initial = "read"
|
||||||
|
}
|
||||||
|
enumFlag(cmd, &f.permission, "permission", "", initial, permissionUsage, teamPermissions...)
|
||||||
|
unitUsage := "Unit access, such as repo.code=write,repo.wiki=read; overrides --permission per unit"
|
||||||
|
if !create {
|
||||||
|
unitUsage = "Unit access to change, such as repo.wiki=write; merged into the current policy"
|
||||||
|
}
|
||||||
|
cmd.Flags().StringToStringVar(&f.units, "unit", nil, unitUsage)
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("unit", completeTeamUnits)
|
||||||
|
cmd.Flags().BoolVar(&f.createRepo, "can-create-repo", false, "Allow creating organization repositories (use =false to revoke)")
|
||||||
|
cmd.Flags().BoolVar(&f.allRepos, "all-repositories", false, "Access every organization repository (use =false to revoke)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitPolicy validates --unit and returns the unit policy to send: all units at the --permission
|
||||||
|
// level when it is passed (or, on create, without --unit), with --unit overriding single units.
|
||||||
|
// Administrator teams get no policy, since they can do everything.
|
||||||
|
func (f *teamFlags) unitPolicy(cmd *cobra.Command, create bool) (map[string]string, error) {
|
||||||
|
changed := cmd.Flags().Changed("unit")
|
||||||
|
if changed && len(f.units) == 0 {
|
||||||
|
return nil, clierr.Wrap(clierr.Usage, errors.New("--unit needs at least one unit, such as repo.code=write"))
|
||||||
|
}
|
||||||
|
if changed && f.permission == "admin" {
|
||||||
|
return nil, clierr.Wrap(clierr.Usage, errors.New("--unit cannot restrict administrator access"))
|
||||||
|
}
|
||||||
|
for unit, access := range f.units {
|
||||||
|
levels := services.UnitAccess(unit)
|
||||||
|
if levels == nil {
|
||||||
|
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("unknown unit %q; use one of %s", unit, strings.Join(slices.Concat(services.TeamUnits, services.ExternalTeamUnits), ", ")))
|
||||||
|
}
|
||||||
|
if !slices.Contains(levels, access) {
|
||||||
|
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("--unit %s=%s: access must be %s", unit, access, strings.Join(levels, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var policy map[string]string
|
||||||
|
switch {
|
||||||
|
case f.permission == "admin":
|
||||||
|
return nil, nil
|
||||||
|
case cmd.Flags().Changed("permission") || (create && !changed):
|
||||||
|
policy = services.UnitPolicy(f.permission)
|
||||||
|
case changed:
|
||||||
|
policy = map[string]string{}
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
maps.Copy(policy, f.units)
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// options returns the supplied team fields.
|
||||||
|
func (f *teamFlags) options(cmd *cobra.Command) services.TeamOptions {
|
||||||
|
opts := services.TeamOptions{}
|
||||||
|
for _, field := range []struct {
|
||||||
|
flag string
|
||||||
|
value *string
|
||||||
|
option **string
|
||||||
|
}{{"description", &f.description, &opts.Description}, {"permission", &f.permission, &opts.Permission}} {
|
||||||
|
if cmd.Flags().Changed(field.flag) {
|
||||||
|
*field.option = field.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
flag string
|
||||||
|
value *bool
|
||||||
|
option **bool
|
||||||
|
}{{"can-create-repo", &f.createRepo, &opts.CanCreateRepo}, {"all-repositories", &f.allRepos, &opts.AllRepositories}} {
|
||||||
|
if cmd.Flags().Changed(field.flag) {
|
||||||
|
*field.option = field.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeTeamUnits completes --unit as comma-separated UNIT=ACCESS entries.
|
||||||
|
var completeTeamUnits = commaSeparated(func(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if unit, _, found := strings.Cut(toComplete, "="); found {
|
||||||
|
levels := services.UnitAccess(unit)
|
||||||
|
values := make([]string, len(levels))
|
||||||
|
for i, level := range levels {
|
||||||
|
values[i] = unit + "=" + level
|
||||||
|
}
|
||||||
|
return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
units := slices.Concat(services.TeamUnits, services.ExternalTeamUnits)
|
||||||
|
for i, unit := range units {
|
||||||
|
units[i] = unit + "="
|
||||||
|
}
|
||||||
|
return matching(units, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||||
|
})
|
||||||
|
|
||||||
|
func (a *application) teamCreateCommand() *cobra.Command {
|
||||||
|
var flags teamFlags
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create <org> [<name>]", Short: "Create a team", Args: usageArgs(cobra.RangeArgs(1, 2)),
|
||||||
|
Long: `Create a team in an organization. Name it as the second argument, as ORG/NAME, or with --name.
|
||||||
|
|
||||||
|
--permission grants read or write access to every repository unit, or admin access. Use --unit
|
||||||
|
to set single units instead, or to override some of them.`,
|
||||||
|
Example: `$ tea team create acme developers --permission write
|
||||||
|
$ tea team create acme/reviewers --unit repo.code=read,repo.pulls=write
|
||||||
|
# give the team access to every repository of the organization
|
||||||
|
$ tea team create acme ops --permission admin --all-repositories`,
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return a.completeOrgs()(cmd, args, toComplete)
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
org, name, scoped := strings.Cut(args[0], "/")
|
||||||
|
names := 0
|
||||||
|
for _, given := range []bool{scoped, len(args) > 1, cmd.Flags().Changed("name")} {
|
||||||
|
if given {
|
||||||
|
names++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names > 1 {
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("supply the team name once: as ORG/NAME, as an argument, or with --name"))
|
||||||
|
}
|
||||||
|
if len(args) > 1 {
|
||||||
|
name = args[1]
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("name") {
|
||||||
|
name = flags.name
|
||||||
|
}
|
||||||
|
units, err := flags.unitPolicy(cmd, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.requireInput(cmd, &name, "name", "Team name", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := flags.options(cmd)
|
||||||
|
opts.Name, opts.Permission, opts.CanCreateRepo, opts.AllRepositories, opts.Units = &name, &flags.permission, &flags.createRepo, &flags.allRepos, units
|
||||||
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
team, err := (services.Teams{Client: target.client}).Create(cmd.Context(), org, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Created team %s (ID %d) with %s access", team.Slug(), team.ID, team.Access())
|
||||||
|
if err := a.printURL(cmd, teamURL(target, team)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.jsonResult(cmd, team)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
flags.register(cmd, true)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) teamEditCommand() *cobra.Command {
|
||||||
|
var flags teamFlags
|
||||||
|
var replaceUnits bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "edit <team>", Short: "Edit a team", Args: argsExact(1), ValidArgsFunction: a.completeTeams(),
|
||||||
|
Long: `Edit a team. --permission sets every repository unit to read or write, or grants admin
|
||||||
|
access. --unit changes single units and keeps the others, unless --unit-replace is passed.`,
|
||||||
|
Example: `$ tea team edit acme/developers --permission write
|
||||||
|
# also let the team edit the wiki
|
||||||
|
$ tea team edit acme/developers --unit repo.wiki=write
|
||||||
|
$ tea team edit acme/developers --name devs --description "Core developers"`,
|
||||||
|
Annotations: map[string]string{"tea.arguments": teamArguments},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if _, err := parseTeam(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if replaceUnits && !cmd.Flags().Changed("unit") {
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("--unit-replace requires --unit"))
|
||||||
|
}
|
||||||
|
units, err := flags.unitPolicy(cmd, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := flags.options(cmd)
|
||||||
|
if cmd.Flags().Changed("name") {
|
||||||
|
if strings.TrimSpace(flags.name) == "" {
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("team name cannot be empty"))
|
||||||
|
}
|
||||||
|
opts.Name = &flags.name
|
||||||
|
}
|
||||||
|
opts.Units, opts.ReplaceUnits = units, replaceUnits || cmd.Flags().Changed("permission")
|
||||||
|
if opts.Name == nil && opts.Description == nil && opts.Permission == nil && opts.Units == nil && opts.CanCreateRepo == nil && opts.AllRepositories == nil {
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("supply team fields to edit"))
|
||||||
|
}
|
||||||
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edited, err := service.Edit(cmd.Context(), team.ID, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edited.Organization = team.Organization
|
||||||
|
a.done(cmd, "Edited team %s (ID %d)", edited.Slug(), edited.ID)
|
||||||
|
return a.jsonResult(cmd, edited)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
flags.register(cmd, false)
|
||||||
|
cmd.Flags().BoolVar(&replaceUnits, "unit-replace", false, "Replace the whole unit policy with --unit; omitted units lose access")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) teamDeleteCommand() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete <team>", Short: "Delete a team", Args: argsExact(1), ValidArgsFunction: a.completeTeams(),
|
||||||
|
Long: `Delete a team. Its members lose the access the team gave them.
|
||||||
|
|
||||||
|
Deleting an organization's owner team takes away owner access, so it needs --force and, on a
|
||||||
|
terminal, typing ORG/TEAM.`,
|
||||||
|
Example: `$ tea team delete acme/reviewers
|
||||||
|
$ tea team delete 7 --yes`,
|
||||||
|
Annotations: map[string]string{"tea.text": "team deletion does not return a resource", "tea.confirm": "true", "tea.arguments": teamArguments},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("team %q (ID %d)", team.Name, team.ID)
|
||||||
|
if team.Organization != "" {
|
||||||
|
subject += " from " + team.Organization
|
||||||
|
}
|
||||||
|
confirmation := terminal.Confirmation{Prompt: "Delete " + subject + "?", NonTTY: "delete " + subject}
|
||||||
|
if team.Permission == "owner" {
|
||||||
|
if !force {
|
||||||
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is the organization's owner team and its members would lose owner access; pass --force to delete it", subject))
|
||||||
|
}
|
||||||
|
confirmation = terminal.Confirmation{Prompt: "Delete owner " + subject + "? Its members lose owner access.", Typed: team.Slug(), NonTTY: "delete owner " + subject}
|
||||||
|
}
|
||||||
|
if err := a.confirmAction(cmd, confirmation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := service.Delete(cmd.Context(), team.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Deleted %s", subject)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&force, "force", false, "Allow deleting the organization's owner team")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinNames lists names in a sentence: "bob", "bob and carol", "bob, carol and dave".
|
||||||
|
func joinNames(names []string) string {
|
||||||
|
if len(names) < 2 {
|
||||||
|
return strings.Join(names, "")
|
||||||
|
}
|
||||||
|
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) teamMemberCommand() *cobra.Command {
|
||||||
|
group := &cobra.Command{Use: "member", Short: "Manage team members", Long: `List, add and remove the members of a team. Adding a user to a team of an organization
|
||||||
|
also makes them a member of the organization.
|
||||||
|
|
||||||
|
` + teamArguments}
|
||||||
|
var limit, page int
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list <team>", Short: "List team members", Args: argsExact(1), ValidArgsFunction: a.completeTeams(),
|
||||||
|
Example: "$ tea team member list acme/developers",
|
||||||
|
Annotations: map[string]string{"tea.arguments": teamArguments},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
target, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
members, err := service.Members(cmd.Context(), team.ID, limit, page)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeTable(a, cmd, members.Items, listSpec[services.OrganizationMember]{
|
||||||
|
Columns: []terminal.Column{{Header: "Username"}, {Header: "Name", Flex: true}},
|
||||||
|
Row: func(member services.OrganizationMember) []terminal.Cell {
|
||||||
|
return []terminal.Cell{{Text: member.Username, Link: webURL(target, member.Username), Bold: true}, {Text: member.FullName}}
|
||||||
|
},
|
||||||
|
Context: "members of team " + team.Slug(), Empty: "no members in team " + team.Slug(),
|
||||||
|
}, servicePage(members, page))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pageFlags(list, &limit, &page)
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add <team> <username>...", Short: "Add users to a team", Args: usageArgs(cobra.MinimumNArgs(2)),
|
||||||
|
Example: `$ tea team member add acme/developers bob
|
||||||
|
$ tea team member add 2 bob carol`,
|
||||||
|
Annotations: map[string]string{"tea.text": "team membership changes do not return a resource", "tea.arguments": teamArguments},
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return a.completeTeams()(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
return a.completeInstanceUsers()(cmd, args, toComplete)
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, username := range args[1:] {
|
||||||
|
member, err := service.IsMember(cmd.Context(), team.ID, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if member {
|
||||||
|
a.warn(cmd, "%s is already a member of team %s", username, team.Slug())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := service.SetMember(cmd.Context(), team.ID, username, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Added %s to team %s", username, team.Slug())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remove := &cobra.Command{
|
||||||
|
Use: "remove <team> <username>...", Short: "Remove users from a team", Args: usageArgs(cobra.MinimumNArgs(2)),
|
||||||
|
Example: "$ tea team member remove acme/developers carol",
|
||||||
|
Annotations: map[string]string{"tea.text": "team membership changes do not return a resource", "tea.confirm": "true", "tea.arguments": teamArguments},
|
||||||
|
ValidArgsFunction: a.completeTeamThen(func(ctx context.Context, t target, team services.Team) ([]cobra.Completion, error) {
|
||||||
|
members, err := services.Teams{Client: t.client}.Members(ctx, team.ID, completionLimit, 1)
|
||||||
|
return describe(members.Items, func(member services.OrganizationMember) (string, string) { return member.Username, member.FullName }), err
|
||||||
|
}),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var members []string
|
||||||
|
for _, username := range args[1:] {
|
||||||
|
member, err := service.IsMember(cmd.Context(), team.ID, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !member {
|
||||||
|
a.warn(cmd, "%s is not a member of team %s", username, team.Slug())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
members = append(members, username)
|
||||||
|
}
|
||||||
|
if len(members) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.confirmAction(cmd, terminal.Confirmation{
|
||||||
|
Prompt: fmt.Sprintf("Remove %s from team %s?", joinNames(members), team.Slug()),
|
||||||
|
NonTTY: fmt.Sprintf("remove %s from team %s", joinNames(members), team.Slug()),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, username := range members {
|
||||||
|
if err := service.SetMember(cmd.Context(), team.ID, username, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Removed %s from team %s", username, team.Slug())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
group.AddCommand(list, add, remove)
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTeam names the team in its confirmation and deletes an organization's owner team only
|
// teamRepository resolves a repository argument of team repo add and remove: a name inside the
|
||||||
// with --force, after the user types its name.
|
// team's organization, or an OWNER/NAME that the organization owns.
|
||||||
func (a *application) deleteTeam(cmd *cobra.Command, service services.Teams, id int64, force bool) error {
|
func teamRepository(team services.Team, raw string) (string, error) {
|
||||||
team, err := service.View(cmd.Context(), id)
|
owner, name, scoped := strings.Cut(strings.TrimSpace(raw), "/")
|
||||||
if err != nil {
|
if !scoped {
|
||||||
return err
|
owner, name = team.Organization, owner
|
||||||
}
|
}
|
||||||
subject, answer := fmt.Sprintf("team %q (ID %d)", team.Name, team.ID), team.Name
|
if owner == "" || name == "" || strings.Contains(name, "/") {
|
||||||
if team.Organization != "" {
|
return "", clierr.Wrap(clierr.Usage, fmt.Errorf("invalid repository %q: use NAME or %s/NAME", raw, team.Organization))
|
||||||
subject += " from " + team.Organization
|
|
||||||
answer = team.Organization + "/" + team.Name
|
|
||||||
}
|
}
|
||||||
if team.Permission != "owner" {
|
if !strings.EqualFold(owner, team.Organization) {
|
||||||
err = a.confirm(cmd, "Delete "+subject+"?")
|
return "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s/%s is not owned by organization %s; teams can only be granted repositories of their organization", owner, name, team.Organization))
|
||||||
} else if !force {
|
|
||||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is the organization's owner team and its members would lose owner access; pass --force to delete it", subject))
|
|
||||||
} else {
|
|
||||||
err = a.confirmTyped(cmd, "Delete owner "+subject+"? Its members lose owner access.", answer)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
return team.Organization + "/" + name, nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
return service.Delete(cmd.Context(), id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) teamAccessCommand(repository bool) *cobra.Command {
|
func (a *application) teamRepoCommand() *cobra.Command {
|
||||||
kind := "member"
|
group := &cobra.Command{Use: "repo", Short: "Manage team repository access", Long: `List the repositories a team can reach, and grant or revoke its access to repositories of
|
||||||
if repository {
|
its organization. Repositories are named alone, such as service, or as ORG/NAME.
|
||||||
kind = "repo"
|
|
||||||
}
|
` + teamArguments}
|
||||||
group := &cobra.Command{Use: kind, Short: "Manage team " + kind + " access"}
|
|
||||||
var limit, page int
|
var limit, page int
|
||||||
list := &cobra.Command{Use: "list <id>", Short: "List team " + kind + "s", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
list := &cobra.Command{
|
||||||
id, err := number(args[0])
|
Use: "list <team>", Short: "List team repositories", Args: argsExact(1), ValidArgsFunction: a.completeTeams(),
|
||||||
if err != nil {
|
Example: "$ tea team repo list acme/developers",
|
||||||
return err
|
Annotations: map[string]string{"tea.arguments": teamArguments},
|
||||||
}
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
target, err := a.resolve(cmd.Context(), false)
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
service := services.Teams{Client: target.client}
|
|
||||||
if repository {
|
|
||||||
items, err := service.Repositories(cmd.Context(), id, limit, page)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeList(a, cmd, items, func(item services.Repository) []terminal.Cell { return terminal.Row(item.FullName) })
|
repos, err := service.Repositories(cmd.Context(), team.ID, limit, page)
|
||||||
}
|
if err != nil {
|
||||||
items, err := service.Members(cmd.Context(), id, limit, page)
|
return err
|
||||||
if err != nil {
|
}
|
||||||
return err
|
return writeTable(a, cmd, repos.Items, listSpec[services.Repository]{
|
||||||
}
|
Columns: []terminal.Column{{Header: "Name"}, {Header: "Description", Flex: true}},
|
||||||
return writeList(a, cmd, items, func(item services.OrganizationMember) []terminal.Cell { return terminal.Row(item.Username) })
|
Row: func(repo services.Repository) []terminal.Cell {
|
||||||
}}
|
return []terminal.Cell{{Text: repo.FullName, Link: repo.URL, Bold: true}, {Text: repo.Description}}
|
||||||
|
},
|
||||||
|
Context: "repositories of team " + team.Slug(), Empty: "team " + team.Slug() + " has no repositories",
|
||||||
|
}, servicePage(repos, page))
|
||||||
|
},
|
||||||
|
}
|
||||||
pageFlags(list, &limit, &page)
|
pageFlags(list, &limit, &page)
|
||||||
group.AddCommand(list)
|
group.AddCommand(list, a.teamRepoAccessCommand(true), a.teamRepoAccessCommand(false))
|
||||||
for _, verb := range []string{"add", "remove"} {
|
return group
|
||||||
use := verb + " <id> <username>"
|
}
|
||||||
count := 2
|
|
||||||
if repository {
|
// teamRepoAccessCommand grants or revokes team access to repositories.
|
||||||
use = verb + " <id> --repo owner/repo"
|
func (a *application) teamRepoAccessCommand(grant bool) *cobra.Command {
|
||||||
count = 1
|
verb, short, example := "remove", "Revoke team access to repositories", "$ tea team repo remove acme/developers legacy"
|
||||||
}
|
annotations := map[string]string{"tea.text": "team access changes do not return a resource", "tea.arguments": teamArguments, "tea.confirm": "true"}
|
||||||
group.AddCommand(&cobra.Command{Use: use, Short: titleCase(verb) + " team " + kind + " access", Annotations: textOutput("team access changes do not return a resource"), Args: argsExact(count), RunE: func(cmd *cobra.Command, args []string) error {
|
if grant {
|
||||||
id, err := number(args[0])
|
verb, short, example = "add", "Grant a team access to repositories", "$ tea team repo add acme/developers service\n$ tea team repo add 2 acme/service acme/website"
|
||||||
|
delete(annotations, "tea.confirm")
|
||||||
|
}
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: verb + " <team> <repo>...", Short: short, Args: usageArgs(cobra.MinimumNArgs(2)), Example: example, Annotations: annotations,
|
||||||
|
Long: short + ` of its organization. Name the repositories alone, such as service, or as ORG/NAME;
|
||||||
|
repositories of other owners cannot be granted to a team.`,
|
||||||
|
ValidArgsFunction: a.completeTeamThen(func(ctx context.Context, t target, team services.Team) ([]cobra.Completion, error) {
|
||||||
|
if grant {
|
||||||
|
repos, _, err := services.Repositories{Client: t.client}.List(ctx, services.RepositoryListOptions{Owner: team.Organization, Limit: completionLimit, Page: 1})
|
||||||
|
return describe(repos, func(repo services.Repository) (string, string) { return repo.Name, repo.Description }), err
|
||||||
|
}
|
||||||
|
repos, err := services.Teams{Client: t.client}.Repositories(ctx, team.ID, completionLimit, 1)
|
||||||
|
return describe(repos.Items, func(repo services.Repository) (string, string) { return repo.Name, repo.Description }), err
|
||||||
|
}),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if cmd.Flags().Changed("repo") {
|
||||||
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("--repo is not used by %s; name the repositories as arguments: %s <team> <repo>", cmd.CommandPath(), cmd.CommandPath()))
|
||||||
|
}
|
||||||
|
_, service, team, err := a.team(cmd, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), repository)
|
var repos []string
|
||||||
if err != nil {
|
for _, raw := range args[1:] {
|
||||||
return err
|
repo, err := teamRepository(team, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
has, err := service.HasRepository(cmd.Context(), team.ID, repo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case has && grant:
|
||||||
|
a.warn(cmd, "Team %s already has access to %s", team.Slug(), repo)
|
||||||
|
case !has && !grant:
|
||||||
|
a.warn(cmd, "Team %s has no access to %s", team.Slug(), repo)
|
||||||
|
default:
|
||||||
|
repos = append(repos, repo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if verb == "remove" {
|
if len(repos) == 0 {
|
||||||
if err := a.confirm(cmd, "Remove team "+args[0]+" "+kind+" access"); err != nil {
|
return nil
|
||||||
|
}
|
||||||
|
if !grant {
|
||||||
|
if err := a.confirmAction(cmd, terminal.Confirmation{
|
||||||
|
Prompt: fmt.Sprintf("Revoke team %s access to %s?", team.Slug(), joinNames(repos)),
|
||||||
|
NonTTY: fmt.Sprintf("revoke team %s access to %s", team.Slug(), joinNames(repos)),
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service := services.Teams{Client: target.client}
|
for _, repo := range repos {
|
||||||
if repository {
|
if err := service.SetRepository(cmd.Context(), team.ID, repo, grant); err != nil {
|
||||||
return service.SetRepository(cmd.Context(), id, target.Repo, verb == "add")
|
return err
|
||||||
|
}
|
||||||
|
if grant {
|
||||||
|
a.done(cmd, "Granted team %s access to %s", team.Slug(), repo)
|
||||||
|
} else {
|
||||||
|
a.done(cmd, "Revoked team %s access to %s", team.Slug(), repo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return service.SetMember(cmd.Context(), id, args[1], verb == "add")
|
return nil
|
||||||
}})
|
},
|
||||||
}
|
}
|
||||||
return group
|
|
||||||
}
|
|
||||||
|
|
||||||
func teamRow(item services.Team) []terminal.Cell {
|
|
||||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, item.Organization, item.Permission)
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
@@ -78,3 +79,133 @@ func TestTeamEditMergesUnitsIntoTheCurrentPolicy(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, "--unit-replace requires --unit")
|
require.ErrorContains(t, err, "--unit-replace requires --unit")
|
||||||
require.Len(t, sent, 2)
|
require.Len(t, sent, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTeamRepoAddTakesRepositoriesAsArguments(t *testing.T) {
|
||||||
|
var granted []string
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "GET /api/v1/teams/5":
|
||||||
|
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"none","organization":{"username":"acme"}}`))
|
||||||
|
case "GET /api/v1/teams/5/repos/acme/service", "GET /api/v1/teams/5/repos/acme/website":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case "PUT /api/v1/teams/5/repos/acme/service", "PUT /api/v1/teams/5/repos/acme/website":
|
||||||
|
granted = append(granted, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, _, _ := testDeps(t)
|
||||||
|
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "repo", "add", "5", "service", "acme/website"}))
|
||||||
|
require.Equal(t, []string{"/api/v1/teams/5/repos/acme/service", "/api/v1/teams/5/repos/acme/website"}, granted)
|
||||||
|
for args, want := range map[string]string{
|
||||||
|
"alice/project": "alice/project is not owned by organization acme; teams can only be granted repositories of their organization",
|
||||||
|
"service -R acme/service": "--repo is not used by tea team repo add; name the repositories as arguments",
|
||||||
|
} {
|
||||||
|
err := Execute(context.Background(), deps, append([]string{"team", "repo", "add", "5"}, strings.Fields(args)...))
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
|
||||||
|
require.ErrorContains(t, err, want)
|
||||||
|
}
|
||||||
|
require.Len(t, granted, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamsAreAddressedAsOrganizationAndName(t *testing.T) {
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/api/v1/orgs/acme/teams/search", r.URL.Path)
|
||||||
|
if r.URL.Query().Get("page") != "1" {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":[]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":[{"id":2,"name":"developers","permission":"none","units_map":{"repo.code":"write"}},{"id":7,"name":"developers-old","permission":"none"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, _ := serverDeps(t, server.URL)
|
||||||
|
for _, ref := range []string{"acme/Developers", "acme/developers", "acme/2"} {
|
||||||
|
out.Reset()
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "view", ref, "--json", "id,name,organization"}))
|
||||||
|
require.JSONEq(t, `{"id":2,"name":"developers","organization":"acme"}`, out.String(), ref)
|
||||||
|
}
|
||||||
|
err := Execute(context.Background(), deps, []string{"team", "view", "acme/ops"})
|
||||||
|
require.EqualError(t, err, `team "ops" not found in acme`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamListShowsAccessAndSizes(t *testing.T) {
|
||||||
|
totals := map[string]string{"/api/v1/teams/1/members": "1", "/api/v1/teams/1/repos": "4", "/api/v1/teams/2/members": "3", "/api/v1/teams/2/repos": "2"}
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/v1/orgs/acme/teams" {
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"name":"Owners","permission":"owner","includes_all_repositories":true},
|
||||||
|
{"id":2,"name":"developers","permission":"none","units_map":{"repo.code":"write","repo.issues":"write","repo.pulls":"write"}}]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, ok := totals[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected request %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Total-Count", total)
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, _ := serverDeps(t, server.URL)
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "list", "acme"}))
|
||||||
|
require.Equal(t, "1\tOwners\towner\t1\tall\n2\tdevelopers\twrite (code, issues, pulls)\t3\t2\n", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamPermissionSetsEveryUnit(t *testing.T) {
|
||||||
|
var sent []map[string]any
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
var body map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||||
|
sent = append(sent, body)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"none","organization":{"username":"acme"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, _, _ := serverDeps(t, server.URL)
|
||||||
|
every := func(access string) map[string]any {
|
||||||
|
units := map[string]any{"repo.ext_issues": "read", "repo.ext_wiki": "read"}
|
||||||
|
for _, unit := range []string{"repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.wiki", "repo.projects", "repo.packages", "repo.actions"} {
|
||||||
|
units[unit] = access
|
||||||
|
}
|
||||||
|
return units
|
||||||
|
}
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "create", "acme/devs", "--permission", "write"}))
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "edit", "5", "--permission", "read", "--unit", "repo.wiki=write"}))
|
||||||
|
require.Len(t, sent, 2)
|
||||||
|
require.Equal(t, "devs", sent[0]["name"])
|
||||||
|
require.Equal(t, every("write"), sent[0]["units_map"])
|
||||||
|
wikiWrite := every("read")
|
||||||
|
wikiWrite["repo.wiki"] = "write"
|
||||||
|
require.Equal(t, map[string]any{"permission": "read", "units_map": wikiWrite}, sent[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamMemberRemoveNamesMembersAndSkipsOthers(t *testing.T) {
|
||||||
|
var removed []string
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "GET /api/v1/teams/5":
|
||||||
|
_, _ = w.Write([]byte(`{"id":5,"name":"devs","organization":{"username":"acme"}}`))
|
||||||
|
case "GET /api/v1/teams/5/members/carol":
|
||||||
|
_, _ = w.Write([]byte(`{"login":"carol"}`))
|
||||||
|
case "GET /api/v1/teams/5/members/dave":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case "DELETE /api/v1/teams/5/members/carol":
|
||||||
|
removed = append(removed, "carol")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, _, diagnostics := serverDeps(t, server.URL)
|
||||||
|
deps.Streams.InTTY, deps.Streams.ErrTTY, deps.Streams.OutTTY = true, true, true
|
||||||
|
prompt := &scriptedPrompter{accept: true}
|
||||||
|
deps.Prompter = prompt
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"team", "member", "remove", "5", "carol", "dave"}))
|
||||||
|
require.Equal(t, []string{"Remove carol from team acme/devs?"}, prompt.confirmations)
|
||||||
|
require.Equal(t, []string{"carol"}, removed)
|
||||||
|
require.Equal(t, "! dave is not a member of team acme/devs\n✓ Removed carol from team acme/devs\n", withoutANSI(diagnostics.String()))
|
||||||
|
}
|
||||||
@@ -205,11 +205,15 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`},
|
{[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`},
|
||||||
{[]string{"status", "--json=token"}, "unknown JSON field \"token\""},
|
{[]string{"status", "--json=token"}, "unknown JSON field \"token\""},
|
||||||
{[]string{"team", "create", "org", "--name", "Developers", "--permission", "owner"}, "--permission must be read, write, or admin"},
|
{[]string{"team", "create", "org", "--name", "Developers", "--permission", "owner"}, "--permission must be read, write, or admin"},
|
||||||
{[]string{"team", "create", "org", "--name", "Developers", "--unit", "repo.unknown=write"}, "--unit requires repo.* keys with none, read, or write access"},
|
{[]string{"team", "create", "org", "--name", "Developers", "--unit", "repo.unknown=write"}, `unknown unit "repo.unknown"; use one of repo.code`},
|
||||||
{[]string{"team", "create", "org", "--name", "Developers", "--unit", "repo.code=write", "--permission", "admin"}, "--unit needs a nonempty policy and cannot restrict administrator access"},
|
{[]string{"team", "create", "org", "--name", "Developers", "--unit", "repo.ext_wiki=write"}, "--unit repo.ext_wiki=write: access must be none, read"},
|
||||||
|
{[]string{"team", "create", "org/Developers", "--name", "Developers"}, "supply the team name once"},
|
||||||
|
{[]string{"team", "create", "org", "--name", "Developers", "--unit", "repo.code=write", "--permission", "admin"}, "--unit cannot restrict administrator access"},
|
||||||
{[]string{"team", "edit", "1"}, "supply team fields to edit"},
|
{[]string{"team", "edit", "1"}, "supply team fields to edit"},
|
||||||
{[]string{"team", "edit", "1", "--unit="}, "must be formatted as key=value"},
|
{[]string{"team", "edit", "1", "--unit="}, "must be formatted as key=value"},
|
||||||
{[]string{"team", "member", "add", "invalid", "alice"}, "invalid resource number \"invalid\""},
|
{[]string{"team", "member", "add", "invalid", "alice"}, `invalid team "invalid": use ORG/TEAM, such as acme/developers, or a team ID`},
|
||||||
|
{[]string{"team", "view", "acme/"}, `invalid team "acme/"`},
|
||||||
|
{[]string{"team", "repo", "add", "2", "service", "-R", "acme/service"}, "--repo is not used by tea team repo add"},
|
||||||
{[]string{"time", "create", "1", "0s"}, "duration must be nonzero whole seconds, for example 1h25m"},
|
{[]string{"time", "create", "1", "0s"}, "duration must be nonzero whole seconds, for example 1h25m"},
|
||||||
{[]string{"time", "create", "1", "1ms"}, "duration must be nonzero whole seconds, for example 1h25m"},
|
{[]string{"time", "create", "1", "1ms"}, "duration must be nonzero whole seconds, for example 1h25m"},
|
||||||
{[]string{"time", "list", "--issue", "0"}, "--issue must be positive"},
|
{[]string{"time", "list", "--issue", "0"}, "--issue must be positive"},
|
||||||
@@ -287,7 +291,7 @@ func TestPromptedCreateSubmitsWithoutConfirmation(t *testing.T) {
|
|||||||
{[]string{"secret", "set", "TOKEN"}, nil, "private", "PUT /api/v1/repos/alice/project/actions/secrets/TOKEN"},
|
{[]string{"secret", "set", "TOKEN"}, nil, "private", "PUT /api/v1/repos/alice/project/actions/secrets/TOKEN"},
|
||||||
{[]string{"variable", "set", "MODE"}, map[string]string{"Variable value": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"},
|
{[]string{"variable", "set", "MODE"}, map[string]string{"Variable value": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"},
|
||||||
{[]string{"webhook", "create"}, map[string]string{"url": "https://hooks.test/path"}, "", "POST /api/v1/repos/alice/project/hooks"},
|
{[]string{"webhook", "create"}, map[string]string{"url": "https://hooks.test/path"}, "", "POST /api/v1/repos/alice/project/hooks"},
|
||||||
{[]string{"team", "create", "acme"}, map[string]string{"name": "Developers"}, "", "POST /api/v1/orgs/acme/teams"},
|
{[]string{"team", "create", "acme"}, map[string]string{"Team name": "Developers"}, "", "POST /api/v1/orgs/acme/teams"},
|
||||||
} {
|
} {
|
||||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||||
var writes []string
|
var writes []string
|
||||||
|
|||||||
+175
-23
@@ -8,6 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
@@ -26,6 +28,72 @@ type Team struct {
|
|||||||
Units map[string]string `json:"units"`
|
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 {
|
func teamFrom(team *gitea.Team) Team {
|
||||||
result := Team{
|
result := Team{
|
||||||
ID: team.ID, Name: team.Name, Description: team.Description, Permission: string(team.Permission),
|
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 }
|
type Teams struct{ Client *transport.Client }
|
||||||
|
|
||||||
// List lists teams in one organization.
|
// List lists teams in one organization.
|
||||||
func (s Teams) List(ctx context.Context, org string, limit, page int) ([]Team, error) {
|
func (s Teams) List(ctx context.Context, org string, limit, page int) (Page[Team], error) {
|
||||||
teams, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Team, *gitea.Response, error) {
|
teams, err := listPage(limit, page, func(limit, page int) ([]*gitea.Team, http.Header, error) {
|
||||||
return c.Organizations.ListOrgTeams(ctx, org, gitea.ListTeamsOptions{ListOptions: listOptions(limit, page)})
|
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 inOrganization(convertPage(teams, teamFrom), org), err
|
||||||
return nil, 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.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -79,17 +158,64 @@ func (s Teams) ByName(ctx context.Context, org string) (map[string]Team, error)
|
|||||||
return result, nil
|
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.
|
// View retrieves a team and its current permissions.
|
||||||
func (s Teams) View(ctx context.Context, id int64) (Team, error) {
|
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) {
|
team, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Team, *gitea.Response, error) {
|
||||||
return c.Organizations.GetTeam(ctx, id)
|
return c.Organizations.GetTeam(ctx, id)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Team{}, err
|
return Team{}, Classify(err, "team", strconv.FormatInt(id, 10), "")
|
||||||
}
|
}
|
||||||
return teamFrom(team), nil
|
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.
|
// Create creates an organization team with explicit initial permissions.
|
||||||
func (s Teams) Create(ctx context.Context, org string, opts TeamOptions) (Team, error) {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return Team{}, err
|
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
|
// 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.
|
// Members lists members of a team.
|
||||||
func (s Teams) Members(ctx context.Context, id int64, limit, page int) ([]OrganizationMember, error) {
|
func (s Teams) Members(ctx context.Context, id int64, limit, page int) (Page[OrganizationMember], error) {
|
||||||
users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) {
|
users, err := listPage(limit, page, func(limit, page int) ([]*gitea.User, http.Header, error) {
|
||||||
return c.Organizations.ListTeamMembers(ctx, id, gitea.ListTeamMembersOptions{ListOptions: listOptions(limit, page)})
|
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 convertPage(users, memberFrom), err
|
||||||
return nil, 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.
|
// SetMember adds or removes one team membership.
|
||||||
func (s Teams) SetMember(ctx context.Context, id int64, username string, add bool) error {
|
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 {
|
if add {
|
||||||
return c.Organizations.AddTeamMember(ctx, id, username)
|
return c.Organizations.AddTeamMember(ctx, id, username)
|
||||||
}
|
}
|
||||||
return c.Organizations.RemoveTeamMember(ctx, id, username)
|
return c.Organizations.RemoveTeamMember(ctx, id, username)
|
||||||
})
|
})
|
||||||
|
return Classify(err, "user", strconv.Quote(username), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repositories lists repositories accessible to a team.
|
// Repositories lists repositories accessible to a team.
|
||||||
func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) ([]Repository, error) {
|
func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) (Page[Repository], error) {
|
||||||
repos, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) {
|
repos, err := listPage(limit, page, func(limit, page int) ([]*gitea.Repository, http.Header, error) {
|
||||||
return c.Organizations.ListTeamRepositories(ctx, id, gitea.ListTeamRepositoriesOptions{ListOptions: listOptions(limit, page)})
|
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 convertPage(repos, repositoryFrom), err
|
||||||
return nil, 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.
|
// SetRepository adds or removes team access to a repository.
|
||||||
func (s Teams) SetRepository(ctx context.Context, id int64, repository string, add bool) error {
|
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 {
|
if add {
|
||||||
return c.Organizations.AddTeamRepository(ctx, id, owner, name)
|
return c.Organizations.AddTeamRepository(ctx, id, owner, name)
|
||||||
}
|
}
|
||||||
return c.Organizations.RemoveTeamRepository(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}
|
service := Teams{Client: client}
|
||||||
members, err := service.Members(context.Background(), 3, 5, 2)
|
members, err := service.Members(context.Background(), 3, 5, 2)
|
||||||
require.NoError(t, err)
|
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)
|
repos, err := service.Repositories(context.Background(), 3, 5, 1)
|
||||||
require.NoError(t, err)
|
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.SetMember(context.Background(), 3, "bob", false))
|
||||||
require.NoError(t, service.SetRepository(context.Background(), 3, "acme/project", true))
|
require.NoError(t, service.SetRepository(context.Background(), 3, "acme/project", true))
|
||||||
require.Equal(t, []string{
|
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}`))
|
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)
|
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