diff --git a/internal/app/app_test.go b/internal/app/app_test.go index da0d49c3..85726082 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -488,15 +488,6 @@ const ( notification unpin notification view notification watch - org create - org delete - org edit - org list - org member list - org member private - org member public - org member remove - org view pr attachment delete pr attachment download pr attachment edit @@ -625,8 +616,6 @@ const ( milestone milestone issue notification - org - org member pr pr attachment pr comment diff --git a/internal/app/completion.go b/internal/app/completion.go index 09049780..d0496405 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -445,8 +445,8 @@ func (a *application) completeReleases() cobra.CompletionFunc { // completeOrgs completes the organizations of the account. func (a *application) completeOrgs() cobra.CompletionFunc { return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { - items, _, err := services.Organizations{Client: t.client}.List(ctx, true, "", completionLimit, 1) - return describe(items, func(item services.Organization) (string, string) { return item.Name, item.FullName }), err + items, err := services.Organizations{Client: t.client}.List(ctx, true, "", completionLimit, 1) + return describe(items.Items, func(item services.Organization) (string, string) { return item.Name, item.FullName }), err }) } diff --git a/internal/app/organizations.go b/internal/app/organizations.go index 02c4b364..dc4b0325 100644 --- a/internal/app/organizations.go +++ b/internal/app/organizations.go @@ -4,7 +4,11 @@ package app import ( + "context" "errors" + "fmt" + "net/url" + "strconv" "strings" "gitea.dev/tea/internal/clierr" @@ -13,154 +17,455 @@ import ( "github.com/spf13/cobra" ) -func organizationRow(item services.Organization) []terminal.Cell { - return terminal.Row(item.Name, item.FullName, item.Visibility) +var visibilities = []string{"public", "limited", "private"} + +// visibilityColors color the visibility of organizations and accounts. +var visibilityColors = map[string]string{"public": "green", "limited": "yellow", "private": "red"} + +// servicePage places a page of a service listing for writeTable. +func servicePage[T any](page services.Page[T], number int) listPage { + return listPage{Number: number, More: page.More, Total: page.Total, Counted: page.Counted} +} + +// jsonResult writes the result of a mutation when --json asks for it; people get a done line instead. +func (a *application) jsonResult(cmd *cobra.Command, item any) error { + if a.options.json == "" { + return nil + } + return a.writeJSON(cmd, item) +} + +func yesNo(value bool) string { + if value { + return "yes" + } + return "no" +} + +// countOf words a count, such as "1 member" or "2 members". +func countOf(n int, singular, plural string) string { + if n == 1 { + return "1 " + singular + } + return strconv.Itoa(n) + " " + plural +} + +// webURL is the page of a path on the target's instance, such as an organization's profile. +func webURL(t target, path ...string) string { + escaped := make([]string, len(path)) + for i, segment := range path { + escaped[i] = url.PathEscape(segment) + } + return strings.TrimSuffix(t.Host, "/") + "/" + strings.Join(escaped, "/") } func (a *application) organizationCommand() *cobra.Command { - group := &cobra.Command{Use: "org", Short: "Manage organizations"} - var all bool - var username string - var limit, page int - list := &cobra.Command{Use: "list", Short: "List organizations on the selected instance", Args: argsExact(0), 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 - } - items, more, err := (services.Organizations{Client: target.client}).List(cmd.Context(), !all && username == "", username, limit, page) - if err != nil { - return err - } - if err := writeList(a, cmd, items, organizationRow); err != nil { - return err - } - return a.morePagesHint(cmd, more, page) - }} - list.Flags().BoolVar(&all, "all", false, "List public organizations instead of your memberships") - list.Flags().StringVar(&username, "username", "", "List organizations for a username") - pageFlags(list, &limit, &page) - group.AddCommand(list) - for _, verb := range []string{"view", "create", "edit", "delete"} { - var fullName, description, website, location, visibility string - var adminAccess bool - command := &cobra.Command{Use: verb + " ", Short: titleCase(verb) + " an organization", Args: argsExact(1)} - if verb == "delete" { - command.Annotations = textOutput("organization deletion does not return a resource") - } - if verb == "create" || verb == "edit" { - command.Flags().StringVar(&fullName, "full-name", "", "Display name") - command.Flags().StringVar(&description, "description", "", "Organization description") - command.Flags().StringVar(&website, "website", "", "Website URL") - command.Flags().StringVar(&location, "location", "", "Location") - command.Flags().StringVar(&visibility, "visibility", "public", "public, limited, or private") - command.Flags().BoolVar(&adminAccess, "repo-admins-can-change-team-access", false, "Allow repository administrators to change team access") - } - command.RunE = func(cmd *cobra.Command, args []string) error { - name := args[0] - if strings.TrimSpace(name) == "" { - return clierr.Wrap(clierr.Usage, errors.New("organization name cannot be empty")) - } - opts := services.OrganizationOptions{} - if verb == "create" { - opts.Name = &name - opts.Visibility = &visibility - opts.RepoAdminChangeTeamAccess = &adminAccess - } - if verb == "create" || verb == "edit" { - if err := oneOf("visibility", visibility, "public", "limited", "private"); err != nil { - return err - } - if cmd.Flags().Changed("full-name") { - opts.FullName = &fullName - } - if cmd.Flags().Changed("description") { - opts.Description = &description - } - if cmd.Flags().Changed("website") { - opts.Website = &website - } - if cmd.Flags().Changed("location") { - opts.Location = &location - } - if cmd.Flags().Changed("visibility") { - opts.Visibility = &visibility - } - if cmd.Flags().Changed("repo-admins-can-change-team-access") { - opts.RepoAdminChangeTeamAccess = &adminAccess - } - if verb == "edit" && 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 - } - service := services.Organizations{Client: target.client} - if verb == "delete" { - if err := a.confirm(cmd, "Delete organization "+name); err != nil { - return err - } - return service.Delete(cmd.Context(), name) - } - var item services.Organization - switch verb { - case "view": - item, err = service.View(cmd.Context(), name) - case "create": - item, err = service.Create(cmd.Context(), opts) - case "edit": - item, err = service.Edit(cmd.Context(), name, opts) - } - if err != nil { - return err - } - return writeItem(a, cmd, item, organizationRow) - } - group.AddCommand(command) - } - group.AddCommand(a.organizationMemberCommand()) + 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) organizationMemberCommand() *cobra.Command { - group := &cobra.Command{Use: "member", Short: "Manage organization membership"} - var public bool +func (a *application) organizationListCommand() *cobra.Command { + var all bool + var username string var limit, page int - list := &cobra.Command{Use: "list ", Short: "List organization members", 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.Organizations{Client: target.client}).Members(cmd.Context(), args[0], public, limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, func(item services.OrganizationMember) []terminal.Cell { - return terminal.Row(item.Username, item.FullName) - }) - }} - list.Flags().BoolVar(&public, "public", false, "List publicly visible memberships") - pageFlags(list, &limit, &page) - group.AddCommand(list) - for _, verb := range []string{"remove", "public", "private"} { - group.AddCommand(&cobra.Command{Use: verb + " ", Short: titleCase(verb) + " an organization membership", Annotations: textOutput("membership changes do not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error { + 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") + pageFlags(cmd, &limit, &page) + return cmd +} + +func (a *application) organizationViewCommand() *cobra.Command { + cmd := &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} - if verb == "remove" { - if err := a.confirm(cmd, "Remove "+args[1]+" from organization "+args[0]); err != nil { + 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 service.RemoveMember(cmd.Context(), args[0], args[1]) } - return service.SetMemberPublic(cmd.Context(), args[0], args[1], verb == "public") - }}) + return writeView(a, cmd, org, func(org services.Organization) terminal.View { + return organizationView(org, size, webURL(target, org.Name)) + }) + }, } + return cmd +} + +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 { + counts = append(counts, countOf(*count.n, count.singular, count.plural)) + 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 + } +} + +// organizationName rejects an empty organization argument. +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) + if err := a.printURL(cmd, webURL(target, org.Name)); err != nil { + return err + } + return a.jsonResult(cmd, org) + } + 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: transfer or delete them first. + +The deletion cannot be undone, so a terminal asks you to type the organization's name; --yes +skips the question.`, + 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 + } + size, err := service.Size(cmd.Context(), org.Name) + if err != nil { + return err + } + if size.Repositories != nil && *size.Repositories > 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) + } +} + +// organizationVisibilityCommand publicizes or conceals an organization membership. +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 + }, + } +} diff --git a/internal/app/organizations_test.go b/internal/app/organizations_test.go new file mode 100644 index 00000000..2cf4e0e4 --- /dev/null +++ b/internal/app/organizations_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "net/http" + "testing" + + "gitea.dev/tea/internal/clierr" + "github.com/stretchr/testify/require" +) + +func TestOrganizationMemberChangesCheckMembershipFirst(t *testing.T) { + var changes []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/orgs/acme/members/bob": + w.WriteHeader(http.StatusNotFound) + case "GET /api/v1/orgs/acme/members/carol", "GET /api/v1/orgs/acme/public_members/carol": + w.WriteHeader(http.StatusNoContent) + default: + changes = append(changes, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + deps, _, diagnostics := serverDeps(t, server.URL) + deps.Streams.OutTTY = true + require.NoError(t, Execute(context.Background(), deps, []string{"org", "member", "remove", "acme", "bob", "--yes"})) + require.Equal(t, "! bob is not a member of acme\n", withoutANSI(diagnostics.String())) + err := Execute(context.Background(), deps, []string{"org", "member", "publicize", "acme", "bob"}) + require.EqualError(t, err, "bob is not a member of acme") + diagnostics.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"org", "member", "public", "acme", "carol"})) + require.Equal(t, "! carol's membership of acme is already public\n", withoutANSI(diagnostics.String())) + require.Empty(t, changes) +} + +func TestOrganizationDeleteAsksToTypeTheName(t *testing.T) { + var deleted []string + repos := "0" + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/orgs/acme": + _, _ = w.Write([]byte(`{"id":4,"name":"acme"}`)) + case "GET /api/v1/orgs/acme/repos": + w.Header().Set("X-Total-Count", repos) + _, _ = w.Write([]byte(`[]`)) + case "GET /api/v1/orgs/acme/members", "GET /api/v1/orgs/acme/teams": + _, _ = w.Write([]byte(`[]`)) + case "DELETE /api/v1/orgs/acme": + deleted = append(deleted, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + const prompt = "Delete organization acme and its teams? This cannot be undone. Type acme to confirm:" + prompter := &scriptedPrompter{inputs: map[string]string{prompt: "yes"}, accept: true} + deps.Prompter = prompter + err := Execute(context.Background(), deps, []string{"org", "delete", "acme"}) + require.Equal(t, clierr.Canceled, clierr.ExitCode(err), "%v", err) + require.Equal(t, []string{prompt}, prompter.asked) + require.Empty(t, prompter.confirmations) + prompter.inputs[prompt] = "acme" + require.NoError(t, Execute(context.Background(), deps, []string{"org", "delete", "acme"})) + require.Equal(t, []string{"/api/v1/orgs/acme"}, deleted) + + repos = "2" + err = Execute(context.Background(), deps, []string{"org", "delete", "acme", "--yes"}) + require.EqualError(t, err, "organization acme still owns repositories; transfer or delete them first (see tea repo list acme)") + require.Len(t, deleted, 1) +} + +func TestOrganizationViewShowsDetailsAndCounts(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/orgs/acme": + _, _ = w.Write([]byte(`{"id":4,"name":"acme","full_name":"ACME Corp","description":"Tools","website":"https://acme.example","visibility":"public"}`)) + case "/api/v1/orgs/acme/members", "/api/v1/orgs/acme/teams", "/api/v1/orgs/acme/repos": + w.Header().Set("X-Total-Count", map[string]string{"members": "2", "teams": "3", "repos": "1"}[r.URL.Path[len("/api/v1/orgs/acme/"):]]) + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"org", "view", "acme"})) + require.Equal(t, "title:\tACME Corp\nname:\tacme\nvisibility:\tpublic\nwebsite:\thttps://acme.example\nlocation:\t\n"+ + "repo admins manage team access:\tno\nmembers:\t2\nteams:\t3\nrepositories:\t1\nurl:\t"+server.URL+"/acme\n--\nTools\n", out.String()) +} diff --git a/internal/services/organizations.go b/internal/services/organizations.go index 53d6d0c3..a01bc65e 100644 --- a/internal/services/organizations.go +++ b/internal/services/organizations.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/url" + "sync" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -32,7 +33,7 @@ func organization(ctx context.Context, client *transport.Client, owner string) ( org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { return c.Organizations.GetOrg(ctx, owner) }) - if failure, ok := errors.AsType[*transport.APIError](err); ok && failure.Status == http.StatusNotFound { + if statusNotFound(err) { return 0, false, nil } if err != nil { @@ -44,6 +45,12 @@ func organization(ctx context.Context, client *transport.Client, owner string) ( return org.ID, true, nil } +// statusNotFound reports whether err is a 404 response. +func statusNotFound(err error) bool { + failure, ok := errors.AsType[*transport.APIError](err) + return ok && failure.Status == http.StatusNotFound +} + // OrganizationOptions permits explicit empty and false preference updates. type OrganizationOptions struct { Name *string `json:"username,omitempty"` @@ -59,9 +66,8 @@ type OrganizationOptions struct { type Organizations struct{ Client *transport.Client } // List selects public organizations, your organizations, or a named user's organizations. -// It reports whether more organizations follow the selected page. -func (s Organizations) List(ctx context.Context, mine bool, username string, limit, page int) ([]Organization, bool, error) { - orgs, more, err := listWindow(limit, page, func(limit, page int) ([]*gitea.Organization, http.Header, error) { +func (s Organizations) List(ctx context.Context, mine bool, username string, limit, page int) (Page[Organization], error) { + orgs, err := listPage(limit, page, func(limit, page int) ([]*gitea.Organization, http.Header, error) { orgs, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Organization, *gitea.Response, error) { opts := gitea.ListOrgsOptions{ListOptions: listOptions(limit, page)} switch { @@ -74,10 +80,12 @@ func (s Organizations) List(ctx context.Context, mine bool, username string, lim }) return orgs, headerOf(resp), err }) - if err != nil { - return nil, false, err - } - return convertAll(orgs, organizationFrom), more, nil + return convertPage(orgs, organizationFrom), err +} + +// convertPage maps a page of SDK items onto tea results. +func convertPage[S, T any](page Page[*S], convert func(*S) T) Page[T] { + return Page[T]{Items: convertAll(page.Items, convert), More: page.More, Total: page.Total, Counted: page.Counted} } // View retrieves a named organization. @@ -91,6 +99,56 @@ func (s Organizations) View(ctx context.Context, name string) (Organization, err return organizationFrom(org), nil } +// OrganizationSize counts what an organization holds; a count the account cannot read is nil. +type OrganizationSize struct { + Members, Teams, Repositories *int +} + +// Size counts the members, teams and repositories of an organization. +func (s Organizations) Size(ctx context.Context, name string) (OrganizationSize, error) { + first := listOptions(1, 1) + counts := []func(context.Context, *gitea.Client) (*gitea.Response, error){ + func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + _, resp, err := c.Organizations.ListOrgMembership(ctx, name, gitea.ListOrgMembershipOption{ListOptions: first}) + return resp, err + }, + func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + _, resp, err := c.Organizations.ListOrgTeams(ctx, name, gitea.ListTeamsOptions{ListOptions: first}) + return resp, err + }, + func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + _, resp, err := c.Repositories.ListOrgRepos(ctx, name, gitea.ListOrgReposOptions{ListOptions: first}) + return resp, err + }, + } + totals := make([]*int, len(counts)) + for i, count := range counts { + total, err := listTotal(ctx, s.Client, count) + if ctx.Err() != nil { + return OrganizationSize{}, ctx.Err() + } + if err == nil { + totals[i] = total + } + } + return OrganizationSize{Members: totals[0], Teams: totals[1], Repositories: totals[2]}, nil +} + +// listTotal returns the collection size a list request reports, or nil when the server does not count it. +func listTotal(ctx context.Context, client *transport.Client, list func(context.Context, *gitea.Client) (*gitea.Response, error)) (*int, error) { + _, resp, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (struct{}, *gitea.Response, error) { + resp, err := list(ctx, c) + return struct{}{}, resp, err + }) + if err != nil { + return nil, err + } + if _, total, counted := pageHints(headerOf(resp)); counted { + return &total, nil + } + return nil, nil +} + // Create creates an organization owned by the selected account. func (s Organizations) Create(ctx context.Context, opts OrganizationOptions) (Organization, error) { org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { @@ -128,21 +186,107 @@ type OrganizationMember struct { ID int64 `json:"id"` Username string `json:"username"` FullName string `json:"full_name"` + // Visibility of an organization membership is public or concealed; team members have none. + Visibility string `json:"visibility,omitempty"` } -// Members lists visible members in an organization. -func (s Organizations) Members(ctx context.Context, name string, public bool, limit, page int) ([]OrganizationMember, error) { - users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { - opts := gitea.ListOrgMembershipOption{ListOptions: listOptions(limit, page)} - if public { - return c.Organizations.ListPublicOrgMembership(ctx, name, opts) - } - return c.Organizations.ListOrgMembership(ctx, name, opts) +// Membership visibilities. +const ( + MembershipPublic = "public" + MembershipConcealed = "concealed" +) + +// MemberListOptions selects a page of organization members. +type MemberListOptions struct { + // Public lists only public memberships, and Visibility reports the visibility of each one. + // Gitea lists concealed members as public to administrators, so both check each membership on + // its own, and Public drops the server's total, which counts concealed members too. + Public, Visibility bool + Limit, Page int +} + +// Members lists the members of an organization. +func (s Organizations) Members(ctx context.Context, name string, opts MemberListOptions) (Page[OrganizationMember], error) { + users, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.User, http.Header, error) { + users, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { + list := gitea.ListOrgMembershipOption{ListOptions: listOptions(limit, page)} + if opts.Public { + return c.Organizations.ListPublicOrgMembership(ctx, name, list) + } + return c.Organizations.ListOrgMembership(ctx, name, list) + }) + return users, headerOf(resp), err + }) + members := convertPage(users, memberFrom) + if err != nil || !(opts.Public || opts.Visibility) { + return members, err + } + public := make([]bool, len(members.Items)) + err = concurrently(len(members.Items), func(i int) (err error) { + public[i], err = s.IsPublicMember(ctx, name, members.Items[i].Username) + return err }) if err != nil { - return nil, err + return Page[OrganizationMember]{}, err } - return convertAll(users, memberFrom), nil + shown := members.Items[:0] + for i, member := range members.Items { + member.Visibility = MembershipConcealed + if public[i] { + member.Visibility = MembershipPublic + } + if !opts.Public || public[i] { + shown = append(shown, member) + } + } + members.Items = shown + members.Counted = members.Counted && !opts.Public + return members, nil +} + +// concurrentRequests bounds the requests that concurrently keeps in flight. +const concurrentRequests = 8 + +// concurrently runs request for each index 0..n-1 with a few requests in flight, and returns their +// errors joined. +func concurrently(n int, request func(i int) error) error { + errs := make([]error, n) + slots := make(chan struct{}, concurrentRequests) + var wg sync.WaitGroup + for i := range n { + slots <- struct{}{} + wg.Go(func() { + defer func() { <-slots }() + errs[i] = request(i) + }) + } + wg.Wait() + return errors.Join(errs...) +} + +// IsMember reports whether username belongs to the organization. +func (s Organizations) IsMember(ctx context.Context, name, username string) (bool, error) { + return s.check(ctx, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + _, resp, err := c.Organizations.CheckOrgMembership(ctx, name, username) + return resp, err + }) +} + +// IsPublicMember reports whether username's membership of the organization is public. +func (s Organizations) IsPublicMember(ctx context.Context, name, username string) (bool, error) { + return s.check(ctx, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + _, resp, err := c.Organizations.CheckPublicOrgMembership(ctx, name, username) + return resp, err + }) +} + +// check runs a membership check that answers no with a 404. +func (s Organizations) check(ctx context.Context, op func(context.Context, *gitea.Client) (*gitea.Response, error)) (bool, error) { + err := do(ctx, s.Client, op) + if statusNotFound(err) { + return false, nil + } + return err == nil, err } // RemoveMember removes an organization member and their organization access. diff --git a/internal/services/organizations_test.go b/internal/services/organizations_test.go index 273bb279..445b9618 100644 --- a/internal/services/organizations_test.go +++ b/internal/services/organizations_test.go @@ -82,10 +82,11 @@ func TestOrganizationListSelectsScopeAndPage(t *testing.T) { w.Header().Set("X-Total-Count", "7") _, _ = w.Write([]byte(`[{"id":1,"username":"legacy"},{"id":2,"name":"acme"}]`)) })) - items, more, err := (Organizations{Client: client}).List(context.Background(), tc.mine, tc.username, 2, 3) + items, err := (Organizations{Client: client}).List(context.Background(), tc.mine, tc.username, 2, 3) require.NoError(t, err) - require.True(t, more) - require.Equal(t, []string{"legacy", "acme"}, []string{items[0].Name, items[1].Name}) + require.True(t, items.More) + require.Equal(t, 7, items.Total) + require.Equal(t, []string{"legacy", "acme"}, []string{items.Items[0].Name, items.Items[1].Name}) }) } } @@ -111,20 +112,53 @@ func TestOrganizationMembershipRequests(t *testing.T) { var requests []string client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r.Method+" "+r.URL.Path) - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(`[{"id":2,"login":"bob","full_name":"Bob"}]`)) - return - } w.WriteHeader(http.StatusNoContent) })) service := Organizations{Client: client} - members, err := service.Members(context.Background(), "acme", true, 10, 1) + member, err := service.IsMember(context.Background(), "acme", "bob") require.NoError(t, err) - require.Equal(t, []OrganizationMember{{ID: 2, Username: "bob", FullName: "Bob"}}, members) + require.True(t, member) require.NoError(t, service.SetMemberPublic(context.Background(), "acme", "bob", true)) require.NoError(t, service.RemoveMember(context.Background(), "acme", "bob")) require.Equal(t, []string{ - "GET /api/v1/orgs/acme/public_members", "PUT /api/v1/orgs/acme/public_members/bob", - "DELETE /api/v1/orgs/acme/members/bob", + "GET /api/v1/orgs/acme/members/bob", "PUT /api/v1/orgs/acme/public_members/bob", "DELETE /api/v1/orgs/acme/members/bob", }, requests) } + +func TestOrganizationMembersCheckEachMembership(t *testing.T) { + // Gitea lists concealed members among the public ones to administrators; only the check of + // each membership tells them apart. + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/orgs/acme/members", "/api/v1/orgs/acme/public_members": + w.Header().Set("X-Total-Count", "2") + _, _ = w.Write([]byte(`[{"id":1,"login":"alice"},{"id":2,"login":"bob","full_name":"Bob"}]`)) + case "/api/v1/orgs/acme/public_members/alice": + w.WriteHeader(http.StatusNoContent) + case "/api/v1/orgs/acme/public_members/bob": + w.WriteHeader(http.StatusNotFound) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + alice, bob := OrganizationMember{ID: 1, Username: "alice"}, OrganizationMember{ID: 2, Username: "bob", FullName: "Bob"} + public, concealed := alice, bob + public.Visibility, concealed.Visibility = MembershipPublic, MembershipConcealed + for name, tc := range map[string]struct { + opts MemberListOptions + want []OrganizationMember + counted bool + }{ + "names": {MemberListOptions{}, []OrganizationMember{alice, bob}, true}, + "visibility": {MemberListOptions{Visibility: true}, []OrganizationMember{public, concealed}, true}, + "public": {MemberListOptions{Public: true}, []OrganizationMember{public}, false}, + } { + t.Run(name, func(t *testing.T) { + tc.opts.Limit, tc.opts.Page = 10, 1 + members, err := (Organizations{Client: client}).Members(context.Background(), "acme", tc.opts) + require.NoError(t, err) + require.Equal(t, tc.want, members.Items) + require.Equal(t, tc.counted, members.Counted) + }) + } +} diff --git a/internal/services/pagination_test.go b/internal/services/pagination_test.go index bc7a3b7f..2365861c 100644 --- a/internal/services/pagination_test.go +++ b/internal/services/pagination_test.go @@ -71,14 +71,14 @@ func TestListsCollectPagesBeyondServerPageSize(t *testing.T) { want = append(want, int64(id)) } client := transport.New(server.URL, "", false) - orgs, more, err := (Organizations{Client: client}).List(context.Background(), true, "", tc.limit, tc.page) + orgs, err := (Organizations{Client: client}).List(context.Background(), true, "", tc.limit, tc.page) require.NoError(t, err) got := []int64{} - for _, item := range orgs { + for _, item := range orgs.Items { got = append(got, item.ID) } require.Equal(t, want, got) - require.Equal(t, tc.total > tc.page*tc.limit, more) + require.Equal(t, tc.total > tc.page*tc.limit, orgs.More) require.LessOrEqual(t, *requests, tc.limit/min(tc.limit, tc.maxItems)+2) repos, more, err := (Repositories{Client: client}).List(context.Background(), RepositoryListOptions{Limit: tc.limit, Page: tc.page}) require.NoError(t, err)