Skip to content

Commit 984640c

Browse files
fix(github): org repo listing fails with fine-grained PATs lacking Issues:Read (#415)
Root cause: `issues { totalCount }` is typed `Int!` in GitHub's GraphQL schema. When a FGPAT lacks Issues:Read on private repos, GitHub cannot resolve that non-null field and nulls out the entire repo node — silently dropping those repos from the batch and surfacing the error as a full batch failure. Fix: move issues fetching into a dedicated `issueNodes: repositories(...)` alias within the same GraphQL query. The main `repositories` selection no longer includes `issues`, so all repos are always returned regardless of Issues:Read permission. The shurcooL/graphql library unmarshals response data before returning errors, so when issueNodes gets FORBIDDEN the main repo list is already intact. - isIssuesPermissionError() detects this partial-success case so the retry function returns nil and pagination continues normally - repoWithIssueCount carries the count matched by databaseId; repos where Issues:Read is missing get count 0 without any error or dropped data - isFatalError() + early break added so true auth failures (bad credentials, 401/403) stop pagination immediately instead of exhausting retries Minimum required FGPAT permissions for analyze_org: resource owner set to the target org + Repository > Contents: Read. Members:Read and Issues:Read are not required. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3b89252 commit 984640c

1 file changed

Lines changed: 78 additions & 7 deletions

File tree

providers/github/client.go

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,6 @@ type GithubRepository struct {
107107
License struct {
108108
Name string `graphql:"name"`
109109
} `graphql:"licenseInfo"`
110-
Issues struct {
111-
TotalCount int `graphql:"totalCount"`
112-
} `graphql:"issues"`
113110
}
114111

115112
func (gh GithubRepository) GetProviderName() string {
@@ -206,7 +203,10 @@ func (gh GithubRepository) GetStarsCount() int {
206203
}
207204

208205
func (gh GithubRepository) GetOpenIssuesCount() int {
209-
return gh.Issues.TotalCount
206+
// Issue counts are fetched via the issueNodes alias in GetOrgRepos and
207+
// carried by repoWithIssueCount, which overrides this method. This
208+
// implementation exists only to satisfy the analyze.Repository interface.
209+
return 0
210210
}
211211

212212
func (gh GithubRepository) GetIsEmpty() bool {
@@ -374,6 +374,17 @@ func (c *Client) GetOrgRepos(ctx context.Context, org string) <-chan analyze.Rep
374374
HasNextPage bool
375375
}
376376
} `graphql:"repositories(first: 100, after: $after, isArchived: false, isLocked: false, orderBy: {field: UPDATED_AT, direction: DESC})"`
377+
// issueNodes is a separate alias for the same query to fetch issue counts.
378+
// It may return FORBIDDEN errors for repos where Issues:Read is not granted;
379+
// those repos will have a zero issue count rather than failing the whole batch.
380+
IssueNodes struct {
381+
Nodes []struct {
382+
DatabaseId int `graphql:"databaseId"`
383+
Issues struct {
384+
TotalCount int `graphql:"totalCount"`
385+
} `graphql:"issues"`
386+
}
387+
} `graphql:"issueNodes: repositories(first: 100, after: $after, isArchived: false, isLocked: false, orderBy: {field: UPDATED_AT, direction: DESC})"`
377388
} `graphql:"repositoryOwner(login: $org)"`
378389
}
379390

@@ -385,6 +396,12 @@ func (c *Client) GetOrgRepos(ctx context.Context, org string) <-chan analyze.Rep
385396
if queryErr == nil {
386397
return nil
387398
}
399+
// Partial success: issues FORBIDDEN on some repos but main repo data is intact.
400+
// The library unmarshals data before returning errors, so Repositories.Nodes
401+
// is already populated. Accept the partial result.
402+
if isIssuesPermissionError(queryErr) && query.RepositoryOwner.Login != "" {
403+
return nil
404+
}
388405
if !isRetryableError(queryErr) {
389406
return backoff.Permanent(queryErr)
390407
}
@@ -417,6 +434,13 @@ func (c *Client) GetOrgRepos(ctx context.Context, org string) <-chan analyze.Rep
417434
return
418435
}
419436

437+
issueCounts := make(map[int]int, len(query.RepositoryOwner.IssueNodes.Nodes))
438+
for _, node := range query.RepositoryOwner.IssueNodes.Nodes {
439+
if node.DatabaseId != 0 {
440+
issueCounts[node.DatabaseId] = node.Issues.TotalCount
441+
}
442+
}
443+
420444
totalCount := 0
421445
if !totalCountSent {
422446
totalCount = query.RepositoryOwner.Repositories.TotalCount
@@ -425,7 +449,7 @@ func (c *Client) GetOrgRepos(ctx context.Context, org string) <-chan analyze.Rep
425449

426450
batchChan <- analyze.RepoBatch{
427451
TotalCount: totalCount,
428-
Repositories: convertToRepositorySlice(query.RepositoryOwner.Repositories.Nodes),
452+
Repositories: convertToRepositorySlice(query.RepositoryOwner.Repositories.Nodes, issueCounts),
429453
}
430454

431455
if !query.RepositoryOwner.Repositories.PageInfo.HasNextPage {
@@ -439,21 +463,68 @@ func (c *Client) GetOrgRepos(ctx context.Context, org string) <-chan analyze.Rep
439463
return batchChan
440464
}
441465

442-
func convertToRepositorySlice(githubRepos []GithubRepository) []analyze.Repository {
466+
// repoWithIssueCount wraps GithubRepository to carry an issue count that is
467+
// fetched via a separate aliased query rather than from GithubRepository itself.
468+
type repoWithIssueCount struct {
469+
GithubRepository
470+
issueCount int
471+
}
472+
473+
func (r repoWithIssueCount) GetOpenIssuesCount() int {
474+
return r.issueCount
475+
}
476+
477+
func convertToRepositorySlice(githubRepos []GithubRepository, issueCounts map[int]int) []analyze.Repository {
443478
repos := make([]analyze.Repository, len(githubRepos))
444479
for i, repo := range githubRepos {
445-
repos[i] = repo
480+
repos[i] = repoWithIssueCount{
481+
GithubRepository: repo,
482+
issueCount: issueCounts[repo.DatabaseId],
483+
}
446484
}
447485
return repos
448486
}
449487

488+
// isIssuesPermissionError returns true when the only GraphQL error is a
489+
// "Resource not accessible by personal access token" on the issueNodes alias —
490+
// meaning the FGPAT lacks Issues:Read, but the main repo list is intact.
491+
func isIssuesPermissionError(err error) bool {
492+
return err != nil && strings.EqualFold(err.Error(), "resource not accessible by personal access token")
493+
}
494+
495+
// isFatalError returns true for errors that should stop pagination immediately
496+
// (auth failures, permission errors) rather than being retried.
497+
func isFatalError(err error) bool {
498+
if err == nil {
499+
return false
500+
}
501+
errStr := strings.ToLower(err.Error())
502+
fatalPatterns := []string{
503+
"401",
504+
"403",
505+
"bad credentials",
506+
"requires authentication",
507+
"must have push access",
508+
}
509+
for _, pattern := range fatalPatterns {
510+
if strings.Contains(errStr, pattern) {
511+
return true
512+
}
513+
}
514+
return false
515+
}
516+
450517
// isRetryableError returns true for transient server errors (5xx, network issues)
451518
// that are worth retrying.
452519
func isRetryableError(err error) bool {
453520
if err == nil {
454521
return false
455522
}
456523

524+
if isFatalError(err) {
525+
return false
526+
}
527+
457528
// Type-based checks for network errors
458529
var netErr net.Error
459530
if errors.As(err, &netErr) {

0 commit comments

Comments
 (0)