The go-github library, maintained by Google, provides idiomatic Go bindings for the GitHub REST API. Rather than hand-crafting HTTP requests and manually deserializing JSON, go-github exposes strongly typed service clients—one per GitHub resource category—that handle authentication, request construction, response parsing, and error reporting in a consistent way. The result is code that reads like ordinary Go rather than HTTP boilerplate, while still giving you precise control over every API parameter.
GitHub’s Issues API covers far more than the bugs and feature requests you see in a browser. Each issue carries a rich data model: labels for categorization, assignees for ownership, milestones for tracking progress, comments for discussion, reactions, and cross-reference events that tie issues to pull requests, commits, and other repositories. For applications that aggregate, analyze, or index GitHub activity—such as a semantic search project, which ingests issue content for semantic search—this breadth of data means a single issue fetch can provide all the signal needed to build a rich, searchable document.
Working with the GitHub API at scale requires understanding two additional concerns: pagination and rate limiting. The REST API returns at most 100 items per request, so any query that might return more requires iterating through pages using ListOptions. GitHub enforces per-hour and per-minute rate limits on both authenticated and unauthenticated requests; authenticated requests get significantly higher limits, making token-based authentication a practical necessity for any non-trivial integration. The go-github library surfaces rate limit information in every response, making it straightforward to implement respectful, adaptive clients.
A github.Client is created from an HTTP client that handles authentication. The standard pattern wraps a Personal Access Token (PAT) or GitHub App token using the golang.org/x/oauth2 package to produce a token source, then passes that to github.NewClient. The resulting client is safe for concurrent use and reuse across requests. For GitHub Enterprise deployments, github.NewEnterpriseClient accepts custom base and upload URLs.
The IssuesService.ListByRepo method accepts an IssueListByRepoOptions struct that controls which issues are returned. You can filter by state (open, closed, or all), label names, milestone, assignee, creator, and the time range using Since. Results are sorted by creation date, update date, or comment count, in ascending or descending order. These filters map directly to the GitHub API query parameters, so what you see in the GitHub UI can be replicated exactly in code.
Every list method in go-github accepts a github.ListOptions embedded in the method-specific options struct. Setting PerPage (max 100) and Page controls which slice of results is returned. The response always includes a *github.Response whose NextPage field is non-zero when more pages exist and zero on the final page. The standard loop pattern checks resp.NextPage after each call and updates opts.Page until it reaches zero.
Labels and comments are separate resources addressable by issue number. IssuesService.ListLabelsByIssue returns the labels attached to a specific issue; IssuesService.ListComments returns the comments with their authors, bodies, and timestamps. Both support their own pagination. Labels can be added, replaced, or removed in bulk via AddLabelsToIssue and ReplaceLabelsForIssue—useful for automated triage workflows. Comments are created with CreateComment and accept Markdown bodies.
Unauthenticated requests are limited to 60 per hour; authenticated requests to 5,000 per hour for the core API. Every *github.Response contains a Rate field with the current limit, remaining count, and reset time. When Remaining drops to zero, the next request will fail with a *github.RateLimitError, which includes the reset time so callers can sleep until the window resets. The go-github library also handles GitHub’s secondary rate limits (abuse detection) by returning *github.AbuseRateLimitError with a RetryAfter duration.
The go-github client is constructed once and shared. The oauth2 token source automatically attaches the Authorization: Bearer <token> header to every request, so individual callers never need to think about authentication after setup.
package github
import (
"context"
"os"
"github.com/google/go-github/v60/github"
"golang.org/x/oauth2"
)
func NewClient(ctx context.Context) *github.Client {
token := os.Getenv("GITHUB_TOKEN")
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
tc := oauth2.NewClient(ctx, ts)
return github.NewClient(tc)
}
// For GitHub Enterprise:
func NewEnterpriseClient(ctx context.Context, baseURL string) (*github.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
tc := oauth2.NewClient(ctx, ts)
return github.NewClient(tc).WithAuthToken(token).
WithEnterpriseURLs(baseURL, baseURL)
}
The standard pagination loop runs until resp.NextPage is zero. Collecting all issues into a slice before processing is safe for repositories with hundreds of issues; for very large repositories (tens of thousands of issues), consider processing each page as it arrives to avoid unbounded memory growth.
func FetchAllIssues(
ctx context.Context,
client *github.Client,
owner, repo string,
) ([]*github.Issue, error) {
opts := &github.IssueListByRepoOptions{
State: "all",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
var allIssues []*github.Issue
for {
issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
if err != nil {
return nil, fmt.Errorf("listing issues (page %d): %w", opts.Page, err)
}
// Filter out pull requests (GitHub returns them as issues)
for _, issue := range issues {
if issue.PullRequestLinks == nil {
allIssues = append(allIssues, issue)
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allIssues, nil
}
// FetchIssuesSince fetches only issues updated after a given time,
// useful for incremental sync.
func FetchIssuesSince(
ctx context.Context,
client *github.Client,
owner, repo string,
since time.Time,
) ([]*github.Issue, error) {
opts := &github.IssueListByRepoOptions{
State: "all",
Since: since,
ListOptions: github.ListOptions{PerPage: 100},
}
var allIssues []*github.Issue
for {
issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
if err != nil {
return nil, err
}
allIssues = append(allIssues, issues...)
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allIssues, nil
}
Labels are already embedded in the *github.Issue struct returned by list calls, but fetching comments requires a separate call per issue. When ingesting content for search, comments often contain the most substantive text and are worth including in the indexed document body.
func FetchIssueComments(
ctx context.Context,
client *github.Client,
owner, repo string,
issueNumber int,
) ([]*github.IssueComment, error) {
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
var allComments []*github.IssueComment
for {
comments, resp, err := client.Issues.ListComments(
ctx, owner, repo, issueNumber, opts,
)
if err != nil {
return nil, fmt.Errorf("listing comments for issue %d: %w", issueNumber, err)
}
allComments = append(allComments, comments...)
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allComments, nil
}
// ExtractLabelNames pulls the label name strings from an issue.
func ExtractLabelNames(issue *github.Issue) []string {
names := make([]string, 0, len(issue.Labels))
for _, label := range issue.Labels {
if label.Name != nil {
names = append(names, *label.Name)
}
}
return names
}
// ExtractAssigneeLogins pulls assignee login strings from an issue.
func ExtractAssigneeLogins(issue *github.Issue) []string {
logins := make([]string, 0, len(issue.Assignees))
for _, user := range issue.Assignees {
if user.Login != nil {
logins = append(logins, *user.Login)
}
}
return logins
}
Robust clients check the rate limit in every error path and sleep until the reset window rather than retrying immediately. The go-github library surfaces both primary and secondary rate limit errors as distinct types, so a single error handler can cover both cases cleanly.
func handleRateLimit(err error) (shouldRetry bool, waitDuration time.Duration) {
var rateLimitErr *github.RateLimitError
if errors.As(err, &rateLimitErr) {
resetAt := rateLimitErr.Rate.Reset.Time
wait := time.Until(resetAt) + 5*time.Second // small buffer
return true, wait
}
var abuseErr *github.AbuseRateLimitError
if errors.As(err, &abuseErr) {
if abuseErr.RetryAfter != nil {
return true, *abuseErr.RetryAfter
}
return true, 60 * time.Second // conservative default
}
return false, 0
}
func listIssuesWithRetry(
ctx context.Context,
client *github.Client,
owner, repo string,
opts *github.IssueListByRepoOptions,
) ([]*github.Issue, *github.Response, error) {
for attempts := 0; attempts < 3; attempts++ {
issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
if err == nil {
return issues, resp, nil
}
retry, wait := handleRateLimit(err)
if !retry {
return nil, nil, err
}
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(wait):
// loop and retry
}
}
return nil, nil, fmt.Errorf("exceeded retry limit for %s/%s", owner, repo)
}
The semantic search project uses the GitHub Issues API as one of its primary data ingestion sources. When indexing a repository, the pipeline fetches all open and closed issues, combines the issue title, body, label names, and comment bodies into a single text document, then generates a vector embedding for semantic search. This allows engineers to search across historical discussions, bug reports, and feature requests using natural language queries rather than keyword matching.
The incremental sync path uses the Since filter on IssueListByRepoOptions to fetch only issues updated since the last ingestion run. The pipeline stores the last-seen update timestamp in its state store and passes it on each run, keeping the index current without re-fetching the entire repository history. Labels are stored as filterable metadata alongside the embedding, enabling post-retrieval filtering by issue category (e.g., returning only issues labeled bug or performance).
Assignee information is used to scope search results by team ownership—an engineer can restrict queries to issues assigned to their team—and to build per-engineer summaries of historical work, useful for performance reviews and onboarding. Comment authors are likewise indexed to support “find issues where Alice and Bob discussed X” queries that span conversation threads rather than just issue metadata.
All optional fields in go-github structs are pointer types (*string, *int, *time.Time). This faithfully models the JSON schema where fields may be absent, but it means every field access requires a nil check before dereferencing. The convention is to write small helpers like github.Stringify for logging and explicit nil guards for business logic. Never dereference a pointer field without checking it first—fields like issue.Body and issue.Milestone are routinely nil for issues that have no body text or milestone set.
Fetching comments for hundreds of issues sequentially is slow. The standard pattern uses a bounded worker pool: a fixed number of goroutines process a channel of issue numbers, each making the comments API call and writing results to an output channel. Keeping the pool size well below the rate limit ceiling (e.g., 5–10 concurrent workers) avoids triggering secondary rate limits while providing meaningful parallelism.
func FetchCommentsForIssues(
ctx context.Context,
client *github.Client,
owner, repo string,
issues []*github.Issue,
workers int,
) (map[int][]*github.IssueComment, error) {
type result struct {
number int
comments []*github.IssueComment
err error
}
jobs := make(chan int, len(issues))
results := make(chan result, len(issues))
for w := 0; w < workers; w++ {
go func() {
for number := range jobs {
comments, err := FetchIssueComments(ctx, client, owner, repo, number)
results <- result{number: number, comments: comments, err: err}
}
}()
}
for _, issue := range issues {
jobs <- issue.GetNumber()
}
close(jobs)
commentMap := make(map[int][]*github.IssueComment)
for range issues {
r := <-results
if r.err != nil {
return nil, r.err
}
commentMap[r.number] = r.comments
}
return commentMap, nil
}
Since go-github v60, every struct field has a generated .GetFieldName() accessor that returns the zero value when the pointer is nil rather than panicking. Prefer issue.GetTitle() over *issue.Title and issue.GetBody() over manually nil-checking issue.Body. This produces cleaner code and eliminates an entire class of nil-dereference panics.
Always pass a context.Context derived from the request or operation context into every go-github call. When the top-level context is cancelled (e.g., because a timeout fired or an orchestrating process shut down), all in-flight API calls return immediately with a context error rather than hanging until the network times out. This is especially important for pagination loops that may run for minutes on large repositories.
issue.PullRequestLinks != nil when you only want true issues, or you’ll process duplicate content and skew metrics.issue.Body, issue.Milestone, and issue.ClosedAt are nil for many real issues. Use the generated .GetFieldName() accessors or explicit nil guards—a nil dereference in a pagination loop that has already fetched thousands of issues will discard all that work.per_page to 100. Requesting 200 per page does not double your throughput; it just wastes the extra parameter and returns 100 items anyway. Always set PerPage: 100 explicitly for maximum efficiency per API call.go-github client wraps a long-lived http.Client that maintains a connection pool. Constructing a new client per operation destroys that pool, adds TLS handshake overhead to every request, and can exhaust file descriptors under load. Create one client at startup and share it across all requests.