diff --git a/cli/exp_chat.go b/cli/exp_chat.go index 61c017f172..840539b9e2 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -1,14 +1,18 @@ package cli import ( + "context" "fmt" "os" "path/filepath" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/agent/agentcontextconfig" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) @@ -23,6 +27,115 @@ func (r *RootCmd) chatCommand() *serpent.Command { }, Children: []*serpent.Command{ r.chatContextCommand(), + r.chatShareCommand(), + }, + } +} + +func (r *RootCmd) chatShareCommand() *serpent.Command { + return &serpent.Command{ + Use: "share", + Short: "Manage chat sharing", + Long: "Share chats with users and groups.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.chatShareAddCommand(), + }, + } +} + +func (r *RootCmd) chatShareAddCommand() *serpent.Command { + var users []string + var groups []string + + return &serpent.Command{ + Use: "add --user : --group :", + Short: "Share a chat with a user or group.", + Options: serpent.OptionSet{ + { + Name: "user", + Description: "A comma separated list of users to share the chat with.", + Flag: "user", + Value: serpent.StringArrayOf(&users), + }, { + Name: "group", + Description: "A comma separated list of groups to share the chat with.", + Flag: "group", + Value: serpent.StringArrayOf(&groups), + }, + }, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + if len(users) == 0 && len(groups) == 0 { + return xerrors.New("at least one user or group must be provided") + } + + chatID, err := parseChatShareID(inv.Args[0]) + if err != nil { + return err + } + + client, err := r.InitClient(inv) + if err != nil { + return err + } + experimentalClient := codersdk.NewExperimentalClient(client) + + chat, err := experimentalClient.GetChat(inv.Context(), chatID) + if err != nil { + return xerrors.Errorf("unable to fetch chat %s: %w", inv.Args[0], err) + } + + userRoleStrings := make([][2]string, len(users)) + for i, user := range users { + parsed, err := parseChatShareActorRole(user) + if err != nil { + return xerrors.Errorf("invalid user format %q: %w", user, err) + } + userRoleStrings[i] = parsed + } + + groupRoleStrings := make([][2]string, len(groups)) + for i, group := range groups { + parsed, err := parseChatShareActorRole(group) + if err != nil { + return xerrors.Errorf("invalid group format %q: %w", group, err) + } + groupRoleStrings[i] = parsed + } + + userRoles, groupRoles, err := fetchChatUsersAndGroups(inv.Context(), chatRoleLookupParams{ + Client: client, + OrgID: chat.OrganizationID, + Users: userRoleStrings, + Groups: groupRoleStrings, + DefaultRole: codersdk.ChatRoleRead, + }) + if err != nil { + return err + } + + if err := experimentalClient.UpdateChatACL(inv.Context(), chat.ID, codersdk.UpdateChatACL{ + UserRoles: userRoles, + GroupRoles: groupRoles, + }); err != nil { + return err + } + + acl, err := experimentalClient.GetChatACL(inv.Context(), chat.ID) + if err != nil { + return xerrors.Errorf("could not fetch current chat ACL after sharing: %w", err) + } + out, err := chatACLToTable(inv.Context(), &acl) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err }, } } @@ -179,6 +292,173 @@ func (*RootCmd) chatContextClearCommand() *serpent.Command { return cmd } +const chatShareDefaultGroupDisplay = "-" + +type chatRoleLookupParams struct { + Client *codersdk.Client + OrgID uuid.UUID + OrgName string + Users [][2]string + Groups [][2]string + DefaultRole codersdk.ChatRole +} + +func parseChatShareID(raw string) (uuid.UUID, error) { + parsed, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, xerrors.Errorf("invalid chat ID %q: %w", raw, err) + } + return parsed, nil +} + +func parseChatShareActorRole(raw string) ([2]string, error) { + if strings.Count(raw, ":") > 1 { + return [2]string{}, xerrors.New("must match pattern 'name:role'") + } + parts := strings.SplitN(raw, ":", 2) + name := parts[0] + if name == "" || !codersdk.UsernameValidRegex.MatchString(name) { + return [2]string{}, xerrors.New("invalid name") + } + if len(parts) == 1 { + return [2]string{name, ""}, nil + } + if parts[1] == "" { + return [2]string{}, xerrors.New("role cannot be empty") + } + return [2]string{name, parts[1]}, nil +} + +func parseChatShareActor(raw string) ([2]string, error) { + if strings.Contains(raw, ":") { + return [2]string{}, xerrors.New("roles are only accepted by chat share add") + } + if raw == "" || !codersdk.UsernameValidRegex.MatchString(raw) { + return [2]string{}, xerrors.New("invalid name") + } + return [2]string{raw, ""}, nil +} + +func stringToChatRole(role string) (codersdk.ChatRole, error) { + switch role { + case string(codersdk.ChatRoleRead): + return codersdk.ChatRoleRead, nil + case string(codersdk.ChatRoleDeleted): + return codersdk.ChatRoleDeleted, nil + default: + return "", xerrors.Errorf("invalid role %q: expected %q", role, codersdk.ChatRoleRead) + } +} + +func fetchChatUsersAndGroups(ctx context.Context, params chatRoleLookupParams) (map[string]codersdk.ChatRole, map[string]codersdk.ChatRole, error) { + userRoles := make(map[string]codersdk.ChatRole, len(params.Users)) + if len(params.Users) > 0 { + orgMembers, err := params.Client.OrganizationMembers(ctx, params.OrgID) + if err != nil { + return nil, nil, err + } + + for _, user := range params.Users { + username := user[0] + role := user[1] + if role == "" { + role = string(params.DefaultRole) + } + + userID := "" + for _, member := range orgMembers { + if member.Username == username { + userID = member.UserID.String() + break + } + } + if userID == "" { + return nil, nil, xerrors.Errorf("could not find user %s in the organization %s", username, params.OrgName) + } + + chatRole, err := stringToChatRole(role) + if err != nil { + return nil, nil, err + } + userRoles[userID] = chatRole + } + } + + groupRoles := make(map[string]codersdk.ChatRole, len(params.Groups)) + if len(params.Groups) > 0 { + orgGroups, err := params.Client.Groups(ctx, codersdk.GroupArguments{ + Organization: params.OrgID.String(), + }) + if err != nil { + return nil, nil, err + } + + for _, group := range params.Groups { + groupName := group[0] + role := group[1] + if role == "" { + role = string(params.DefaultRole) + } + + var orgGroup *codersdk.Group + for _, candidate := range orgGroups { + if candidate.Name == groupName { + orgGroup = &candidate + break + } + } + if orgGroup == nil { + return nil, nil, xerrors.Errorf("could not find group named %s belonging to the organization %s", groupName, params.OrgName) + } + + chatRole, err := stringToChatRole(role) + if err != nil { + return nil, nil, err + } + groupRoles[orgGroup.ID.String()] = chatRole + } + } + + return userRoles, groupRoles, nil +} + +func chatACLToTable(ctx context.Context, acl *codersdk.ChatACL) (string, error) { + type chatShareRow struct { + User string `table:"user"` + Group string `table:"group,default_sort"` + Role codersdk.ChatRole `table:"role"` + } + + formatter := cliui.NewOutputFormatter( + cliui.TableFormat( + []chatShareRow{}, []string{"User", "Group", "Role"}), + cliui.JSONFormat()) + + outputRows := make([]chatShareRow, 0, len(acl.Users)+len(acl.Groups)) + for _, user := range acl.Users { + if user.Role == codersdk.ChatRoleDeleted { + continue + } + outputRows = append(outputRows, chatShareRow{ + User: user.Username, + Group: chatShareDefaultGroupDisplay, + Role: user.Role, + }) + } + for _, group := range acl.Groups { + if group.Role == codersdk.ChatRoleDeleted { + continue + } + outputRows = append(outputRows, chatShareRow{ + User: "", + Group: group.Name, + Role: group.Role, + }) + } + + return formatter.Format(ctx, outputRows) +} + // parseChatID returns the chat UUID from the flag value (which // serpent already populates from --chat or CODER_CHAT_ID). Returns // uuid.Nil if empty (the server will auto-detect). diff --git a/cli/exp_chat_test.go b/cli/exp_chat_test.go index 30696c6eca..660aea6691 100644 --- a/cli/exp_chat_test.go +++ b/cli/exp_chat_test.go @@ -1,13 +1,192 @@ package cli_test import ( + "bytes" + "fmt" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) +func TestExpChatShareAdd(t *testing.T) { + t.Parallel() + + t.Run("ShareWithUserExplicitReadRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add user", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":read") + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + Name: toShareWithUser.Name, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + assert.Contains(t, out.String(), toShareWithUser.Username) + assert.Contains(t, out.String(), string(codersdk.ChatRoleRead)) + }) + + t.Run("ShareWithUserDefaultReadRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add user default role", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username) + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + Name: toShareWithUser.Name, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + }) + + t.Run("ShareWithMultipleUsers", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser1 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, toShareWithUser2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add multiple users", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, + "exp", "chat", "share", "add", chat.ID.String(), + fmt.Sprintf("--user=%s:read,%s:read", toShareWithUser1.Username, toShareWithUser2.Username), + ) + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser1.ID, + Username: toShareWithUser1.Username, + Name: toShareWithUser1.Name, + AvatarURL: toShareWithUser1.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser2.ID, + Username: toShareWithUser2.Username, + Name: toShareWithUser2.Name, + AvatarURL: toShareWithUser2.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + }) + + t.Run("RejectsUnknownRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add invalid role", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":write") + clitest.SetupConfig(t, client, root) + + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid role \"write\"") + }) + + t.Run("RequiresActor", func(t *testing.T) { + t.Parallel() + + chatID := "00000000-0000-0000-0000-000000000001" + inv, _ := clitest.New(t, "exp", "chat", "share", "add", chatID) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "at least one user or group must be provided") + }) + + t.Run("RejectsInvalidChatID", func(t *testing.T) { + t.Parallel() + + inv, _ := clitest.New(t, "exp", "chat", "share", "add", "not-a-uuid", "--user", "alice:read") + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid chat ID") + }) +} + func TestExpChatContextAdd(t *testing.T) { t.Parallel()