diff --git a/example/iterpagination/main.go b/example/iterpagination/main.go new file mode 100644 index 00000000000..a026ce1865f --- /dev/null +++ b/example/iterpagination/main.go @@ -0,0 +1,60 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// iterpagination is an example of how to use pagination with Go-native iterators. +// It's runnable with the following command: +// +// export GITHUB_AUTH_TOKEN=your_token +// export GITHUB_REPOSITORY_OWNER=your_owner +// export GITHUB_REPOSITORY_NAME=your_repo +// export GITHUB_REPOSITORY_ISSUE=your_issue +// go run . +package main + +import ( + "cmp" + "context" + "fmt" + "log" + "os" + "strconv" + + "github.com/google/go-github/v81/github" +) + +func main() { + token := os.Getenv("GITHUB_AUTH_TOKEN") + if token == "" { + log.Fatal("Unauthorized: No token present") + } + owner := cmp.Or(os.Getenv("GITHUB_REPOSITORY_OWNER"), "google") + repo := cmp.Or(os.Getenv("GITHUB_REPOSITORY_NAME"), "go-github") + issue, _ := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ISSUE")) + issue = cmp.Or(issue, 2618) + + ctx := context.Background() + client := github.NewClient(nil).WithAuthToken(token) + + opts := github.IssueListCommentsOptions{ + Sort: github.Ptr("created"), + ListOptions: github.ListOptions{ + Page: 1, + PerPage: 5, + }, + } + + fmt.Println("Listing comments for issue", issue, "in repository", owner+"/"+repo) + + scannedOpts := opts + for c := range github.MustIter(github.Scan2(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(ctx, owner, repo, issue, &scannedOpts, p) + })) { + body := c.GetBody() + if len(body) > 50 { + body = body[:50] + } + fmt.Printf("Comment: %q\n", body) + } +} diff --git a/github/examples_pagination_test.go b/github/examples_pagination_test.go new file mode 100644 index 00000000000..0e86a15ab77 --- /dev/null +++ b/github/examples_pagination_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github_test + +import ( + "context" + "fmt" + "log" + "slices" + + "github.com/google/go-github/v81/github" +) + +func ExampleIssuesService_ListComments_offset_pagination_scan() { + client := github.NewClient(nil) + ctx := context.Background() + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + + it, hasErr := github.Scan(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(ctx, "google", "go-github", 526, opts, p) + }) + + comments := slices.Collect(it) + if err := hasErr(); err != nil { + log.Fatalf("Scan iterator returned error: %v", err) + } + + fmt.Println("Total comments:", len(comments)) +} + +func ExampleIssuesService_ListComments_offset_pagination_scan2() { + client := github.NewClient(nil) + ctx := context.Background() + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + + var comments []*github.IssueComment + for c, err := range github.Scan2(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(ctx, "google", "go-github", 526, opts, p) + }) { + if err != nil { + log.Fatalf("Scan2 iterator returned error: %v", err) + } + comments = append(comments, c) + } + + fmt.Println("Total comments:", len(comments)) +} + +func ExampleIssuesService_ListComments_offset_pagination_scanAndCollect() { + client := github.NewClient(nil) + ctx := context.Background() + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + + comments, err := github.ScanAndCollect(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(ctx, "google", "go-github", 526, opts, p) + }) + if err != nil { + log.Fatalf("ScanAndCollect returned error: %v", err) + } + + fmt.Println("Total comments:", len(comments)) +} + +func ExampleIssuesService_ListComments_offset_pagination_scan2MustIter() { + client := github.NewClient(nil) + ctx := context.Background() + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + + var comments []*github.IssueComment + for c := range github.MustIter(github.Scan2(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(ctx, "google", "go-github", 526, opts, p) + })) { + comments = append(comments, c) + } + + fmt.Println("Total comments:", len(comments)) +} + +func ExampleSecurityAdvisoriesService_ListRepositorySecurityAdvisoriesForOrg_after_pagination_scan2MustIter() { + client := github.NewClient(nil) + ctx := context.Background() + + opts := &github.ListRepositorySecurityAdvisoriesOptions{ + ListCursorOptions: github.ListCursorOptions{ + PerPage: 1, + }, + } + + var advisories []*github.SecurityAdvisory + for a := range github.MustIter(github.Scan2(func(p github.PaginationOption) ([]*github.SecurityAdvisory, *github.Response, error) { + return client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, "alexandear-org", opts, p) + })) { + advisories = append(advisories, a) + } + + fmt.Println("Total advisories:", len(advisories)) +} diff --git a/github/github.go b/github/github.go index 9ab1fb11d7a..8567cc8434d 100644 --- a/github/github.go +++ b/github/github.go @@ -540,6 +540,24 @@ func WithVersion(version string) RequestOption { } } +// WithOffsetPagination adds page query parameter to the request. +func WithOffsetPagination(page int) RequestOption { + return func(req *http.Request) { + q := req.URL.Query() + q.Set("page", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + } +} + +// WithAfterPagination adds cursor pagination parameters to the request. +func WithAfterPagination(after string) RequestOption { + return func(req *http.Request) { + q := req.URL.Query() + q.Set("after", after) + req.URL.RawQuery = q.Encode() + } +} + // NewRequest creates an API request. A relative URL can be provided in urlStr, // in which case it is resolved relative to the BaseURL of the Client. // Relative URLs should always be specified without a preceding slash. If @@ -581,7 +599,9 @@ func (c *Client) NewRequest(method, urlStr string, body any, opts ...RequestOpti req.Header.Set(headerAPIVersion, defaultAPIVersion) for _, opt := range opts { - opt(req) + if opt != nil { + opt(req) + } } return req, nil diff --git a/github/issues_comments.go b/github/issues_comments.go index ef5314b188d..c16aba56515 100644 --- a/github/issues_comments.go +++ b/github/issues_comments.go @@ -60,7 +60,7 @@ type IssueListCommentsOptions struct { // //meta:operation GET /repos/{owner}/{repo}/issues/comments //meta:operation GET /repos/{owner}/{repo}/issues/{issue_number}/comments -func (s *IssuesService) ListComments(ctx context.Context, owner, repo string, number int, opts *IssueListCommentsOptions) ([]*IssueComment, *Response, error) { +func (s *IssuesService) ListComments(ctx context.Context, owner, repo string, number int, opts *IssueListCommentsOptions, reqOpts ...RequestOption) ([]*IssueComment, *Response, error) { var u string if number == 0 { u = fmt.Sprintf("repos/%v/%v/issues/comments", owner, repo) @@ -72,7 +72,7 @@ func (s *IssuesService) ListComments(ctx context.Context, owner, repo string, nu return nil, nil, err } - req, err := s.client.NewRequest("GET", u, nil) + req, err := s.client.NewRequest("GET", u, nil, reqOpts...) if err != nil { return nil, nil, err } diff --git a/github/pagination.go b/github/pagination.go new file mode 100644 index 00000000000..05c6a9bbd45 --- /dev/null +++ b/github/pagination.go @@ -0,0 +1,106 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "iter" + "slices" +) + +// PaginationOption represents a pagination option for requests. +type PaginationOption = RequestOption + +// Scan scans all pages for the given request function f and returns individual items in an iterator. +// If an error happens during pagination, the iterator stops immediately. +// The caller must consume the returned error function to retrieve potential errors. +func Scan[T any](f func(PaginationOption) ([]T, *Response, error)) (iter.Seq[T], func() error) { + exhausted := false + var e error + it := func(yield func(T) bool) { + defer func() { + exhausted = true + }() + for t, err := range Scan2(f) { + if err != nil { + e = err + return + } + + if !yield(t) { + return + } + } + } + hasErr := func() error { + if !exhausted { + panic("called error function of Scan iterator before iterator was exhausted") + } + return e + } + return it, hasErr +} + +// Scan2 scans all pages for the given request function f and returns individual items and potential errors in an iterator. +// The caller must consume the error element of the iterator during each iteration +// to ensure that no errors happened. +func Scan2[T any](f func(PaginationOption) ([]T, *Response, error)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + var nextOpt PaginationOption + + Pagination: + for { + ts, resp, err := f(nextOpt) + if err != nil { + var t T + yield(t, err) + return + } + + for _, t := range ts { + if !yield(t, nil) { + return + } + } + + // the f request function was either configured for offset- or cursor-based pagination. + switch { + case resp.NextPage != 0: + nextOpt = WithOffsetPagination(resp.NextPage) + case resp.After != "": + nextOpt = WithAfterPagination(resp.After) + default: + // no more pages + break Pagination + } + } + } +} + +// MustIter provides a single item iterator for the provided two item iterator and panics if an error happens. +func MustIter[T any](it iter.Seq2[T, error]) iter.Seq[T] { + return func(yield func(T) bool) { + for x, err := range it { + if err != nil { + panic(fmt.Errorf("iterator produced an error: %w", err)) + } + + if !yield(x) { + return + } + } + } +} + +// ScanAndCollect is a convenience function that collects all results and returns them as slice as well as an error if one happens. +func ScanAndCollect[T any](f func(p PaginationOption) ([]T, *Response, error)) ([]T, error) { + it, hasErr := Scan(f) + allItems := slices.Collect(it) + if err := hasErr(); err != nil { + return nil, err + } + return allItems, nil +} diff --git a/github/pagination_test.go b/github/pagination_test.go new file mode 100644 index 00000000000..e274c1e50d3 --- /dev/null +++ b/github/pagination_test.go @@ -0,0 +1,445 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "errors" + "slices" + "testing" +) + +func TestScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pages [][]int + responses []*Response + wantItems []int + wantErr error + callErrBefore bool // call error function before exhausting iterator + }{ + { + name: "single page", + pages: [][]int{ + {1, 2, 3}, + }, + responses: []*Response{ + {NextPage: 0}, + }, + wantItems: []int{1, 2, 3}, + }, + { + name: "multiple pages with offset pagination", + pages: [][]int{ + {1, 2}, + {3, 4}, + {5}, + }, + responses: []*Response{ + {NextPage: 2}, + {NextPage: 3}, + {NextPage: 0}, + }, + wantItems: []int{1, 2, 3, 4, 5}, + }, + { + name: "multiple pages with cursor pagination", + pages: [][]int{ + {1, 2}, + {3, 4}, + {5}, + }, + responses: []*Response{ + {After: "cursor1"}, + {After: "cursor2"}, + {After: ""}, + }, + wantItems: []int{1, 2, 3, 4, 5}, + }, + { + name: "error on first page", + pages: [][]int{ + nil, + }, + responses: []*Response{ + nil, + }, + wantErr: errors.New("request failed"), + }, + { + name: "error on second page", + pages: [][]int{ + {1, 2}, + nil, + }, + responses: []*Response{ + {NextPage: 2}, + nil, + }, + wantErr: errors.New("request failed"), + }, + { + name: "error function called before iterator exhausted", + callErrBefore: true, + }, + { + name: "empty pages", + pages: [][]int{ + {}, + }, + responses: []*Response{ + {NextPage: 0}, + }, + wantItems: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.callErrBefore { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic but got none") + } + }() + } + + pageIdx := 0 + f := func(PaginationOption) ([]int, *Response, error) { + if pageIdx >= len(tt.pages) { + t.Fatal("unexpected pagination call") + } + if tt.wantErr != nil && pageIdx == len(tt.pages)-1 { + pageIdx++ + return nil, nil, tt.wantErr + } + page := tt.pages[pageIdx] + resp := tt.responses[pageIdx] + pageIdx++ + return page, resp, nil + } + + it, hasErr := Scan(f) + + if tt.callErrBefore { + _ = hasErr() // should panic + return + } + + got := slices.Collect(it) + err := hasErr() + + if tt.wantErr != nil { + if err == nil { + t.Errorf("want error %v, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr.Error() { + t.Errorf("want error %v, got %v", tt.wantErr, err) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + want := tt.wantItems + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestScan2(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pages [][]string + responses []*Response + wantItems []string + wantErr error + }{ + { + name: "single page", + pages: [][]string{ + {"a", "b", "c"}, + }, + responses: []*Response{ + {NextPage: 0}, + }, + wantItems: []string{"a", "b", "c"}, + }, + { + name: "multiple pages with offset pagination", + pages: [][]string{ + {"a", "b"}, + {"c", "d"}, + {"e"}, + }, + responses: []*Response{ + {NextPage: 2}, + {NextPage: 3}, + {NextPage: 0}, + }, + wantItems: []string{"a", "b", "c", "d", "e"}, + }, + { + name: "error on first page", + pages: [][]string{ + nil, + }, + responses: []*Response{ + nil, + }, + wantErr: errors.New("api error"), + }, + { + name: "error on second page", + pages: [][]string{ + {"a", "b"}, + nil, + }, + responses: []*Response{ + {NextPage: 2}, + nil, + }, + wantErr: errors.New("api error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pageIdx := 0 + f := func(PaginationOption) ([]string, *Response, error) { + if pageIdx >= len(tt.pages) { + t.Fatal("unexpected pagination call") + } + if tt.wantErr != nil && pageIdx == len(tt.pages)-1 { + pageIdx++ + return nil, nil, tt.wantErr + } + page := tt.pages[pageIdx] + resp := tt.responses[pageIdx] + pageIdx++ + return page, resp, nil + } + + var got []string + var err error + + for item, itemErr := range Scan2(f) { + if itemErr != nil { + err = itemErr + break + } + got = append(got, item) + } + + if tt.wantErr != nil { + if err == nil { + t.Errorf("want error %v, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr.Error() { + t.Errorf("want error %v, got %v", tt.wantErr, err) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + want := tt.wantItems + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestMustIter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []int + errorAt int // position to error at, -1 for no error + wantItems []int + wantPanic bool + }{ + { + name: "no error", + items: []int{1, 2, 3}, + errorAt: -1, + wantItems: []int{1, 2, 3}, + }, + { + name: "error on first item", + items: []int{1, 2, 3}, + errorAt: 0, + wantPanic: true, + }, + { + name: "error on second item", + items: []int{1, 2, 3}, + errorAt: 1, + wantPanic: true, + }, + { + name: "empty iterator", + items: []int{}, + errorAt: -1, + wantItems: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.wantPanic { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic but got none") + } + }() + } + + // Create a Seq2 iterator that yields items with errors at specified position + it := func(yield func(int, error) bool) { + for i, item := range tt.items { + if i == tt.errorAt { + yield(item, errors.New("test error")) + return + } + if !yield(item, nil) { + return + } + } + } + + var got []int + for item := range MustIter(it) { + got = append(got, item) + } + + if tt.wantPanic { + return + } + + want := tt.wantItems + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestScanAndCollect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pages [][]float64 + responses []*Response + wantItems []float64 + wantErr error + }{ + { + name: "single page", + pages: [][]float64{ + {1.5, 2.5, 3.5}, + }, + responses: []*Response{ + {NextPage: 0}, + }, + wantItems: []float64{1.5, 2.5, 3.5}, + }, + { + name: "multiple pages", + pages: [][]float64{ + {1.1, 2.2}, + {3.3, 4.4}, + }, + responses: []*Response{ + {NextPage: 2}, + {NextPage: 0}, + }, + wantItems: []float64{1.1, 2.2, 3.3, 4.4}, + }, + { + name: "error", + pages: [][]float64{ + {1.1}, + nil, + }, + responses: []*Response{ + {NextPage: 2}, + nil, + }, + wantErr: errors.New("collection failed"), + }, + { + name: "empty result", + pages: [][]float64{ + {}, + }, + responses: []*Response{ + {NextPage: 0}, + }, + wantItems: []float64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pageIdx := 0 + f := func(PaginationOption) ([]float64, *Response, error) { + if pageIdx >= len(tt.pages) { + t.Fatal("unexpected pagination call") + } + if tt.wantErr != nil && pageIdx == len(tt.pages)-1 { + pageIdx++ + return nil, nil, tt.wantErr + } + page := tt.pages[pageIdx] + resp := tt.responses[pageIdx] + pageIdx++ + return page, resp, nil + } + + got, err := ScanAndCollect(f) + + if tt.wantErr != nil { + if err == nil { + t.Errorf("want error %v, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr.Error() { + t.Errorf("want error %v, got %v", tt.wantErr, err) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + want := tt.wantItems + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} diff --git a/github/security_advisories.go b/github/security_advisories.go index 5c7b8fd4e3c..a9477a64ed8 100644 --- a/github/security_advisories.go +++ b/github/security_advisories.go @@ -190,14 +190,14 @@ func (s *SecurityAdvisoriesService) CreateTemporaryPrivateFork(ctx context.Conte // GitHub API docs: https://docs.github.com/rest/security-advisories/repository-advisories#list-repository-security-advisories-for-an-organization // //meta:operation GET /orgs/{org}/security-advisories -func (s *SecurityAdvisoriesService) ListRepositorySecurityAdvisoriesForOrg(ctx context.Context, org string, opts *ListRepositorySecurityAdvisoriesOptions) ([]*SecurityAdvisory, *Response, error) { +func (s *SecurityAdvisoriesService) ListRepositorySecurityAdvisoriesForOrg(ctx context.Context, org string, opts *ListRepositorySecurityAdvisoriesOptions, reqOpts ...RequestOption) ([]*SecurityAdvisory, *Response, error) { url := fmt.Sprintf("orgs/%v/security-advisories", org) url, err := addOptions(url, opts) if err != nil { return nil, nil, err } - req, err := s.client.NewRequest("GET", url, nil) + req, err := s.client.NewRequest("GET", url, nil, reqOpts...) if err != nil { return nil, nil, err } diff --git a/test/integration/pagination_test.go b/test/integration/pagination_test.go new file mode 100644 index 00000000000..3fd738547ea --- /dev/null +++ b/test/integration/pagination_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build integration + +package integration + +import ( + "testing" + + "github.com/google/go-github/v81/github" +) + +func TestScan2_Offset(t *testing.T) { + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + var comments []*github.IssueComment + for c, err := range github.Scan2(func(p github.PaginationOption) ([]*github.IssueComment, *github.Response, error) { + return client.Issues.ListComments(t.Context(), "google", "go-github", 526, opts, p) + }) { + if err != nil { + t.Fatalf("Offset scan2 iterator returned error: %v", err) + } + comments = append(comments, c) + } + + if got, want := len(comments), 16; got != want { + t.Fatalf("Offset scan2 iterator returned %v comments, want %v", got, want) + } + + if got, want := comments[0].GetID(), int64(274246625); got != want { + t.Fatalf("Offset scan2 iterator returned first comment ID %v, want %v", got, want) + } +} diff --git a/tools/sliceofpointers/sliceofpointers.go b/tools/sliceofpointers/sliceofpointers.go index 75b14d068dc..fc562c2f4c9 100644 --- a/tools/sliceofpointers/sliceofpointers.go +++ b/tools/sliceofpointers/sliceofpointers.go @@ -70,8 +70,10 @@ func checkArrayType(arrType *ast.ArrayType, tokenPos token.Pos, pass *analysis.P pass.Reportf(tokenPos, msg) } } else if ident, ok := arrType.Elt.(*ast.Ident); ok && ident.Obj != nil { - if _, ok := ident.Obj.Decl.(*ast.TypeSpec).Type.(*ast.StructType); ok { - pass.Reportf(tokenPos, "use []*%v instead of []%[1]v", ident.Name) + if typeSpec, ok := ident.Obj.Decl.(*ast.TypeSpec); ok { + if _, ok := typeSpec.Type.(*ast.StructType); ok { + pass.Reportf(tokenPos, "use []*%v instead of []%[1]v", ident.Name) + } } } }