From 6c40906cb428f61bfc39919284d4d0254dffe556 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 30 Jul 2026 10:26:02 +0700 Subject: [PATCH] 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. --- cmd/cmd.go | 1 + cmd/teams.go | 40 +++++++++++++ cmd/teams/create.go | 126 +++++++++++++++++++++++++++++++++++++++++ cmd/teams/delete.go | 64 +++++++++++++++++++++ cmd/teams/edit.go | 128 ++++++++++++++++++++++++++++++++++++++++++ cmd/teams/list.go | 77 +++++++++++++++++++++++++ modules/print/team.go | 70 +++++++++++++++++++++++ 7 files changed, 506 insertions(+) create mode 100644 cmd/teams.go create mode 100644 cmd/teams/create.go create mode 100644 cmd/teams/delete.go create mode 100644 cmd/teams/edit.go create mode 100644 cmd/teams/list.go create mode 100644 modules/print/team.go diff --git a/cmd/cmd.go b/cmd/cmd.go index a2306d9c..801ed0a3 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -41,6 +41,7 @@ func App() *cli.Command { &CmdActions, &CmdWiki, &CmdWebhooks, + &CmdTeams, &CmdComments, &CmdOpen, diff --git a/cmd/teams.go b/cmd/teams.go new file mode 100644 index 00000000..a15c215c --- /dev/null +++ b/cmd/teams.go @@ -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]", + }, + }, +} diff --git a/cmd/teams/create.go b/cmd/teams/create.go new file mode 100644 index 00000000..6faffb4c --- /dev/null +++ b/cmd/teams/create.go @@ -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: "", + 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 +} diff --git a/cmd/teams/delete.go b/cmd/teams/delete.go new file mode 100644 index 00000000..4393d8f7 --- /dev/null +++ b/cmd/teams/delete.go @@ -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: "", + 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 +} diff --git a/cmd/teams/edit.go b/cmd/teams/edit.go new file mode 100644 index 00000000..4c6ee4c1 --- /dev/null +++ b/cmd/teams/edit.go @@ -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: "", + 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 +} diff --git a/cmd/teams/list.go b/cmd/teams/list.go new file mode 100644 index 00000000..33124784 --- /dev/null +++ b/cmd/teams/list.go @@ -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()) +} diff --git a/modules/print/team.go b/modules/print/team.go new file mode 100644 index 00000000..ff44a412 --- /dev/null +++ b/modules/print/team.go @@ -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" +}