Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions pkg/linear/teams/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ func NewClient(base *core.BaseClient) *Client {
return &Client{base: base}
}

// teamsPageSize is the number of teams requested per page when paginating
// through the full team list. Linear's API defaults to 50 per page for
// unpaginated connection queries, which silently truncates results for
// workspaces with more than 50 teams (see joa23/linear-cli#71). We paginate
// explicitly to ensure GetTeams always returns the complete list.
const teamsPageSize = 50

// GetTeams retrieves all teams in the workspace
// Why: Teams are the primary organizational unit in Linear. Users need
// to discover available teams for issue creation and assignment.
//
// This walks the full teams connection using cursor-based pagination
// (first/after + pageInfo.hasNextPage/endCursor) so that workspaces with
// more than one page of teams (>50) are not silently truncated. See
// https://github.com/joa23/linear-cli/issues/71.
func (tc *Client) GetTeams() ([]core.Team, error) {
const query = `
query GetTeams {
teams {
query GetTeams($first: Int!, $after: String) {
teams(first: $first, after: $after) {
nodes {
id
name
Expand All @@ -35,22 +47,49 @@ func (tc *Client) GetTeams() ([]core.Team, error) {
issueEstimationExtended
defaultIssueEstimate
}
pageInfo {
hasNextPage
endCursor
}
}
}
`

var response struct {
Teams struct {
Nodes []core.Team `json:"nodes"`
} `json:"teams"`
}

err := tc.base.ExecuteRequest(query, nil, &response)
if err != nil {
return nil, fmt.Errorf("failed to get teams: %w", err)

var allTeams []core.Team
var after string

for {
variables := map[string]interface{}{
"first": teamsPageSize,
}
if after != "" {
variables["after"] = after
}

var response struct {
Teams struct {
Nodes []core.Team `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
} `json:"teams"`
}

err := tc.base.ExecuteRequest(query, variables, &response)
if err != nil {
return nil, fmt.Errorf("failed to get teams: %w", err)
}

allTeams = append(allTeams, response.Teams.Nodes...)

if !response.Teams.PageInfo.HasNextPage || response.Teams.PageInfo.EndCursor == "" {
break
}
after = response.Teams.PageInfo.EndCursor
}
return response.Teams.Nodes, nil

return allTeams, nil
}

// GetTeam retrieves a single team by ID with estimate settings
Expand Down
133 changes: 133 additions & 0 deletions pkg/linear/teams/pagination_manual_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package teams

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/joa23/linear-cli/pkg/linear/core"
)

// sequentialTransport returns a different canned response body on each
// successive RoundTrip call, in order. Used to simulate multi-page
// GraphQL pagination responses.
type sequentialTransport struct {
bodies []string
calls []string // captures request bodies for assertions
idx int
}

func (s *sequentialTransport) RoundTrip(req *http.Request) (*http.Response, error) {
buf, _ := io.ReadAll(req.Body)
s.calls = append(s.calls, string(buf))

if s.idx >= len(s.bodies) {
panic("sequentialTransport: no more canned responses")
}
body := s.bodies[s.idx]
s.idx++

return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}, nil
}

// TestGetTeams_PaginatesAcrossMultiplePages verifies that GetTeams follows
// pageInfo.hasNextPage/endCursor to fetch every page of teams, rather than
// silently truncating at the first page (regression test for
// joa23/linear-cli#71).
func TestGetTeams_PaginatesAcrossMultiplePages(t *testing.T) {
page1 := `{"data":{"teams":{"nodes":[{"id":"t1","name":"Team One","key":"ONE"},{"id":"t2","name":"Team Two","key":"TWO"}],"pageInfo":{"hasNextPage":true,"endCursor":"cursor-abc"}}}}`
page2 := `{"data":{"teams":{"nodes":[{"id":"t3","name":"Team Three","key":"THR"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}`

transport := &sequentialTransport{bodies: []string{page1, page2}}

base := core.NewBaseClient("fake-token")
base.SetHTTPClient(&http.Client{Transport: transport})

client := NewClient(base)

teams, err := client.GetTeams()
if err != nil {
t.Fatalf("GetTeams returned error: %v", err)
}

if len(teams) != 3 {
t.Fatalf("expected 3 teams across 2 pages, got %d: %+v", len(teams), teams)
}

wantIDs := map[string]bool{"t1": true, "t2": true, "t3": true}
for _, tm := range teams {
if !wantIDs[tm.ID] {
t.Errorf("unexpected team ID in result: %s", tm.ID)
}
delete(wantIDs, tm.ID)
}
if len(wantIDs) != 0 {
t.Errorf("missing expected team IDs: %+v", wantIDs)
}

// Verify exactly 2 HTTP calls were made (one per page)
if len(transport.calls) != 2 {
t.Fatalf("expected 2 GraphQL requests (one per page), got %d", len(transport.calls))
}

// First request must NOT include an "after" cursor
var firstReq struct {
Variables map[string]interface{} `json:"variables"`
}
if err := json.Unmarshal([]byte(transport.calls[0]), &firstReq); err != nil {
t.Fatalf("failed to unmarshal first request: %v", err)
}
if _, hasAfter := firstReq.Variables["after"]; hasAfter {
t.Errorf("first request should not include 'after' variable, got: %+v", firstReq.Variables)
}
if firstReq.Variables["first"] == nil {
t.Errorf("first request should include 'first' variable")
}

// Second request MUST include the cursor returned by page 1
var secondReq struct {
Variables map[string]interface{} `json:"variables"`
}
if err := json.Unmarshal([]byte(transport.calls[1]), &secondReq); err != nil {
t.Fatalf("failed to unmarshal second request: %v", err)
}
if secondReq.Variables["after"] != "cursor-abc" {
t.Errorf("second request 'after' = %v, want %q", secondReq.Variables["after"], "cursor-abc")
}

// Sanity: query string should reference pageInfo/hasNextPage/endCursor
if !strings.Contains(transport.calls[0], "pageInfo") {
t.Errorf("query does not request pageInfo")
}
}

// TestGetTeams_SinglePage verifies the common case (a single page, no
// pagination needed) still works and issues exactly one request.
func TestGetTeams_SinglePage(t *testing.T) {
page1 := `{"data":{"teams":{"nodes":[{"id":"t1","name":"Team One","key":"ONE"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}`

transport := &sequentialTransport{bodies: []string{page1}}

base := core.NewBaseClient("fake-token")
base.SetHTTPClient(&http.Client{Transport: transport})

client := NewClient(base)

teams, err := client.GetTeams()
if err != nil {
t.Fatalf("GetTeams returned error: %v", err)
}
if len(teams) != 1 {
t.Fatalf("expected 1 team, got %d", len(teams))
}
if len(transport.calls) != 1 {
t.Fatalf("expected exactly 1 request for single page, got %d", len(transport.calls))
}
}