diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d65f7d8e..da9e8d08 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -562,17 +562,6 @@ const ( ssh-key list ssh-key view 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 delete time list @@ -620,9 +609,6 @@ const ( search secret ssh-key - team - team member - team repo time variable webhook diff --git a/internal/app/completion.go b/internal/app/completion.go index d0496405..58394a8b 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -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) { org, _, _ := strings.Cut(toComplete, "/") 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) + ")") }), err }) diff --git a/internal/app/teams.go b/internal/app/teams.go index 5ac4c58c..d5d453a9 100644 --- a/internal/app/teams.go +++ b/internal/app/teams.go @@ -4,8 +4,11 @@ package app import ( + "context" "errors" "fmt" + "maps" + "slices" "strconv" "strings" @@ -15,230 +18,706 @@ import ( "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 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 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 { - 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 - list := &cobra.Command{Use: "list ", Short: "List organization teams", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - target, err := a.resolve(cmd.Context(), false) - if err != nil { - return err - } - items, err := (services.Teams{Client: target.client}).List(cmd.Context(), args[0], limit, page) - if err != nil { - 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 + " ", Short: titleCase(verb) + " an organization team", Args: argsExact(1)} - switch verb { - case "create": - command.Use = "create " - 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")) - } - } + cmd := &cobra.Command{ + Use: "list ", Short: "List organization teams", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(), + Long: "List the teams of an organization with their access to its repositories and their sizes.", + Example: `$ tea team list acme +# print the team names +$ tea team list acme --jq '.[].name'`, + RunE: func(cmd *cobra.Command, args []string) error { target, err := a.resolve(cmd.Context(), false) if err != nil { return err } + org := args[0] service := services.Teams{Client: target.client} - var item services.Team - 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) - } + teams, err := service.List(cmd.Context(), org, limit, page) if err != nil { return err } - return writeItem(a, cmd, item, teamRow) - } - group.AddCommand(command) + sizes := map[int64]services.TeamSize{} + if a.options.json == "" { + 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 ", 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 []", 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 ", 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 ", 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 ", 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 ...", 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 ...", 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 } -// deleteTeam names the team in its confirmation and deletes an organization's owner team only -// with --force, after the user types its name. -func (a *application) deleteTeam(cmd *cobra.Command, service services.Teams, id int64, force bool) error { - team, err := service.View(cmd.Context(), id) - if err != nil { - return err +// teamRepository resolves a repository argument of team repo add and remove: a name inside the +// team's organization, or an OWNER/NAME that the organization owns. +func teamRepository(team services.Team, raw string) (string, error) { + owner, name, scoped := strings.Cut(strings.TrimSpace(raw), "/") + if !scoped { + owner, name = team.Organization, owner } - subject, answer := fmt.Sprintf("team %q (ID %d)", team.Name, team.ID), team.Name - if team.Organization != "" { - subject += " from " + team.Organization - answer = team.Organization + "/" + team.Name + if owner == "" || name == "" || strings.Contains(name, "/") { + return "", clierr.Wrap(clierr.Usage, fmt.Errorf("invalid repository %q: use NAME or %s/NAME", raw, team.Organization)) } - if team.Permission != "owner" { - err = a.confirm(cmd, "Delete "+subject+"?") - } 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 !strings.EqualFold(owner, team.Organization) { + 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)) } - if err != nil { - return err - } - return service.Delete(cmd.Context(), id) + return team.Organization + "/" + name, nil } -func (a *application) teamAccessCommand(repository bool) *cobra.Command { - kind := "member" - if repository { - kind = "repo" - } - group := &cobra.Command{Use: kind, Short: "Manage team " + kind + " access"} +func (a *application) teamRepoCommand() *cobra.Command { + 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 +its organization. Repositories are named alone, such as service, or as ORG/NAME. + +` + teamArguments} var limit, page int - list := &cobra.Command{Use: "list ", Short: "List team " + kind + "s", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - target, err := a.resolve(cmd.Context(), false) - if err != nil { - return err - } - service := services.Teams{Client: target.client} - if repository { - items, err := service.Repositories(cmd.Context(), id, limit, page) + list := &cobra.Command{ + Use: "list ", Short: "List team repositories", Args: argsExact(1), ValidArgsFunction: a.completeTeams(), + Example: "$ tea team repo list acme/developers", + Annotations: map[string]string{"tea.arguments": teamArguments}, + RunE: func(cmd *cobra.Command, args []string) error { + _, service, team, err := a.team(cmd, args[0]) if err != nil { return err } - return writeList(a, cmd, items, func(item services.Repository) []terminal.Cell { return terminal.Row(item.FullName) }) - } - items, err := service.Members(cmd.Context(), id, limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, func(item services.OrganizationMember) []terminal.Cell { return terminal.Row(item.Username) }) - }} + repos, err := service.Repositories(cmd.Context(), team.ID, limit, page) + if err != nil { + return err + } + return writeTable(a, cmd, repos.Items, listSpec[services.Repository]{ + Columns: []terminal.Column{{Header: "Name"}, {Header: "Description", Flex: true}}, + 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) - group.AddCommand(list) - for _, verb := range []string{"add", "remove"} { - use := verb + " " - count := 2 - if repository { - use = verb + " --repo owner/repo" - count = 1 - } - 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 { - id, err := number(args[0]) + group.AddCommand(list, a.teamRepoAccessCommand(true), a.teamRepoAccessCommand(false)) + return group +} + +// teamRepoAccessCommand grants or revokes team access to repositories. +func (a *application) teamRepoAccessCommand(grant bool) *cobra.Command { + 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"} + if grant { + 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 + " ...", 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 ", cmd.CommandPath(), cmd.CommandPath())) + } + _, service, team, err := a.team(cmd, args[0]) if err != nil { return err } - target, err := a.resolve(cmd.Context(), repository) - if err != nil { - return err + var repos []string + for _, raw := range args[1:] { + 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 err := a.confirm(cmd, "Remove team "+args[0]+" "+kind+" access"); err != nil { + if len(repos) == 0 { + 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 } } - service := services.Teams{Client: target.client} - if repository { - return service.SetRepository(cmd.Context(), id, target.Repo, verb == "add") + for _, repo := range repos { + if err := service.SetRepository(cmd.Context(), team.ID, repo, grant); err != nil { + 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) } diff --git a/internal/app/teams_test.go b/internal/app/teams_test.go index d3648f10..65b07095 100644 --- a/internal/app/teams_test.go +++ b/internal/app/teams_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "testing" "gitea.dev/tea/internal/clierr" @@ -78,3 +79,133 @@ func TestTeamEditMergesUnitsIntoTheCurrentPolicy(t *testing.T) { require.ErrorContains(t, err, "--unit-replace requires --unit") 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())) +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 6cd497c8..edd1a1ac 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -205,11 +205,15 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`}, {[]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", "--unit", "repo.unknown=write"}, "--unit requires repo.* keys with none, read, or write access"}, - {[]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.unknown=write"}, `unknown unit "repo.unknown"; use one of repo.code`}, + {[]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", "--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", "1ms"}, "duration must be nonzero whole seconds, for example 1h25m"}, {[]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{"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{"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) { var writes []string diff --git a/internal/services/teams.go b/internal/services/teams.go index 9623aaa7..ee286c38 100644 --- a/internal/services/teams.go +++ b/internal/services/teams.go @@ -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, "") } diff --git a/internal/services/teams_test.go b/internal/services/teams_test.go index 7ea6f9c6..000dc29a 100644 --- a/internal/services/teams_test.go +++ b/internal/services/teams_test.go @@ -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) +}