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:
bircni committed 2026-10-11 23:42:49 +02:00
1 parent c18dae6685
commit 769e93f5fd
7 files changed
+1035 -236

No files matched your search

+671 -192
View File
@@ -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 <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 {
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 <org>", 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 + " <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"))
}
}
cmd := &cobra.Command{
Use: "list <org>", 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 <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
}
// 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 <id>", 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 <team>", 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 + " <id> <username>"
count := 2
if repository {
use = verb + " <id> --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 + " <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 {
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)
}