From 85492487eb19a91f42437091b62b127b2e2dcfde Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Thu, 19 Sep 2024 15:52:55 +1000 Subject: [PATCH 1/3] Allow users to be invited to the org. --- internal/graphql/generated.go | 139 +++++++++++++++++++++ internal/organization/organization.graphql | 6 + internal/user/user.graphql | 7 ++ pkg/cmd/root/root.go | 2 + pkg/cmd/user/invite.go | 84 +++++++++++++ pkg/cmd/user/user.go | 27 ++++ 6 files changed, 265 insertions(+) create mode 100644 internal/organization/organization.graphql create mode 100644 internal/user/user.graphql create mode 100644 pkg/cmd/user/invite.go create mode 100644 pkg/cmd/user/user.go diff --git a/internal/graphql/generated.go b/internal/graphql/generated.go index aa9cebb..e4cf23a 100644 --- a/internal/graphql/generated.go +++ b/internal/graphql/generated.go @@ -203,6 +203,28 @@ func (v *GetClusterQueuesResponse) GetOrganization() *GetClusterQueuesOrganizati return v.Organization } +// GetOrganizationIDOrganization includes the requested fields of the GraphQL type Organization. +// The GraphQL type's documentation follows. +// +// An organization +type GetOrganizationIDOrganization struct { + Id string `json:"id"` +} + +// GetId returns GetOrganizationIDOrganization.Id, and is useful for accessing the field via an interface. +func (v *GetOrganizationIDOrganization) GetId() string { return v.Id } + +// GetOrganizationIDResponse is returned by GetOrganizationID on success. +type GetOrganizationIDResponse struct { + // Find an organization + Organization *GetOrganizationIDOrganization `json:"organization"` +} + +// GetOrganization returns GetOrganizationIDResponse.Organization, and is useful for accessing the field via an interface. +func (v *GetOrganizationIDResponse) GetOrganization() *GetOrganizationIDOrganization { + return v.Organization +} + // GetPipelinePipeline includes the requested fields of the GraphQL type Pipeline. // The GraphQL type's documentation follows. // @@ -324,6 +346,31 @@ type GetPipelineResponse struct { // GetPipeline returns GetPipelineResponse.Pipeline, and is useful for accessing the field via an interface. func (v *GetPipelineResponse) GetPipeline() *GetPipelinePipeline { return v.Pipeline } +// InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload includes the requested fields of the GraphQL type OrganizationInvitationCreatePayload. +// The GraphQL type's documentation follows. +// +// Autogenerated return type of OrganizationInvitationCreate. +type InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload struct { + // A unique identifier for the client performing the mutation. + ClientMutationId *string `json:"clientMutationId"` +} + +// GetClientMutationId returns InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload.ClientMutationId, and is useful for accessing the field via an interface. +func (v *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload) GetClientMutationId() *string { + return v.ClientMutationId +} + +// InviteUserResponse is returned by InviteUser on success. +type InviteUserResponse struct { + // Send email invitations to this organization. + OrganizationInvitationCreate *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload `json:"organizationInvitationCreate"` +} + +// GetOrganizationInvitationCreate returns InviteUserResponse.OrganizationInvitationCreate, and is useful for accessing the field via an interface. +func (v *InviteUserResponse) GetOrganizationInvitationCreate() *InviteUserOrganizationInvitationCreateOrganizationInvitationCreatePayload { + return v.OrganizationInvitationCreate +} + // All the possible states a job can be in type JobStates string @@ -468,6 +515,14 @@ func (v *__GetClusterQueuesInput) GetOrgSlug() string { return v.OrgSlug } // GetClusterId returns __GetClusterQueuesInput.ClusterId, and is useful for accessing the field via an interface. func (v *__GetClusterQueuesInput) GetClusterId() string { return v.ClusterId } +// __GetOrganizationIDInput is used internally by genqlient +type __GetOrganizationIDInput struct { + Slug string `json:"slug"` +} + +// GetSlug returns __GetOrganizationIDInput.Slug, and is useful for accessing the field via an interface. +func (v *__GetOrganizationIDInput) GetSlug() string { return v.Slug } + // __GetPipelineInput is used internally by genqlient type __GetPipelineInput struct { Slug string `json:"slug"` @@ -476,6 +531,18 @@ type __GetPipelineInput struct { // GetSlug returns __GetPipelineInput.Slug, and is useful for accessing the field via an interface. func (v *__GetPipelineInput) GetSlug() string { return v.Slug } +// __InviteUserInput is used internally by genqlient +type __InviteUserInput struct { + Organization string `json:"organization"` + Emails []string `json:"emails"` +} + +// GetOrganization returns __InviteUserInput.Organization, and is useful for accessing the field via an interface. +func (v *__InviteUserInput) GetOrganization() string { return v.Organization } + +// GetEmails returns __InviteUserInput.Emails, and is useful for accessing the field via an interface. +func (v *__InviteUserInput) GetEmails() []string { return v.Emails } + // __UnblockJobInput is used internally by genqlient type __UnblockJobInput struct { Id string `json:"id"` @@ -588,6 +655,41 @@ func GetClusterQueues( return &data_, err_ } +// The query or mutation executed by GetOrganizationID. +const GetOrganizationID_Operation = ` +query GetOrganizationID ($slug: ID!) { + organization(slug: $slug) { + id + } +} +` + +func GetOrganizationID( + ctx_ context.Context, + client_ graphql.Client, + slug string, +) (*GetOrganizationIDResponse, error) { + req_ := &graphql.Request{ + OpName: "GetOrganizationID", + Query: GetOrganizationID_Operation, + Variables: &__GetOrganizationIDInput{ + Slug: slug, + }, + } + var err_ error + + var data_ GetOrganizationIDResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + // The query or mutation executed by GetPipeline. const GetPipeline_Operation = ` query GetPipeline ($slug: ID!) { @@ -641,6 +743,43 @@ func GetPipeline( return &data_, err_ } +// The query or mutation executed by InviteUser. +const InviteUser_Operation = ` +mutation InviteUser ($organization: ID!, $emails: [String!]!) { + organizationInvitationCreate(input: {organizationID:$organization,emails:$emails}) { + clientMutationId + } +} +` + +func InviteUser( + ctx_ context.Context, + client_ graphql.Client, + organization string, + emails []string, +) (*InviteUserResponse, error) { + req_ := &graphql.Request{ + OpName: "InviteUser", + Query: InviteUser_Operation, + Variables: &__InviteUserInput{ + Organization: organization, + Emails: emails, + }, + } + var err_ error + + var data_ InviteUserResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + // The query or mutation executed by UnblockJob. const UnblockJob_Operation = ` mutation UnblockJob ($id: ID!, $fields: JSON) { diff --git a/internal/organization/organization.graphql b/internal/organization/organization.graphql new file mode 100644 index 0000000..e47759b --- /dev/null +++ b/internal/organization/organization.graphql @@ -0,0 +1,6 @@ +query GetOrganizationID ($slug: ID!) { + organization(slug: $slug){ + id + } +} + diff --git a/internal/user/user.graphql b/internal/user/user.graphql new file mode 100644 index 0000000..d63d7cf --- /dev/null +++ b/internal/user/user.graphql @@ -0,0 +1,7 @@ +mutation InviteUser($organization: ID!, $emails: [String!]!) { + organizationInvitationCreate( + input: { organizationID: $organization, emails: $emails } + ) { + clientMutationId + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 55d53ef..eb2ce6b 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -13,6 +13,7 @@ import ( pipelineCmd "github.com/buildkite/cli/v3/pkg/cmd/pipeline" packageCmd "github.com/buildkite/cli/v3/pkg/cmd/pkg" useCmd "github.com/buildkite/cli/v3/pkg/cmd/use" + "github.com/buildkite/cli/v3/pkg/cmd/user" versionCmd "github.com/buildkite/cli/v3/pkg/cmd/version" "github.com/spf13/cobra" ) @@ -41,6 +42,7 @@ func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { cmd.AddCommand(pipelineCmd.NewCmdPipeline(f)) cmd.AddCommand(packageCmd.NewCmdPackage(f)) cmd.AddCommand(useCmd.NewCmdUse(f)) + cmd.AddCommand(user.CommandUser(f)) cmd.AddCommand(versionCmd.NewCmdVersion(f)) return cmd, nil diff --git a/pkg/cmd/user/invite.go b/pkg/cmd/user/invite.go new file mode 100644 index 0000000..d20d94f --- /dev/null +++ b/pkg/cmd/user/invite.go @@ -0,0 +1,84 @@ +package user + +import ( + "context" + "fmt" + "sync" + + "github.com/MakeNowJust/heredoc" + "github.com/buildkite/cli/v3/internal/graphql" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/spf13/cobra" +) + +func CommandUserInvite(f *factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "invite [emails]", + Short: "Invite users to your organization", + Long: heredoc.Doc(` + Invite 1 or many users to your organization. + `), + Example: heredoc.Doc(` + # Invite a single user to your organization + $ bk user invite bob@supercoolorg.com + + # Invite multiple users to your organization + $ bk user invite bob@supercoolorg.com bobs_mate@supercoolorg.com + `), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("at least one email address is required") + } + + orgID, err := graphql.GetOrganizationID(cmd.Context(), f.GraphQLClient, f.Config.OrganizationSlug()) + if err != nil { + return err + } + + return createInvite(cmd.Context(), f, orgID.Organization.GetId(), args...) + }, + } + return cmd +} + +func createInvite(ctx context.Context, f *factory.Factory, orgID string, emails ...string) error { + if len(emails) == 0 { + return nil + } + + errChan := make(chan error, len(emails)) + var wg sync.WaitGroup + + for _, email := range emails { + wg.Add(1) + go func(email string) { + defer wg.Done() + _, err := graphql.InviteUser(ctx, f.GraphQLClient, orgID, []string{email}) + if err != nil { + errChan <- fmt.Errorf("error creating user invite for %s: %w", email, err) + } + }(email) + } + + go func() { + wg.Wait() + close(errChan) + }() + + var errs []error + for err := range errChan { + errs = append(errs, err) + } + + if len(errs) > 0 { + return fmt.Errorf("errors creating user invites: %v", errs) + } + + message := "Invite sent to" + if len(emails) > 1 { + message = "Invites sent to" + } + + fmt.Printf("%s: %v\n", message, emails) + return nil +} diff --git a/pkg/cmd/user/user.go b/pkg/cmd/user/user.go new file mode 100644 index 0000000..91b8944 --- /dev/null +++ b/pkg/cmd/user/user.go @@ -0,0 +1,27 @@ +package user + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/spf13/cobra" +) + +func CommandUser(f *factory.Factory) *cobra.Command { + cmd := cobra.Command{ + Use: "user ", + Short: "Manage users.", + Long: heredoc.Doc(` + Manage organization users via the CLI. + + To invite a user: + bk user invite [email address] + `), + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + + cmd.AddCommand(CommandUserInvite(f)) + + return &cmd +} From 478a3c5ba6688a0adec75788997a18ed69aaae8c Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Thu, 19 Sep 2024 15:55:19 +1000 Subject: [PATCH 2/3] Remove RunE from base User command so help prints. --- pkg/cmd/user/user.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/user/user.go b/pkg/cmd/user/user.go index 91b8944..b96d154 100644 --- a/pkg/cmd/user/user.go +++ b/pkg/cmd/user/user.go @@ -3,22 +3,21 @@ package user import ( "github.com/MakeNowJust/heredoc" "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" "github.com/spf13/cobra" ) func CommandUser(f *factory.Factory) *cobra.Command { cmd := cobra.Command{ Use: "user ", - Short: "Manage users.", + Short: "Invite users to the organization", Long: heredoc.Doc(` Manage organization users via the CLI. To invite a user: bk user invite [email address] `), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + PersistentPreRunE: validation.CheckValidConfiguration(f.Config), } cmd.AddCommand(CommandUserInvite(f)) From c40bc425987d5ce8cf7a2c4cfa7c67135b84f3e7 Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Thu, 19 Sep 2024 16:09:52 +1000 Subject: [PATCH 3/3] Add USER as usage option in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index be5c486..4472e6b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Available Commands: package Manage packages pipeline Manage pipelines use Select an organization + user Invite users to the organization Flags: -h, --help help for bk