Verified Commit cfdf5ee6 authored by Florian Forster's avatar Florian Forster
Browse files

feat(workitems): Add pagination support to `ListWorkItems`.

Add full pagination support to `ListWorkItems` by implementing GraphQL
cursor-based pagination following the Relay specification. This allows callers
to efficiently iterate through large result sets.

Changes:
- Add `PageInfo` type to `Response` for GraphQL cursor pagination metadata
- Add `connectionGQL[T]` generic type for unmarshaling paginated responses
- Replace templated queries with static query using nullable variables
- Add `WithNext()` function to handle all pagination styles uniformly
- Update `Scan2()` to use `WithNext()` for automatic pagination
- Return `Response` with pagination info from `ListWorkItems`
- Remove unused `gqlVariable` types and helper functions
- Add comprehensive unit tests for pagination scenarios
parent 6f472570
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -1073,6 +1073,9 @@ type Response struct {
	NextLink     string
	FirstLink    string
	LastLink     string

	// GraphQL pagination.
	PageInfo *PageInfo
}

// newResponse creates a new Response for the provided http.Response.
+34 −128
Original line number Diff line number Diff line
@@ -4,7 +4,6 @@ import (
	"encoding/json"
	"fmt"
	"net/http"
	"reflect"
	"regexp"
	"strconv"
	"strings"
@@ -104,133 +103,6 @@ func (g *GraphQL) Do(query GraphQLQuery, response any, options ...RequestOptionF
	return resp, nil
}

type variableGQL struct {
	Name  string
	Type  string
	Value any
}

func (v variableGQL) definition() string {
	return fmt.Sprintf("$%s: %s", v.Name, v.Type)
}

func (v variableGQL) argument() string {
	return fmt.Sprintf("%s: $%s", v.Name, v.Name)
}

type variablesGQL []variableGQL

func (vs variablesGQL) asMap(base map[string]any) map[string]any {
	if base == nil {
		base = make(map[string]any)
	}

	for _, f := range vs {
		base[f.Name] = f.Value
	}

	return base
}

// Definitions generates the GraphQL query variable declarations for use in a query definition.
// It returns a comma-separated string of parameter declarations in the format "$name: Type".
// For example, if fieldsGQL contains fields with names "state" and "authorUsername" with types
// "IssuableState" and "String", it returns: "$state: IssuableState, $authorUsername: String".
// This is typically used in the query signature section of a GraphQL query.
func (vs variablesGQL) Definitions() string {
	defs := make([]string, len(vs))

	for i, v := range vs {
		defs[i] = v.definition()
	}

	return strings.Join(defs, ", ")
}

// Arguments generates the GraphQL argument assignments for use in a query body.
// It returns a comma-separated string of argument assignments in the format "name: $name".
// For example, if fieldsGQL contains fields with names "state" and "authorUsername", it returns:
// "state: $state, authorUsername: $authorUsername".
// This is typically used when passing variables to a GraphQL field or connection.
func (vs variablesGQL) Arguments() string {
	args := make([]string, len(vs))

	for i, v := range vs {
		args[i] = v.argument()
	}

	return strings.Join(args, ", ")
}

// gqlVariables extracts GraphQL variable definitions from a struct's fields.
// It accepts a pointer to a struct where each field is annotated with a `gql:"name type"` tag.
// The tag specifies the GraphQL variable name and type (e.g., `gql:"state IssuableState"`).
//
// Fields can be excluded using `gql:"-"`. Only non-zero fields are included in the result.
//
// Returns a variablesGQL slice containing the variable name, GraphQL type, and value for each field.
// This can be used to generate both variable definitions (for query signatures) and variable
// arguments (for field parameters) in GraphQL queries.
//
// Returns an error if:
//   - s is not a pointer to a struct
//   - any field is missing a `gql` tag
//   - a `gql` tag has invalid format (must be "name type", except those tagged with "-")
//
// Example:
//
//	type Options struct {
//	    State  *string `gql:"state IssuableState"`
//	    Author *string `gql:"authorUsername String"`
//	}
//	fields, err := gqlVariables(&Options{State: Ptr("opened")})
//	// Returns: [{Name: "state", Type: "IssuableState", Value: "opened"}]
func gqlVariables(s any) (variablesGQL, error) {
	if s == nil {
		return nil, nil
	}

	structValue := reflect.ValueOf(s)
	if structValue.Kind() != reflect.Ptr || structValue.Elem().Kind() != reflect.Struct {
		return nil, fmt.Errorf("expected a pointer to a struct, got %T", s)
	}

	structValue = structValue.Elem() // Dereference the pointer to get the struct value
	structType := structValue.Type()

	var fields variablesGQL

	for i := range structType.NumField() {
		field := structType.Field(i)
		gqlTag := field.Tag.Get("gql")

		switch gqlTag {
		case "":
			return nil, fmt.Errorf("field %s.%s is missing a 'gql' tag", structType.Name(), field.Name)
		case "-":
			continue
		}

		name, typ, ok := strings.Cut(gqlTag, " ")
		if !ok {
			return nil, fmt.Errorf("invalid 'gql' tag format for field %s.%s: got %q, want \"name type\"", structType.Name(), field.Name, gqlTag)
		}

		fieldValue := structValue.Field(i)
		if fieldValue.IsZero() {
			continue
		}

		fields = append(fields, variableGQL{
			Name:  name,
			Type:  typ,
			Value: fieldValue.Interface(),
		})
	}

	return fields, nil
}

// gidGQL is a global ID. It is used by GraphQL to uniquely identify resources.
type gidGQL struct {
	Type  string
@@ -283,3 +155,37 @@ func (id *iidGQL) UnmarshalJSON(b []byte) error {
	*id = iidGQL(i)
	return nil
}

// PageInfo contains cursor-based pagination metadata for GraphQL connections following the Relay
// cursor pagination specification. Use EndCursor and HasNextPage for forward pagination
// (most common), or StartCursor and HasPreviousPage for backward pagination.
//
// Cursors are opaque strings that should not be parsed or constructed manually - always
// use the cursors returned by the API.
//
// Note: GraphQL cursor pagination differs from GitLab's REST API keyset pagination.
// In REST, the pagination link points to the first item of the next page. In GraphQL,
// EndCursor points to the last item of the current page - you pass this to the "after"
// parameter to fetch items after it (essentially an off-by-one difference in semantics).
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#pageinfo
type PageInfo struct {
	EndCursor       string `json:"endCursor"`       // Cursor of the last item in this page (pass to "after" for next page)
	HasNextPage     bool   `json:"hasNextPage"`     // True if more items exist after this page
	StartCursor     string `json:"startCursor"`     // Cursor of the first item in this page (pass to "before" for previous page)
	HasPreviousPage bool   `json:"hasPreviousPage"` // True if items exist before this page
}

// connectionGQL represents a paginated GraphQL connection response following the Relay
// cursor pagination specification. It wraps a list of nodes of any type T along with
// pagination metadata. This type is used internally to unmarshal GraphQL responses from
// GitLab's API, which consistently uses this connection pattern for all paginated fields.
//
// The PageInfo field provides cursors and flags for iterating through pages, while Nodes
// contains the actual data items for the current page.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#connection-fields
type connectionGQL[T any] struct {
	PageInfo PageInfo `json:"pageInfo"`
	Nodes    []T      `json:"nodes"`
}
+2 −6
Original line number Diff line number Diff line
@@ -119,12 +119,8 @@ func Scan2[T any](f func(p PaginationOptionFunc) ([]T, *Response, error)) iter.S
			// the f request function was either configured for offset- or keyset-based
			// pagination. We support both here, by checking if the next link is provided (keyset)
			// or not. If both are provided, keyset-based pagination takes precedence.
			switch {
			case resp.NextLink != "":
				nextOpt = WithKeysetPaginationParameters(resp.NextLink)
			case resp.NextPage != 0:
				nextOpt = WithOffsetPaginationParameters(resp.NextPage)
			default:
			nextOpt = WithNext(resp)
			if nextOpt == nil {
				// no more pages
				break Pagination
			}
+69 −0
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ package gitlab

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strconv"

@@ -100,6 +102,73 @@ func WithOffsetPaginationParameters(page int64) RequestOptionFunc {
	}
}

// withGraphQLPaginationParamters takes a PageInfo from a GraphQL response and
// modifies the request to use that cursor for GraphQL pagination, overriding
// any existing "after" variable.
//
// GraphQL API docs:
// https://docs.gitlab.com/development/graphql_guide/pagination/
func withGraphQLPaginationParamters(pi PageInfo) RequestOptionFunc {
	if !pi.HasNextPage {
		return nil
	}

	return func(req *retryablehttp.Request) error {
		var q GraphQLQuery

		data, err := req.BodyBytes()
		if err != nil {
			return fmt.Errorf("reading request body failed: %w", err)
		}

		if err := json.Unmarshal(data, &q); err != nil {
			return fmt.Errorf("decoding request body failed: %w", err)
		}

		if q.Variables == nil {
			q.Variables = make(map[string]any)
		}

		q.Variables["after"] = pi.EndCursor

		data, err = json.Marshal(q)
		if err != nil {
			return fmt.Errorf("encoding request body failed: %w", err)
		}

		return req.SetBody(data)
	}
}

// WithNext returns a RequestOptionFunc that configures the next page of a paginated
// request based on pagination metadata from a previous response. It automatically
// detects and handles all three pagination styles used by GitLab's APIs:
//
//   - GraphQL cursor pagination: Uses PageInfo.EndCursor with the "after" variable
//   - REST keyset pagination: Extracts parameters from the "next" link header
//   - REST offset pagination: Uses the NextPage number with "page" parameter
//
// If multiple pagination styles are present in the response, keyset/cursor pagination
// is preferred over offset pagination for better performance and consistency.
//
// Returns nil if the response indicates there are no more pages (HasNextPage=false,
// no NextLink, or NextPage=0).
func WithNext(resp *Response) RequestOptionFunc {
	switch {
	case resp.PageInfo != nil:
		return withGraphQLPaginationParamters(*resp.PageInfo)

	case resp.NextLink != "":
		return WithKeysetPaginationParameters(resp.NextLink)

	case resp.NextPage != 0:
		return WithOffsetPaginationParameters(resp.NextPage)

	default:
		return nil
	}
}

// WithSudo takes either a username or user ID and sets the Sudo request header.
func WithSudo(uid any) RequestOptionFunc {
	return func(req *retryablehttp.Request) error {
+33 −0
Original line number Diff line number Diff line
@@ -460,3 +460,36 @@ func ExampleWithRequestRetry_createMergeRequestAndSetAutoMerge() {
		log.Fatal(err)
	}
}

func ExampleWithNext() {
	ctx := context.Background()

	client, err := NewClient("yourtokengoeshere")
	if err != nil {
		log.Fatal(err)
	}

	var (
		projectName = "example/example"
		opts        = ListProjectMergeRequestsOptions{
			AuthorUsername: Ptr("me"),
		}
		page RequestOptionFunc
	)

	for {
		mrs, resp, err := client.MergeRequests.ListProjectMergeRequests(projectName, &opts, WithContext(ctx), page)
		if err != nil {
			log.Fatal(err)
		}

		// Process mrs...
		_ = mrs

		page = WithNext(resp)
		if page == nil {
			// No more pages, break the loop
			break
		}
	}
}
Loading