feat(teams): add teams list, create, delete, edit subcommands

Provide basic CRUD operations for organization teams via 'tea teams'.
Defaults to listing teams for the configured organization when invoked
with no subcommand. Subsequent PRs will add 'tea teams members' and
'tea teams repos' as nested subcommands.
This commit is contained in:
Ross Golder 2026-07-30 10:26:02 +07:00
parent 58931b5d17
commit 6c40906cb4
No known key found for this signature in database
GPG key ID: 253A7E508D2D59CD
7 changed files with 506 additions and 0 deletions

View file

@ -41,6 +41,7 @@ func App() *cli.Command {
&CmdActions,
&CmdWiki,
&CmdWebhooks,
&CmdTeams,
&CmdComments,
&CmdOpen,

40
cmd/teams.go Normal file
View file

@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"gitea.dev/tea/cmd/teams"
"github.com/urfave/cli/v3"
)
// CmdTeams represents the teams command for managing organization teams.
var CmdTeams = cli.Command{
Name: "teams",
Aliases: []string{"team"},
Category: catEntities,
Usage: "Manage organization teams",
Description: "Manage organization teams",
Action: teams.RunTeamsList,
Commands: []*cli.Command{
&teams.CmdTeamsList,
&teams.CmdTeamsCreate,
&teams.CmdTeamsDelete,
&teams.CmdTeamsEdit,
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "org",
Usage: "organization to operate on",
},
&cli.StringFlag{
Name: "login",
Usage: "gitea login instance to use",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "output format [table, csv, simple, tsv, yaml, json]",
},
},
}

126
cmd/teams/create.go Normal file
View file

@ -0,0 +1,126 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package teams
import (
stdctx "context"
"errors"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdTeamsCreate represents a sub command of teams to create a new team.
var CmdTeamsCreate = cli.Command{
Name: "create",
Aliases: []string{"add", "c"},
Usage: "Create a new team",
Description: "Create a new team in the specified organization",
ArgsUsage: "<team-name>",
Action: runTeamsCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "org",
Usage: "organization to create the team in",
Required: true,
},
&cli.StringFlag{
Name: "description",
Usage: "team description",
},
&cli.StringFlag{
Name: "permission",
Value: "read",
Usage: "team permission level: read, write, admin",
},
&cli.BoolFlag{
Name: "can-create-repo",
Usage: "allow team members to create repositories",
},
&cli.BoolFlag{
Name: "all-repos",
Usage: "grant access to all organization repositories",
},
&cli.StringSliceFlag{
Name: "units",
Usage: "repository units the team can access (code, issues, pulls, wiki, releases, projects, packages, actions)",
},
}, flags.AllDefaultFlags...),
}
func runTeamsCreate(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return errors.New("team name is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := c.Login.Client()
opt := gitea.CreateTeamOption{
Name: cmd.Args().First(),
Description: cmd.String("description"),
Permission: gitea.AccessMode(cmd.String("permission")),
CanCreateOrgRepo: cmd.Bool("can-create-repo"),
IncludesAllRepositories: cmd.Bool("all-repos"),
Units: ParseRepoUnits(cmd.StringSlice("units")),
}
if err := opt.Validate(); err != nil {
return fmt.Errorf("invalid team options: %w", err)
}
team, _, err := client.Organizations.CreateTeam(ctx, cmd.String("org"), opt)
if err != nil {
return err
}
fmt.Printf("Team '%s' created in organization '%s' (ID: %d)\n", team.Name, cmd.String("org"), team.ID)
print.TeamDetails(team, c.Output)
return nil
}
// ParseRepoUnits maps user-supplied unit names to RepoUnitType values.
// An empty input yields the Gitea default unit set (code, issues, pulls, releases).
// Exported for reuse by edit.go and the teams test suite.
func ParseRepoUnits(units []string) []gitea.RepoUnitType {
if len(units) == 0 {
return []gitea.RepoUnitType{
gitea.RepoUnitCode,
gitea.RepoUnitIssues,
gitea.RepoUnitPulls,
gitea.RepoUnitReleases,
}
}
result := make([]gitea.RepoUnitType, 0, len(units))
for _, unit := range units {
switch strings.ToLower(unit) {
case "code":
result = append(result, gitea.RepoUnitCode)
case "issues":
result = append(result, gitea.RepoUnitIssues)
case "pulls":
result = append(result, gitea.RepoUnitPulls)
case "wiki":
result = append(result, gitea.RepoUnitWiki)
case "releases":
result = append(result, gitea.RepoUnitReleases)
case "projects":
result = append(result, gitea.RepoUnitProjects)
case "packages":
result = append(result, gitea.RepoUnitPackages)
case "actions":
result = append(result, gitea.RepoUnitActions)
}
}
return result
}

64
cmd/teams/delete.go Normal file
View file

@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package teams
import (
stdctx "context"
"fmt"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdTeamsDelete represents a sub command of teams to delete a team by ID.
var CmdTeamsDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm", "remove"},
Usage: "Delete a team",
Description: "Delete a team by ID",
ArgsUsage: "<team-id>",
Action: runTeamsDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
func runTeamsDelete(ctx stdctx.Context, cmd *cli.Command) error {
teamID, err := RequireTeamID(cmd)
if err != nil {
return err
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := c.Login.Client()
team, _, err := client.Organizations.GetTeam(ctx, teamID)
if err != nil {
return err
}
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to delete team '%s' (ID: %d)? [y/N] ", team.Name, team.ID)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
if _, err = client.Organizations.DeleteTeam(ctx, teamID); err != nil {
return err
}
fmt.Printf("Team '%s' (ID: %d) deleted successfully\n", team.Name, team.ID)
return nil
}

128
cmd/teams/edit.go Normal file
View file

@ -0,0 +1,128 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package teams
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdTeamsEdit represents a sub command of teams to edit an existing team's settings.
var CmdTeamsEdit = cli.Command{
Name: "edit",
Aliases: []string{"update"},
Usage: "Edit a team",
Description: "Edit an existing team's settings",
ArgsUsage: "<team-id>",
Action: runTeamsEdit,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "new team name",
},
&cli.StringFlag{
Name: "description",
Usage: "new team description",
},
&cli.StringFlag{
Name: "permission",
Usage: "team permission level: read, write, admin",
},
&cli.BoolFlag{
Name: "can-create-repo",
Usage: "allow team members to create repositories",
},
&cli.BoolFlag{
Name: "no-create-repo",
Usage: "disallow team members to create repositories",
},
&cli.BoolFlag{
Name: "all-repos",
Usage: "grant access to all organization repositories",
},
&cli.BoolFlag{
Name: "no-all-repos",
Usage: "remove access to all organization repositories",
},
&cli.StringSliceFlag{
Name: "units",
Usage: "repository units the team can access (code, issues, pulls, wiki, releases, projects, packages, actions)",
},
}, flags.AllDefaultFlags...),
}
func runTeamsEdit(ctx stdctx.Context, cmd *cli.Command) error {
teamID, err := RequireTeamID(cmd)
if err != nil {
return err
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := c.Login.Client()
// Get the current team to use as the baseline for unspecified fields.
team, _, err := client.Organizations.GetTeam(ctx, teamID)
if err != nil {
return err
}
opt := gitea.EditTeamOption{
Name: team.Name,
Permission: team.Permission,
Units: team.Units,
Description: &team.Description,
CanCreateOrgRepo: &team.CanCreateOrgRepo,
IncludesAllRepositories: &team.IncludesAllRepositories,
}
if name := cmd.String("name"); name != "" {
opt.Name = name
}
if cmd.IsSet("description") {
desc := cmd.String("description")
opt.Description = &desc
}
if cmd.IsSet("permission") {
opt.Permission = gitea.AccessMode(cmd.String("permission"))
}
if cmd.Bool("can-create-repo") {
v := true
opt.CanCreateOrgRepo = &v
} else if cmd.Bool("no-create-repo") {
v := false
opt.CanCreateOrgRepo = &v
}
if cmd.Bool("all-repos") {
v := true
opt.IncludesAllRepositories = &v
} else if cmd.Bool("no-all-repos") {
v := false
opt.IncludesAllRepositories = &v
}
if cmd.IsSet("units") {
opt.Units = ParseRepoUnits(cmd.StringSlice("units"))
}
if _, err = client.Organizations.EditTeam(ctx, teamID, opt); err != nil {
return err
}
updated, _, err := client.Organizations.GetTeam(ctx, teamID)
if err != nil {
return err
}
fmt.Printf("Team '%s' (ID: %d) updated successfully\n", updated.Name, updated.ID)
print.TeamDetails(updated, c.Output)
return nil
}

77
cmd/teams/list.go Normal file
View file

@ -0,0 +1,77 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package teams
import (
stdctx "context"
"errors"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdTeamsList represents a sub command of teams to list teams in an organization.
var CmdTeamsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List teams in an organization",
Description: "List all teams in the specified organization",
Action: RunTeamsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
&cli.StringFlag{
Name: "search",
Usage: "filter teams by name (substring match)",
},
}, flags.AllDefaultFlags...),
}
// RunTeamsList lists teams in an organization.
func RunTeamsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{Org: true}); err != nil {
return err
}
client := c.Login.Client()
if search := cmd.String("search"); search != "" {
teams, _, err := client.Organizations.SearchOrgTeams(ctx, c.Org, &gitea.SearchTeamsOptions{
ListOptions: flags.GetListOptions(cmd),
Query: search,
IncludeDescription: true,
})
if err != nil {
return err
}
print.TeamsList(teams, c.Output, c.Org)
return nil
}
teams, _, err := client.Organizations.ListOrgTeams(ctx, c.Org, gitea.ListTeamsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
print.TeamsList(teams, c.Output, c.Org)
return nil
}
// RequireTeamID returns an error if no team ID was provided, or the
// parsed team ID. Exported so sub-packages (members, repos) can share it.
func RequireTeamID(cmd *cli.Command) (int64, error) {
if cmd.Args().Len() == 0 {
return 0, errors.New("team ID is required")
}
return utils.ArgToIndex(cmd.Args().First())
}

70
modules/print/team.go Normal file
View file

@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strconv"
"gitea.dev/sdk"
)
// TeamsList prints a list of teams in an organization.
func TeamsList(teams []*gitea.Team, output, orgName string) {
if len(teams) == 0 {
fmt.Printf("No teams found in organization '%s'\n", orgName)
return
}
t := tableWithHeader(
"ID",
"Name",
"Description",
"Permission",
"Can Create Repo",
"All Repos",
)
for _, team := range teams {
t.addRow(
strconv.FormatInt(team.ID, 10),
team.Name,
team.Description,
string(team.Permission),
yesNo(team.CanCreateOrgRepo),
yesNo(team.IncludesAllRepositories),
)
}
fmt.Printf("Teams in organization '%s':\n", orgName)
t.print(output)
}
// TeamDetails prints detailed information about a team.
func TeamDetails(team *gitea.Team, output string) {
fmt.Printf("# Team: %s (ID: %d)\n\n", team.Name, team.ID)
fmt.Printf("- **Description**: %s\n", team.Description)
fmt.Printf("- **Permission**: %s\n", team.Permission)
fmt.Printf("- **Can Create Org Repo**: %t\n", team.CanCreateOrgRepo)
fmt.Printf("- **Includes All Repositories**: %t\n", team.IncludesAllRepositories)
if len(team.Units) > 0 {
fmt.Printf("- **Repository Units**:\n")
for _, unit := range team.Units {
fmt.Printf(" - %s\n", unit)
}
}
if team.Organization != nil {
fmt.Printf("- **Organization**: %s\n", team.Organization.UserName)
}
}
// yesNo maps a bool to a human-readable "yes"/"no" string for table output.
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}