mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
fix(org): check membership before member changes and polish org output
org member remove and public/private exited 0 without a word for users who are not members, and member list --public showed concealed members, because Gitea lists them as public to administrators. Membership changes now check the membership first: removing a non-member warns, publicizing one fails, and a change to the current visibility warns. Member list checks each membership and gains a VISIBILITY column. org list and member list get a header and context line through writeTable, org view shows the description, website, location and member, team and repository counts, mutations print ✓ lines, and org delete asks to type the organization's name after refusing organizations that still own repositories. public and private become publicize and conceal, the old names stay as aliases, and org edit --visibility no longer shows the create default. Every command has examples, and organizations and members complete. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
7 files changed
+700
-131
No files matched your search
@@ -488,15 +488,6 @@ const (
|
|||||||
notification unpin
|
notification unpin
|
||||||
notification view
|
notification view
|
||||||
notification watch
|
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 delete
|
||||||
pr attachment download
|
pr attachment download
|
||||||
pr attachment edit
|
pr attachment edit
|
||||||
@@ -625,8 +616,6 @@ const (
|
|||||||
milestone
|
milestone
|
||||||
milestone issue
|
milestone issue
|
||||||
notification
|
notification
|
||||||
org
|
|
||||||
org member
|
|
||||||
pr
|
pr
|
||||||
pr attachment
|
pr attachment
|
||||||
pr comment
|
pr comment
|
||||||
|
|||||||
@@ -445,8 +445,8 @@ func (a *application) completeReleases() cobra.CompletionFunc {
|
|||||||
// completeOrgs completes the organizations of the account.
|
// completeOrgs completes the organizations of the account.
|
||||||
func (a *application) completeOrgs() cobra.CompletionFunc {
|
func (a *application) completeOrgs() cobra.CompletionFunc {
|
||||||
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
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)
|
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
|
return describe(items.Items, func(item services.Organization) (string, string) { return item.Name, item.FullName }), err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+393
-88
@@ -4,7 +4,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
@@ -13,16 +17,71 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func organizationRow(item services.Organization) []terminal.Cell {
|
var visibilities = []string{"public", "limited", "private"}
|
||||||
return terminal.Row(item.Name, item.FullName, item.Visibility)
|
|
||||||
|
// 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 {
|
func (a *application) organizationCommand() *cobra.Command {
|
||||||
group := &cobra.Command{Use: "org", Short: "Manage organizations"}
|
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 all bool
|
||||||
var username string
|
var username string
|
||||||
var limit, page int
|
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 {
|
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 != "" {
|
if all && username != "" {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--all and --username are mutually exclusive"))
|
return clierr.Wrap(clierr.Usage, errors.New("--all and --username are mutually exclusive"))
|
||||||
}
|
}
|
||||||
@@ -30,137 +89,383 @@ func (a *application) organizationCommand() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
items, more, err := (services.Organizations{Client: target.client}).List(cmd.Context(), !all && username == "", username, limit, page)
|
orgs, err := (services.Organizations{Client: target.client}).List(cmd.Context(), !all && username == "", username, limit, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeList(a, cmd, items, organizationRow); err != nil {
|
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 <org>", 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
|
return err
|
||||||
}
|
}
|
||||||
return a.morePagesHint(cmd, more, page)
|
service := services.Organizations{Client: target.client}
|
||||||
}}
|
org, err := service.View(cmd.Context(), args[0])
|
||||||
list.Flags().BoolVar(&all, "all", false, "List public organizations instead of your memberships")
|
if err != nil {
|
||||||
list.Flags().StringVar(&username, "username", "", "List organizations for a username")
|
return err
|
||||||
pageFlags(list, &limit, &page)
|
}
|
||||||
group.AddCommand(list)
|
var size services.OrganizationSize
|
||||||
for _, verb := range []string{"view", "create", "edit", "delete"} {
|
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))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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 fullName, description, website, location, visibility string
|
||||||
var adminAccess bool
|
var adminAccess bool
|
||||||
command := &cobra.Command{Use: verb + " <name>", Short: titleCase(verb) + " an organization", Args: argsExact(1)}
|
cmd.Flags().StringVar(&fullName, "full-name", "", "Display name")
|
||||||
if verb == "delete" {
|
cmd.Flags().StringVarP(&description, "description", "d", "", "Organization description")
|
||||||
command.Annotations = textOutput("organization deletion does not return a resource")
|
cmd.Flags().StringVar(&website, "website", "", "Website URL")
|
||||||
}
|
cmd.Flags().StringVar(&location, "location", "", "Location")
|
||||||
if verb == "create" || verb == "edit" {
|
initial := ""
|
||||||
command.Flags().StringVar(&fullName, "full-name", "", "Display name")
|
if create {
|
||||||
command.Flags().StringVar(&description, "description", "", "Organization description")
|
initial = "public"
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
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{}
|
opts := services.OrganizationOptions{}
|
||||||
if verb == "create" {
|
for _, field := range []struct {
|
||||||
opts.Name = &name
|
flag string
|
||||||
opts.Visibility = &visibility
|
value *string
|
||||||
opts.RepoAdminChangeTeamAccess = &adminAccess
|
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 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") {
|
if cmd.Flags().Changed("repo-admins-can-change-team-access") {
|
||||||
opts.RepoAdminChangeTeamAccess = &adminAccess
|
opts.RepoAdminChangeTeamAccess = &adminAccess
|
||||||
}
|
}
|
||||||
if verb == "edit" && opts == (services.OrganizationOptions{}) {
|
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 <name>", 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 <org>", 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"))
|
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 <org>", 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)
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
service := services.Organizations{Client: target.client}
|
service := services.Organizations{Client: target.client}
|
||||||
if verb == "delete" {
|
org, err := service.View(cmd.Context(), name)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeItem(a, cmd, item, organizationRow)
|
size, err := service.Size(cmd.Context(), org.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
group.AddCommand(command)
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
group.AddCommand(a.organizationMemberCommand())
|
|
||||||
return group
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) organizationMemberCommand() *cobra.Command {
|
func (a *application) organizationMemberCommand() *cobra.Command {
|
||||||
group := &cobra.Command{Use: "member", Short: "Manage organization membership"}
|
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 public bool
|
||||||
var limit, page int
|
var limit, page int
|
||||||
list := &cobra.Command{Use: "list <org>", Short: "List organization members", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
list := &cobra.Command{
|
||||||
|
Use: "list <org>", 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)
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
items, err := (services.Organizations{Client: target.client}).Members(cmd.Context(), args[0], public, limit, page)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeList(a, cmd, items, func(item services.OrganizationMember) []terminal.Cell {
|
what := "members"
|
||||||
return terminal.Row(item.Username, item.FullName)
|
if public {
|
||||||
})
|
what = "public members"
|
||||||
}}
|
}
|
||||||
list.Flags().BoolVar(&public, "public", false, "List publicly visible memberships")
|
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)
|
pageFlags(list, &limit, &page)
|
||||||
group.AddCommand(list)
|
group.AddCommand(list, a.organizationVisibilityCommand(true), a.organizationVisibilityCommand(false), a.organizationMemberRemoveCommand())
|
||||||
for _, verb := range []string{"remove", "public", "private"} {
|
return group
|
||||||
group.AddCommand(&cobra.Command{Use: verb + " <org> <username>", 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 {
|
}
|
||||||
|
|
||||||
|
// 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 + " <org> <username>", 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)
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
service := services.Organizations{Client: target.client}
|
service := services.Organizations{Client: target.client}
|
||||||
if verb == "remove" {
|
member, err := service.IsMember(cmd.Context(), org, username)
|
||||||
if err := a.confirm(cmd, "Remove "+args[1]+" from organization "+args[0]); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return service.RemoveMember(cmd.Context(), args[0], args[1])
|
if !member {
|
||||||
|
return fmt.Errorf("%s is not a member of %s", username, org)
|
||||||
}
|
}
|
||||||
return service.SetMemberPublic(cmd.Context(), args[0], args[1], verb == "public")
|
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 <org> <username>", 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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return group
|
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
"gitea.dev/tea/internal/transport"
|
"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) {
|
org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) {
|
||||||
return c.Organizations.GetOrg(ctx, owner)
|
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
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,6 +45,12 @@ func organization(ctx context.Context, client *transport.Client, owner string) (
|
|||||||
return org.ID, true, nil
|
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.
|
// OrganizationOptions permits explicit empty and false preference updates.
|
||||||
type OrganizationOptions struct {
|
type OrganizationOptions struct {
|
||||||
Name *string `json:"username,omitempty"`
|
Name *string `json:"username,omitempty"`
|
||||||
@@ -59,9 +66,8 @@ type OrganizationOptions struct {
|
|||||||
type Organizations struct{ Client *transport.Client }
|
type Organizations struct{ Client *transport.Client }
|
||||||
|
|
||||||
// List selects public organizations, your organizations, or a named user's organizations.
|
// 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) (Page[Organization], error) {
|
||||||
func (s Organizations) List(ctx context.Context, mine bool, username string, limit, page int) ([]Organization, bool, error) {
|
orgs, err := listPage(limit, page, func(limit, page int) ([]*gitea.Organization, http.Header, error) {
|
||||||
orgs, more, err := listWindow(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) {
|
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)}
|
opts := gitea.ListOrgsOptions{ListOptions: listOptions(limit, page)}
|
||||||
switch {
|
switch {
|
||||||
@@ -74,10 +80,12 @@ func (s Organizations) List(ctx context.Context, mine bool, username string, lim
|
|||||||
})
|
})
|
||||||
return orgs, headerOf(resp), err
|
return orgs, headerOf(resp), err
|
||||||
})
|
})
|
||||||
if err != nil {
|
return convertPage(orgs, organizationFrom), err
|
||||||
return nil, false, err
|
|
||||||
}
|
}
|
||||||
return convertAll(orgs, organizationFrom), more, nil
|
|
||||||
|
// 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.
|
// View retrieves a named organization.
|
||||||
@@ -91,6 +99,56 @@ func (s Organizations) View(ctx context.Context, name string) (Organization, err
|
|||||||
return organizationFrom(org), nil
|
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.
|
// Create creates an organization owned by the selected account.
|
||||||
func (s Organizations) Create(ctx context.Context, opts OrganizationOptions) (Organization, error) {
|
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) {
|
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"`
|
ID int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FullName string `json:"full_name"`
|
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.
|
// Membership visibilities.
|
||||||
func (s Organizations) Members(ctx context.Context, name string, public bool, limit, page int) ([]OrganizationMember, error) {
|
const (
|
||||||
users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) {
|
MembershipPublic = "public"
|
||||||
opts := gitea.ListOrgMembershipOption{ListOptions: listOptions(limit, page)}
|
MembershipConcealed = "concealed"
|
||||||
if public {
|
)
|
||||||
return c.Organizations.ListPublicOrgMembership(ctx, name, opts)
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
return c.Organizations.ListOrgMembership(ctx, name, opts)
|
|
||||||
|
// 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 {
|
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.
|
// RemoveMember removes an organization member and their organization access.
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ func TestOrganizationListSelectsScopeAndPage(t *testing.T) {
|
|||||||
w.Header().Set("X-Total-Count", "7")
|
w.Header().Set("X-Total-Count", "7")
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"username":"legacy"},{"id":2,"name":"acme"}]`))
|
_, _ = 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.NoError(t, err)
|
||||||
require.True(t, more)
|
require.True(t, items.More)
|
||||||
require.Equal(t, []string{"legacy", "acme"}, []string{items[0].Name, items[1].Name})
|
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
|
var requests []string
|
||||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
requests = append(requests, r.Method+" "+r.URL.Path)
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
service := Organizations{Client: client}
|
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.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.SetMemberPublic(context.Background(), "acme", "bob", true))
|
||||||
require.NoError(t, service.RemoveMember(context.Background(), "acme", "bob"))
|
require.NoError(t, service.RemoveMember(context.Background(), "acme", "bob"))
|
||||||
require.Equal(t, []string{
|
require.Equal(t, []string{
|
||||||
"GET /api/v1/orgs/acme/public_members", "PUT /api/v1/orgs/acme/public_members/bob",
|
"GET /api/v1/orgs/acme/members/bob", "PUT /api/v1/orgs/acme/public_members/bob", "DELETE /api/v1/orgs/acme/members/bob",
|
||||||
"DELETE /api/v1/orgs/acme/members/bob",
|
|
||||||
}, requests)
|
}, 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,14 +71,14 @@ func TestListsCollectPagesBeyondServerPageSize(t *testing.T) {
|
|||||||
want = append(want, int64(id))
|
want = append(want, int64(id))
|
||||||
}
|
}
|
||||||
client := transport.New(server.URL, "", false)
|
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)
|
require.NoError(t, err)
|
||||||
got := []int64{}
|
got := []int64{}
|
||||||
for _, item := range orgs {
|
for _, item := range orgs.Items {
|
||||||
got = append(got, item.ID)
|
got = append(got, item.ID)
|
||||||
}
|
}
|
||||||
require.Equal(t, want, got)
|
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)
|
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})
|
repos, more, err := (Repositories{Client: client}).List(context.Background(), RepositoryListOptions{Limit: tc.limit, Page: tc.page})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
Reference in new issue
Block a user