diff --git a/cmd/channel.go b/cmd/channel.go index b47baf4..af0621a 100644 --- a/cmd/channel.go +++ b/cmd/channel.go @@ -124,9 +124,9 @@ func init() { channelCmd.AddCommand(channelListCmd, channelNewCmd, channelInviteCmd) channelListCmd.Flags().String("user", "", "User ID, @handle, or email") - channelListCmd.Flags().Bool("all", false, "List all workspace conversations") - channelListCmd.Flags().Int("limit", 100, "Max conversations per page") - channelListCmd.Flags().String("cursor", "", "Pagination cursor") + channelListCmd.Flags().Bool("all", false, "List all workspace conversations (auto-paginates unless --cursor is set)") + channelListCmd.Flags().Int("limit", 100, "Conversations per page fetched from Slack") + channelListCmd.Flags().String("cursor", "", "Resume from this pagination cursor instead of auto-paginating") channelNewCmd.Flags().String("name", "", "Channel name") _ = channelNewCmd.MarkFlagRequired("name") diff --git a/internal/slack/channels.go b/internal/slack/channels.go index e664e60..12fb991 100644 --- a/internal/slack/channels.go +++ b/internal/slack/channels.go @@ -27,8 +27,11 @@ type ListChannelsOpts struct { type ChannelListResult struct { Channels []types.CompactChannel `json:"channels"` NextCursor string `json:"next_cursor,omitempty"` + Truncated bool `json:"truncated,omitempty"` } +var maxAutoPaginatePages = 50 + // ListChannels lists conversations. // Uses users.conversations (default) or conversations.list (when All=true). func ListChannels(ctx context.Context, client *api.Client, opts ListChannelsOpts) (*ChannelListResult, error) { @@ -47,35 +50,49 @@ func ListChannels(ctx context.Context, client *api.Client, opts ListChannelsOpts method = "conversations.list" } - params := map[string]string{ - "types": opts.Types, - "limit": strconv.Itoa(opts.Limit), - "exclude_archived": "true", - } - if !opts.ExcludeArchived { - params["exclude_archived"] = "false" - } - if opts.Cursor != "" { - params["cursor"] = opts.Cursor - } - if opts.UserID != "" && !opts.All { - params["user"] = opts.UserID - } - - resp, err := client.Call(ctx, method, params) - if err != nil { - return nil, err - } + autoPaginate := opts.All && opts.Cursor == "" - rawChannels := api.GetSlice(resp["channels"]) result := &ChannelListResult{} - for _, ch := range rawChannels { - if m, ok := ch.(map[string]interface{}); ok { - result.Channels = append(result.Channels, parseRawChannel(m)) + cursor := opts.Cursor + for page := 0; ; page++ { + params := map[string]string{ + "types": opts.Types, + "limit": strconv.Itoa(opts.Limit), + "exclude_archived": "true", + } + if !opts.ExcludeArchived { + params["exclude_archived"] = "false" + } + if cursor != "" { + params["cursor"] = cursor + } + if opts.UserID != "" && !opts.All { + params["user"] = opts.UserID + } + + resp, err := client.Call(ctx, method, params) + if err != nil { + return nil, err + } + + rawChannels := api.GetSlice(resp["channels"]) + for _, ch := range rawChannels { + if m, ok := ch.(map[string]interface{}); ok { + result.Channels = append(result.Channels, parseRawChannel(m)) + } + } + + cursor = api.ExtractCursor(resp) + if !autoPaginate || cursor == "" { + break + } + if page+1 >= maxAutoPaginatePages { + result.Truncated = true + break } } - result.NextCursor = api.ExtractCursor(resp) + result.NextCursor = cursor return result, nil } diff --git a/internal/slack/channels_test.go b/internal/slack/channels_test.go new file mode 100644 index 0000000..af2d390 --- /dev/null +++ b/internal/slack/channels_test.go @@ -0,0 +1,129 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nullify/slack-cli/internal/api" + "github.com/nullify/slack-cli/internal/types" +) + +func fakeConversationsList(t *testing.T, pages [][]string) *httptest.Server { + t.Helper() + call := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if call >= len(pages) { + t.Fatalf("unexpected extra call %d to conversations.list", call+1) + } + names := pages[call] + call++ + + channels := make([]map[string]any, len(names)) + for i, n := range names { + channels[i] = map[string]any{"id": fmt.Sprintf("C%d", i), "name": n} + } + + nextCursor := "" + if call < len(pages) { + nextCursor = fmt.Sprintf("cursor-%d", call) + } + + resp := map[string]any{ + "ok": true, + "channels": channels, + "response_metadata": map[string]any{"next_cursor": nextCursor}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) +} + +func testClient(baseURL string) *api.Client { + return api.NewClient(&types.AuthConfig{ + Mode: types.AuthBrowser, + Token: "xoxc-test", + Cookie: "xoxd-test", + WorkspaceURL: baseURL, + }) +} + +func TestListChannels_AllAutoPaginatesAcrossShardedPages(t *testing.T) { + srv := fakeConversationsList(t, [][]string{ + {"general", "nul-team"}, + {"internal-acme", "internal-globex"}, + {"nul-random"}, + }) + defer srv.Close() + + result, err := ListChannels(context.Background(), testClient(srv.URL), ListChannelsOpts{All: true}) + if err != nil { + t.Fatalf("ListChannels: %v", err) + } + + if len(result.Channels) != 5 { + t.Fatalf("expected 5 channels merged across pages, got %d: %+v", len(result.Channels), result.Channels) + } + if result.NextCursor != "" { + t.Fatalf("expected no next_cursor after exhausting pages, got %q", result.NextCursor) + } + if result.Truncated { + t.Fatalf("expected Truncated=false when pagination completes within the cap") + } + + found := false + for _, ch := range result.Channels { + if ch.Name == "internal-acme" { + found = true + } + } + if !found { + t.Fatalf("expected internal-acme in merged results, got %+v", result.Channels) + } +} + +func TestListChannels_ExplicitCursorFetchesSinglePage(t *testing.T) { + srv := fakeConversationsList(t, [][]string{ + {"general"}, + {"internal-acme"}, + }) + defer srv.Close() + + result, err := ListChannels(context.Background(), testClient(srv.URL), ListChannelsOpts{All: true, Cursor: "some-cursor"}) + if err != nil { + t.Fatalf("ListChannels: %v", err) + } + + if len(result.Channels) != 1 { + t.Fatalf("expected exactly 1 page of channels with explicit cursor, got %d", len(result.Channels)) + } +} + +func TestListChannels_TruncatesAtPageCap(t *testing.T) { + old := maxAutoPaginatePages + maxAutoPaginatePages = 2 + defer func() { maxAutoPaginatePages = old }() + + srv := fakeConversationsList(t, [][]string{ + {"a"}, {"b"}, {"c"}, + }) + defer srv.Close() + + result, err := ListChannels(context.Background(), testClient(srv.URL), ListChannelsOpts{All: true}) + if err != nil { + t.Fatalf("ListChannels: %v", err) + } + + if !result.Truncated { + t.Fatalf("expected Truncated=true when the page cap is hit before exhaustion") + } + if result.NextCursor == "" { + t.Fatalf("expected a resumable NextCursor when truncated") + } + if len(result.Channels) != 2 { + t.Fatalf("expected 2 channels (one per allowed page) before truncation, got %d", len(result.Channels)) + } +}