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" +}