// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "context" "errors" "fmt" "strconv" "strings" "time" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) var visibilities = []string{"public", "limited", "private"} var visibilityColors = map[string]string{"public": "green", "limited": "yellow", "private": "red"} // timeField shows an API timestamp relative to now on a TTY, and as RFC3339 for scripts. func timeField(label, raw string, d terminal.Display) terminal.Field { t := terminal.ParseTime(raw) if t.IsZero() { return terminal.Field{Label: label} } if !d.TTY { return terminal.Field{Label: label, Value: terminal.Timestamp(t)} } now := d.Now if now.IsZero() { now = time.Now() } return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)} } // pipedTime normalizes a server timestamp to RFC 3339 UTC like the times of piped tables, and keeps // a value that is not a timestamp as it is. func pipedTime(raw string) string { if t := terminal.ParseTime(raw); !t.IsZero() { return terminal.Timestamp(t) } return raw } func (a *application) organizationCommand() *cobra.Command { group := &cobra.Command{Use: "org", Short: "Manage organizations", Long: `Work with Gitea organizations: list the ones you belong to, view, create, edit and delete them, and manage their members. Teams grant members access to an organization's repositories; see tea team.`} group.AddCommand(a.organizationListCommand(), a.organizationViewCommand(), a.organizationCreateCommand(), a.organizationEditCommand(), a.organizationDeleteCommand(), a.organizationMemberCommand()) return group } func (a *application) organizationListCommand() *cobra.Command { var all bool var username string var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List organizations", Args: argsExact(0), Long: "List the organizations you belong to, those of another user, or every organization you can see.", Example: `# list your organizations $ tea org list # list the organizations of another user $ tea org list --username bob # list every organization on the instance $ tea org list --all`, RunE: func(cmd *cobra.Command, _ []string) error { if all && username != "" { return clierr.Wrap(clierr.Usage, errors.New("--all and --username are mutually exclusive")) } target, err := a.resolve(cmd.Context(), false) if err != nil { return err } orgs, err := (services.Organizations{Client: target.client}).List(cmd.Context(), !all && username == "", username, limit, page) if err != nil { return err } spec := listSpec[services.Organization]{ Columns: []terminal.Column{{Header: "Name"}, {Header: "Full name", Flex: true}, {Header: "Visibility"}}, Context: "organizations you belong to", Empty: "you don't belong to any organization", Row: func(org services.Organization) []terminal.Cell { return []terminal.Cell{{Text: org.Name, Link: webURL(target, org.Name), Bold: true}, {Text: org.FullName}, {Text: org.Visibility, Color: visibilityColors[org.Visibility]}} }, } switch { case all: spec.Context, spec.Empty = "organizations on "+target.Host, "no organizations on "+target.Host case username != "": spec.Context, spec.Empty = "organizations of "+username, "no organizations of "+username } return writeTable(a, cmd, orgs.Items, spec, servicePage(orgs, page)) }, } cmd.Flags().BoolVar(&all, "all", false, "List every organization you can see instead of your memberships") cmd.Flags().StringVar(&username, "username", "", "List the organizations of this user") _ = cmd.RegisterFlagCompletionFunc("username", a.completeInstanceUsers()) pageFlags(cmd, &limit, &page) return cmd } func (a *application) organizationViewCommand() *cobra.Command { return &cobra.Command{ Use: "view ", Short: "View an organization", Args: argsExact(1), Example: `$ tea org view acme # print the description only $ tea org view acme --jq .description`, ValidArgsFunction: a.completeOrgs(), RunE: func(cmd *cobra.Command, args []string) error { target, err := a.resolve(cmd.Context(), false) if err != nil { return err } service := services.Organizations{Client: target.client} org, err := service.View(cmd.Context(), args[0]) if err != nil { return err } var size services.OrganizationSize if a.options.json == "" { if size, err = service.Size(cmd.Context(), org.Name); err != nil { return err } } return writeView(a, cmd, org, func(org services.Organization) terminal.View { return organizationView(org, size, webURL(target, org.Name)) }) }, } } func organizationView(org services.Organization, size services.OrganizationSize, address string) terminal.View { view := terminal.View{ Title: org.Name, Badges: []terminal.Cell{{Text: org.Visibility, Color: visibilityColors[org.Visibility]}}, Body: org.Description, Kind: "organization", URL: address, } if org.FullName != "" && org.FullName != org.Name { view.Title, view.ID = org.FullName, "("+org.Name+")" } var counts []string for _, count := range []struct { label string n *int singular, plural string }{{"Members", size.Members, "member", "members"}, {"Teams", size.Teams, "team", "teams"}, {"Repositories", size.Repositories, "repository", "repositories"}} { if count.n != nil { noun := count.plural if *count.n == 1 { noun = count.singular } counts = append(counts, strconv.Itoa(*count.n)+" "+noun) view.Fields = append(view.Fields, terminal.Field{Label: count.label, Value: strconv.Itoa(*count.n), PipedOnly: true}) } } view.Meta = strings.Join(counts, " • ") view.Fields = append([]terminal.Field{ {Label: "Name", Value: org.Name, PipedOnly: true}, {Label: "Visibility", Value: org.Visibility, PipedOnly: true}, {Label: "Website", Value: org.Website}, {Label: "Location", Value: org.Location}, {Label: "Repo admins manage team access", Value: yesNo(org.RepoAdminChangeTeamAccess)}, }, view.Fields...) return view } // organizationFlags registers the fields that create and edit set and returns the options for the // supplied ones. Edit flags have no defaults, so that only what is passed changes. func organizationFlags(cmd *cobra.Command, create bool) func() services.OrganizationOptions { var fullName, description, website, location, visibility string var adminAccess bool cmd.Flags().StringVar(&fullName, "full-name", "", "Display name") cmd.Flags().StringVarP(&description, "description", "d", "", "Organization description") cmd.Flags().StringVar(&website, "website", "", "Website URL") cmd.Flags().StringVar(&location, "location", "", "Location") initial := "" if create { initial = "public" } enumFlag(cmd, &visibility, "visibility", "", initial, "Who can see the organization", visibilities...) cmd.Flags().BoolVar(&adminAccess, "repo-admins-can-change-team-access", false, "Allow repository administrators to change team access (use =false to revoke)") return func() services.OrganizationOptions { opts := services.OrganizationOptions{} for _, field := range []struct { flag string value *string option **string }{{"full-name", &fullName, &opts.FullName}, {"description", &description, &opts.Description}, {"website", &website, &opts.Website}, {"location", &location, &opts.Location}, {"visibility", &visibility, &opts.Visibility}} { if cmd.Flags().Changed(field.flag) { *field.option = field.value } } if cmd.Flags().Changed("repo-admins-can-change-team-access") { opts.RepoAdminChangeTeamAccess = &adminAccess } if create { opts.Visibility, opts.RepoAdminChangeTeamAccess = &visibility, &adminAccess } return opts } } func organizationName(raw string) (string, error) { if name := strings.TrimSpace(raw); name != "" { return name, nil } return "", clierr.Wrap(clierr.Usage, errors.New("organization name cannot be empty")) } func (a *application) organizationCreateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: "Create an organization", Args: argsExact(1), Example: `$ tea org create acme --full-name "ACME Corp" # create a private organization $ tea org create acme-internal --visibility private`, } options := organizationFlags(cmd, true) cmd.RunE = func(cmd *cobra.Command, args []string) error { name, err := organizationName(args[0]) if err != nil { return err } opts := options() opts.Name = &name target, err := a.resolve(cmd.Context(), false) if err != nil { return err } org, err := (services.Organizations{Client: target.client}).Create(cmd.Context(), opts) if err != nil { return err } a.done(cmd, "Created organization %s", org.Name) return a.created(cmd, org, webURL(target, org.Name)) } return cmd } func (a *application) organizationEditCommand() *cobra.Command { cmd := &cobra.Command{ Use: "edit ", Short: "Edit an organization", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(), Example: `$ tea org edit acme --description "Tools for everyone" --website https://acme.example $ tea org edit acme --visibility private`, } options := organizationFlags(cmd, false) cmd.RunE = func(cmd *cobra.Command, args []string) error { name, err := organizationName(args[0]) if err != nil { return err } opts := options() if opts == (services.OrganizationOptions{}) { return clierr.Wrap(clierr.Usage, errors.New("supply organization fields to edit")) } target, err := a.resolve(cmd.Context(), false) if err != nil { return err } org, err := (services.Organizations{Client: target.client}).Edit(cmd.Context(), name, opts) if err != nil { return err } a.done(cmd, "Edited organization %s", org.Name) return a.jsonResult(cmd, org) } return cmd } func (a *application) organizationDeleteCommand() *cobra.Command { return &cobra.Command{ Use: "delete ", Short: "Delete an organization", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(), Long: "Delete an organization with its teams. Gitea refuses while the organization owns repositories.", Example: `$ tea org delete acme-old $ tea org delete acme-old --yes`, Annotations: map[string]string{"tea.text": "organization deletion does not return a resource", "tea.confirm": "true"}, RunE: func(cmd *cobra.Command, args []string) error { name, err := organizationName(args[0]) if err != nil { return err } target, err := a.resolve(cmd.Context(), false) if err != nil { return err } service := services.Organizations{Client: target.client} org, err := service.View(cmd.Context(), name) if err != nil { return err } repos, err := service.RepositoryCount(cmd.Context(), org.Name) if err != nil { return err } if repos != nil && *repos > 0 { return &services.ConflictError{Reason: services.ReasonOwnsRepos, Kind: "organization", Key: org.Name, Hint: "transfer or delete them first (see tea repo list " + org.Name + ")"} } if err := a.confirmAction(cmd, terminal.Confirmation{ Prompt: fmt.Sprintf("Delete organization %s and its teams? This cannot be undone.", org.Name), Typed: org.Name, NonTTY: "delete organization " + org.Name, }); err != nil { return err } if err := service.Delete(cmd.Context(), org.Name); err != nil { return err } a.done(cmd, "Deleted organization %s", org.Name) return nil }, } } func (a *application) organizationMemberCommand() *cobra.Command { group := &cobra.Command{Use: "member", Short: "Manage organization membership", Long: `List the members of an organization, remove them, and choose whether a membership is public. Gitea adds members through teams: add someone to an organization with tea team member add ORG/TEAM USER.`} var public bool var limit, page int list := &cobra.Command{ Use: "list ", Short: "List organization members", Args: argsExact(1), ValidArgsFunction: a.completeOrgs(), Long: "List the members of an organization and whether each membership is public or concealed.", Example: `$ tea org member list acme # list only the members everyone can see $ tea org member list acme --public`, RunE: func(cmd *cobra.Command, args []string) error { target, err := a.resolve(cmd.Context(), false) if err != nil { return err } org := args[0] members, err := (services.Organizations{Client: target.client}).Members(cmd.Context(), org, services.MemberListOptions{Public: public, Visibility: true, Limit: limit, Page: page}) if err != nil { return err } what := "members" if public { what = "public members" } return writeTable(a, cmd, members.Items, listSpec[services.OrganizationMember]{ Columns: []terminal.Column{{Header: "Username"}, {Header: "Name", Flex: true}, {Header: "Visibility"}}, Row: func(member services.OrganizationMember) []terminal.Cell { visibility := terminal.Cell{Text: member.Visibility, Muted: member.Visibility == services.MembershipConcealed} return []terminal.Cell{{Text: member.Username, Link: webURL(target, member.Username), Bold: true}, {Text: member.FullName}, visibility} }, Context: what + " of " + org, Empty: "no " + what + " in " + org, }, servicePage(members, page)) }, } list.Flags().BoolVar(&public, "public", false, "List only public memberships") pageFlags(list, &limit, &page) group.AddCommand(list, a.organizationVisibilityCommand(true), a.organizationVisibilityCommand(false), a.organizationMemberRemoveCommand()) return group } // completeOrganizationMembers completes an organization, then the members of that organization. func (a *application) completeOrganizationMembers() cobra.CompletionFunc { orgs := a.completeOrgs() return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { if len(args) == 0 { return orgs(cmd, args, toComplete) } return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { members, err := (services.Organizations{Client: t.client}).Members(ctx, args[0], services.MemberListOptions{Limit: completionLimit, Page: 1}) return describe(members.Items, func(member services.OrganizationMember) (string, string) { return member.Username, member.FullName }), err })(cmd, args, toComplete) } } func (a *application) organizationVisibilityCommand(public bool) *cobra.Command { verb, alias, short, state := "conceal", "private", "Hide a membership", "concealed" if public { verb, alias, short, state = "publicize", "public", "Make a membership public", "public" } return &cobra.Command{ Use: verb + " ", Aliases: []string{alias}, Short: short, Args: argsExact(2), Long: fmt.Sprintf("Make the membership of a user %s. A public membership shows on the organization's profile.", state), Example: fmt.Sprintf("$ tea org member %s acme bob", verb), Annotations: textOutput("membership changes do not return a resource"), ValidArgsFunction: a.completeOrganizationMembers(), RunE: func(cmd *cobra.Command, args []string) error { org, username := args[0], args[1] target, err := a.resolve(cmd.Context(), false) if err != nil { return err } service := services.Organizations{Client: target.client} member, err := service.IsMember(cmd.Context(), org, username) if err != nil { return err } if !member { return fmt.Errorf("%s is not a member of %s", username, org) } current, err := service.IsPublicMember(cmd.Context(), org, username) if err != nil { return err } if current == public { a.warn(cmd, "%s's membership of %s is already %s", username, org, state) return nil } if err := service.SetMemberPublic(cmd.Context(), org, username, public); err != nil { return err } if public { a.done(cmd, "Made %s's membership of %s public", username, org) } else { a.done(cmd, "Concealed %s's membership of %s", username, org) } return nil }, } } func (a *application) organizationMemberRemoveCommand() *cobra.Command { return &cobra.Command{ Use: "remove ", Short: "Remove a member from an organization", Args: argsExact(2), Long: "Remove a user from an organization and from all of its teams.", Example: "$ tea org member remove acme bob", Annotations: map[string]string{"tea.text": "membership changes do not return a resource", "tea.confirm": "true"}, ValidArgsFunction: a.completeOrganizationMembers(), RunE: func(cmd *cobra.Command, args []string) error { org, username := args[0], args[1] target, err := a.resolve(cmd.Context(), false) if err != nil { return err } service := services.Organizations{Client: target.client} member, err := service.IsMember(cmd.Context(), org, username) if err != nil { return err } if !member { a.warn(cmd, "%s is not a member of %s", username, org) return nil } if err := a.confirmAction(cmd, terminal.Confirmation{ Prompt: fmt.Sprintf("Remove %s from organization %s and its teams?", username, org), NonTTY: fmt.Sprintf("remove %s from organization %s", username, org), }); err != nil { return err } if err := service.RemoveMember(cmd.Context(), org, username); err != nil { return err } a.done(cmd, "Removed %s from organization %s", username, org) return nil }, } } // completeInstanceUsers completes the usernames of the instance that contain the typed text. func (a *application) completeInstanceUsers() cobra.CompletionFunc { return a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) { users, err := (services.AdminUsers{Client: t.client}).Search(ctx, toComplete, completionLimit) return describe(users, func(user services.AdminUser) (string, string) { return user.Username, user.FullName }), err }) }