Skip to content

Commit 964d243

Browse files
mevdscheeclaude
andcommitted
sync: check token scopes up front, skip projects when read:project is missing
A token with repo but not read:project made the projects v2 GraphQL query fail field-by-field ("the 'createdAt' field requires ['read:project']"), emitting one error per scope-gated field on every run. Add Client.Scopes() (reads X-OAuth-Scopes; reports unknown for fine-grained / app tokens) and a featureScopes table checked before sync. Projects is now skipped with a single clear message when its scope is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent faa1040 commit 964d243

3 files changed

Lines changed: 109 additions & 4 deletions

File tree

internal/github/client.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const (
2121
type Client struct {
2222
token string
2323
client *http.Client
24+
25+
scopes []string
26+
scopesKnown bool
27+
scopesFetched bool
2428
}
2529

2630
func NewClient(token string) *Client {
@@ -30,6 +34,44 @@ func NewClient(token string) *Client {
3034
}
3135
}
3236

37+
// Scopes returns the OAuth scopes granted to the token, as reported by the
38+
// X-OAuth-Scopes header on an authenticated request. The boolean is false when
39+
// the token does not report scopes — fine-grained PATs and GitHub App
40+
// installation tokens omit the header — in which case the caller must not
41+
// infer that any scope is missing. The result is fetched once and cached.
42+
func (c *Client) Scopes() ([]string, bool) {
43+
if c.scopesFetched {
44+
return c.scopes, c.scopesKnown
45+
}
46+
c.scopesFetched = true
47+
48+
req, err := http.NewRequest("GET", API+"/", nil)
49+
if err != nil {
50+
return nil, false
51+
}
52+
req.Header.Set("Accept", "application/vnd.github+json")
53+
req.Header.Set("Authorization", "Bearer "+c.token)
54+
55+
resp, err := c.client.Do(req)
56+
if err != nil {
57+
return nil, false
58+
}
59+
defer resp.Body.Close()
60+
io.Copy(io.Discard, resp.Body)
61+
62+
header, ok := resp.Header["X-Oauth-Scopes"]
63+
if !ok {
64+
return nil, false
65+
}
66+
c.scopesKnown = true
67+
for _, part := range strings.Split(strings.Join(header, ","), ",") {
68+
if s := strings.TrimSpace(part); s != "" {
69+
c.scopes = append(c.scopes, s)
70+
}
71+
}
72+
return c.scopes, true
73+
}
74+
3375
var linkNextRe = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`)
3476

3577
func apiError(url string, status int, body []byte) error {

internal/sync/run.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,28 @@ import (
1818
func Run(c *github.Client, s *store.Store, owner, repo, since, syncStart string) ([]hooks.Event, error) {
1919
var events []hooks.Event
2020

21+
skip := checkScopes(c)
22+
2123
if err := Labels(c, s, owner, repo); err != nil {
2224
log.Printf("Warning: %v", err)
2325
}
2426
if err := Milestones(c, s, owner, repo); err != nil {
2527
log.Printf("Warning: %v", err)
2628
}
27-
issueProjects, projectEvents, err := Projects(c, s, owner, repo, since)
28-
if err != nil {
29-
log.Printf("Warning: %v", err)
29+
var issueProjects map[int64][]string
30+
if !skip["projects"] {
31+
ip, projectEvents, err := Projects(c, s, owner, repo, since)
32+
if err != nil {
33+
log.Printf("Warning: %v", err)
34+
}
35+
issueProjects = ip
36+
events = append(events, projectEvents...)
3037
}
3138
issueEvents, err := Issues(c, s, owner, repo, since, issueProjects)
3239
if err != nil {
3340
log.Printf("Warning: %v", err)
3441
}
3542
events = append(events, issueEvents...)
36-
events = append(events, projectEvents...)
3743

3844
releaseEvents, err := Releases(c, s, owner, repo)
3945
if err != nil {

internal/sync/scopes.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package sync
2+
3+
import (
4+
"log"
5+
"strings"
6+
7+
"github.com/mevdschee/github-export/internal/github"
8+
)
9+
10+
// featureScopes maps each optional feature to the OAuth scopes that unlock it
11+
// (holding any one is enough). Features absent from this map need only `repo`,
12+
// without which nothing syncs at all. Projects v2 is the odd one out: GitHub
13+
// rejects the query field-by-field ("the 'createdAt' field requires one of the
14+
// following scopes: ['read:project']") when the scope is missing, so we skip
15+
// the feature up front instead of letting it spew one error per field.
16+
var featureScopes = map[string][]string{
17+
"projects": {"read:project", "project"},
18+
}
19+
20+
// checkScopes inspects the token's granted scopes, logs them, and returns the
21+
// set of features to skip because none of their accepted scopes is present.
22+
// It returns nil (skip nothing) for tokens that do not report scopes —
23+
// fine-grained PATs and GitHub App tokens — since their permissions are not
24+
// expressed as a scope list and must not be second-guessed.
25+
func checkScopes(c *github.Client) map[string]bool {
26+
granted, known := c.Scopes()
27+
if !known {
28+
return nil
29+
}
30+
if len(granted) == 0 {
31+
log.Println("Token scopes: (none)")
32+
} else {
33+
log.Printf("Token scopes: %s", strings.Join(granted, ", "))
34+
}
35+
36+
have := make(map[string]bool, len(granted))
37+
for _, s := range granted {
38+
have[s] = true
39+
}
40+
41+
skip := map[string]bool{}
42+
for feature, accepted := range featureScopes {
43+
ok := false
44+
for _, s := range accepted {
45+
if have[s] {
46+
ok = true
47+
break
48+
}
49+
}
50+
if !ok {
51+
skip[feature] = true
52+
log.Printf("Skipping %s: token lacks the %q scope (add it with: gh auth refresh -s %s)",
53+
feature, accepted[0], accepted[0])
54+
}
55+
}
56+
return skip
57+
}

0 commit comments

Comments
 (0)