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
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ require (
github.com/google/go-github/v57 v57.0.0
golang.org/x/oauth2 v0.15.0
)

require (
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
golang.org/x/net v0.19.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
29 changes: 29 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs=
github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
22 changes: 16 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (
"os"
"time"

"github.com/alisteuber4ee1/API-Integration-Pagination/pkg/github"
google_github "github.com/google/go-github/v57/github"
ghclient "github.com/alisteuber4ee1/API-Integration-Pagination/pkg/github"
"github.com/google/go-github/v57/github"
"golang.org/x/oauth2"
)

func main() {
Expand All @@ -20,12 +21,21 @@ func main() {
return
}

client := google_github.NewClient(nil)
fetcher := github.NewIssueFetcher(client)
// Use an authenticated client to avoid hitting the unauthenticated
// rate limit (60 req/h) and to access private repositories.
ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)
client := github.NewClient(tc)

fetcher := ghclient.NewIssueFetcher(client)
since := time.Now().Add(-7 * 24 * time.Hour)
issues, err := fetcher.FetchIssues(context.Background(), "google", "go-github", since, 10)

issues, err := fetcher.FetchIssues(ctx, "google", "go-github", since, 10)
if err != nil {
log.Fatalf("Error: %v", err)
}
fmt.Printf("Fetched %d issues from google/go-github\n", len(issues))

fmt.Printf("Fetched %d issues from google/go-github (since %s)\n",
len(issues), since.Format(time.RFC3339))
}
67 changes: 60 additions & 7 deletions pkg/github/client.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package github provides a high-level client for fetching GitHub issues
// with correct cursor-based pagination using response Link headers.
package github

import (
Expand All @@ -8,36 +10,87 @@ import (
"github.com/google/go-github/v57/github"
)

// IssueFetcher handles fetching issues from GitHub.
// maxPages is a safety bound to prevent infinite pagination loops even if
// the API returns a non-zero NextPage indefinitely (e.g. due to a bug or
// API change). 1 000 pages Γ— 100 per-page = 100 000 issues β€” well beyond
// any reasonable repository.
const maxPages = 1000

// IssueFetcher handles fetching issues from GitHub with automatic
// pagination driven by the response Link header.
type IssueFetcher struct {
client *github.Client
}

// NewIssueFetcher creates a new IssueFetcher.
// NewIssueFetcher creates a new IssueFetcher wrapping the given GitHub client.
func NewIssueFetcher(client *github.Client) *IssueFetcher {
return &IssueFetcher{client: client}
}

// FetchIssues retrieves all issues for a repository updated since a specific time.
// FetchIssues retrieves every issue in owner/repo that was updated on or after
// `since`. It paginates through all result pages using the NextPage value
// from the GitHub API response rather than relying on the returned slice
// length β€” which can be less than PerPage even when more pages exist, e.g.
// when the `since` filter is applied server-side.
//
// The perPage argument controls how many issues to request per API call
// (max 100 per GitHub docs). Passing <= 0 defaults to 30 (GitHub's default).
func (f *IssueFetcher) FetchIssues(ctx context.Context, owner, repo string, since time.Time, perPage int) ([]*github.Issue, error) {
if perPage <= 0 {
perPage = 30
}

opt := &github.IssueListByRepoOptions{
Since: since,
ListOptions: github.ListOptions{
PerPage: perPage,
},
}

seenIDs := make(map[int64]bool)
var allIssues []*github.Issue
for {
issues, resp, err := f.client.Issues.ListByRepository(ctx, owner, repo, opt)
for page := 0; page < maxPages; page++ {
// Bail out early if the caller cancelled the context. This avoids
// unnecessary API calls when a timeout or deadline has passed.
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("context cancelled before page %d: %w", opt.Page, err)
}

// BUG FIX: The original code called ListByRepository, which does not
// exist in go-github v57. The correct method is ListByRepo.
// See: https://pkg.go.dev/github.com/google/go-github/v57/github#IssuesService.ListByRepo
issues, resp, err := f.client.Issues.ListByRepo(ctx, owner, repo, opt)
if err != nil {
return nil, fmt.Errorf("failed to list issues: %w", err)
return nil, fmt.Errorf("failed to list issues (page %d): %w", opt.Page, err)
}
allIssues = append(allIssues, issues...)

// BUG FIX: De-duplicate issues to ensure no duplicate issues are returned
// to the caller, satisfying acceptance criteria #5. This handles cases
// where concurrent updates on GitHub might shift issues across pages.
for _, issue := range issues {
if issue == nil {
continue
}
id := issue.GetID()
if !seenIDs[id] {
seenIDs[id] = true
allIssues = append(allIssues, issue)
}
}

// BUG FIX: Use the NextPage value from the response Link header to
// determine whether more pages exist. Do NOT check len(issues) < perPage
// because the GitHub API may return fewer items than PerPage even when
// additional pages are available (particularly with the `since` filter).
if resp.NextPage == 0 {
break
}

// BUG FIX: Assign resp.NextPage directly instead of incrementing
// opt.Page manually. Manual increments can desynchronize with the
// server's cursor when filters cause pages to shift.
opt.Page = resp.NextPage
}

return allIssues, nil
}
Loading