@@ -23,7 +23,6 @@ import (
2323 "context"
2424 "crypto/rsa"
2525 "encoding/json"
26- "errors"
2726 "fmt"
2827 "net/http"
2928 "net/url"
@@ -68,11 +67,16 @@ type Client struct {
6867 httpClient * http.Client
6968 installations * installationCache
7069 installationSF singleflight.Group
71- contentsTokens * tokenCache
70+ tokens * tokenCache
7271 tokenMintSF singleflight.Group
7372 tokenReadyDelay time.Duration
7473}
7574
75+ // mintOpts controls mintToken behavior.
76+ type mintOpts struct {
77+ skipCache bool // skip cache and mint a fresh token
78+ }
79+
7680// ghClient returns a go-github client authenticated with the given token.
7781func (c * Client ) ghClient (token string ) (* gogithub.Client , error ) {
7882 gh := gogithub .NewClient (c .httpClient ).WithAuthToken (token )
@@ -92,7 +96,7 @@ func (c *Client) RequestToken(ctx context.Context, req *TokenRequest) (*TokenRes
9296 return nil , err
9397 }
9498
95- id , err := c .getInstallationID (ctx , owner , repo )
99+ id , err := c .getInstallationID (ctx , owner )
96100 if err != nil {
97101 return nil , fmt .Errorf ("getting installation ID: %w" , err )
98102 }
@@ -148,9 +152,12 @@ func (c *Client) GetContents(ctx context.Context, repository string, path string
148152 return nil , err
149153 }
150154
151- // Retry once: if a cached token yields 401/403, evict it and mint a fresh one.
155+ perms := map [string ]string {"contents" : "read" }
156+ var opts * mintOpts
157+
158+ // Retry once on 401/403 in case the token was stale; the second attempt mints fresh.
152159 for attempt := range 2 {
153- tok , cached , err := c .contentsToken (ctx , repository , owner , repo )
160+ tok , err := c .mintToken (ctx , owner , "" , perms , opts )
154161 if err != nil {
155162 return nil , err
156163 }
@@ -162,8 +169,8 @@ func (c *Client) GetContents(ctx context.Context, repository string, path string
162169
163170 fileContent , _ , resp , err := gh .Repositories .GetContents (ctx , owner , repo , path , nil )
164171 if err != nil {
165- if attempt == 0 && cached && resp != nil && (resp .StatusCode == http .StatusUnauthorized || resp .StatusCode == http .StatusForbidden ) {
166- c . contentsTokens . delete ( repository )
172+ if attempt == 0 && resp != nil && (resp .StatusCode == http .StatusUnauthorized || resp .StatusCode == http .StatusForbidden ) {
173+ opts = & mintOpts { skipCache : true }
167174 continue
168175 }
169176 if resp != nil && resp .StatusCode == http .StatusNotFound {
@@ -181,44 +188,41 @@ func (c *Client) GetContents(ctx context.Context, repository string, path string
181188 }
182189 return []byte (content ), nil
183190 }
184- return nil , errors . New ("unexpected: exhausted GetContents retry" )
191+ return nil , fmt . Errorf ("unexpected: exhausted GetContents retries for %s/%s" , repository , path )
185192}
186193
187- // contentsToken returns a repo-scoped contents:read token, serving from
188- // cache when possible. On a cache miss, concurrent callers for the same
189- // repository are coalesced so only one mint + replication delay occurs.
190- // The bool indicates whether the token was served from cache.
191- func (c * Client ) contentsToken (ctx context.Context , repository , owner , repo string ) (string , bool , error ) {
192- if tok := c .contentsTokens .get (repository ); tok != "" {
193- return tok , true , nil
194+ // mintToken returns a cached or freshly minted installation token.
195+ // When repo is non-empty the token is scoped to that single repository;
196+ // when empty the token covers every repo the installation can access.
197+ // Tokens are keyed by owner[/repo]+permissions.
198+ func (c * Client ) mintToken (ctx context.Context , owner , repo string , perms map [string ]string , opts * mintOpts ) (string , error ) {
199+ key := owner
200+ if repo != "" {
201+ key = owner + "/" + repo
194202 }
203+ key += "|" + permissionsKey (perms )
195204
196- perms := map [string ]string {"contents" : "read" }
197- tok , err := c .mintToken (ctx , repository , owner , repo , perms , c .contentsTokens )
198- if err != nil {
199- return "" , false , err
200- }
201- return tok , false , nil
202- }
205+ skipCache := opts != nil && opts .skipCache
203206
204- // mintToken creates a new installation token with the given permissions,
205- // waits for edge replication, and stores the result in cache. Concurrent
206- // calls for the same repository+permissions share a single in-flight
207- // mint via singleflight.
208- func (c * Client ) mintToken (ctx context.Context , repository , owner , repo string , perms map [string ]string , cache * tokenCache ) (string , error ) {
209- sfKey := repository + "|" + permissionsKey (perms )
207+ if skipCache {
208+ c .tokens .delete (key )
209+ } else if tok := c .tokens .get (key ); tok != "" {
210+ return tok , nil
211+ }
210212
211- v , err , _ := c .tokenMintSF .Do (sfKey , func () (any , error ) {
212- if tok := cache .get (repository ); tok != "" {
213- return tok , nil
213+ v , err , _ := c .tokenMintSF .Do (key , func () (any , error ) {
214+ if ! skipCache {
215+ if tok := c .tokens .get (key ); tok != "" {
216+ return tok , nil
217+ }
214218 }
215219
216- id , err := c .getInstallationID (ctx , owner , repo )
220+ id , err := c .getInstallationID (ctx , owner )
217221 if err != nil {
218222 return nil , fmt .Errorf ("getting installation ID: %w" , err )
219223 }
220224
221- token , err := c .createInstallationToken (ctx , id , repo , perms )
225+ resp , err := c .createInstallationToken (ctx , id , repo , perms )
222226 if err != nil {
223227 return nil , fmt .Errorf ("minting installation token: %w" , err )
224228 }
@@ -227,27 +231,24 @@ func (c *Client) mintToken(ctx context.Context, repository, owner, repo string,
227231 return nil , err
228232 }
229233
230- cache . set (repository , token .Token , token .ExpiresAt )
231- return token .Token , nil
234+ c . tokens . set (key , resp .Token , resp .ExpiresAt )
235+ return resp .Token , nil
232236 })
233237 if err != nil {
234238 return "" , err
235239 }
236240 return v .(string ), nil //nolint:errcheck // singleflight guarantees string on nil error
237241}
238242
239- // getInstallationID returns the app installation ID for owner. Installations
240- // are per-account, so cache and singleflight are keyed by owner; repo is
241- // only used for the API lookup on cache miss.
242- func (c * Client ) getInstallationID (ctx context.Context , owner , repo string ) (int64 , error ) {
243- key := owner
244-
245- if id := c .installations .get (key ); id != 0 {
243+ // getInstallationID returns the app installation ID for the given owner
244+ // (organization or user account). Cache and singleflight are keyed by owner.
245+ func (c * Client ) getInstallationID (ctx context.Context , owner string ) (int64 , error ) {
246+ if id := c .installations .get (owner ); id != 0 {
246247 return id , nil
247248 }
248249
249- v , err , _ := c .installationSF .Do (key , func () (any , error ) {
250- if id := c .installations .get (key ); id != 0 {
250+ v , err , _ := c .installationSF .Do (owner , func () (any , error ) {
251+ if id := c .installations .get (owner ); id != 0 {
251252 return id , nil
252253 }
253254
@@ -261,15 +262,19 @@ func (c *Client) getInstallationID(ctx context.Context, owner, repo string) (int
261262 return nil , err
262263 }
263264
264- installation , resp , err := gh .Apps .FindRepositoryInstallation (ctx , owner , repo )
265+ // Try organization first, fall back to user on 404.
266+ installation , resp , err := gh .Apps .FindOrganizationInstallation (ctx , owner )
267+ if err != nil && resp != nil && resp .StatusCode == http .StatusNotFound {
268+ installation , resp , err = gh .Apps .FindUserInstallation (ctx , owner )
269+ }
265270 if err != nil {
266271 if resp != nil && resp .StatusCode == http .StatusNotFound {
267- return nil , fmt .Errorf ("%w: %s/%s " , ErrRepositoryNotFound , owner , repo )
272+ return nil , fmt .Errorf ("%w: %s" , ErrInstallationNotFound , owner )
268273 }
269274 return nil , fmt .Errorf ("fetching installation ID: %w" , err )
270275 }
271276
272- c .installations .set (key , installation .GetID ())
277+ c .installations .set (owner , installation .GetID ())
273278 return installation .GetID (), nil
274279 })
275280 if err != nil {
@@ -278,8 +283,9 @@ func (c *Client) getInstallationID(ctx context.Context, owner, repo string) (int
278283 return v .(int64 ), nil //nolint:errcheck // singleflight guarantees int64 on nil error
279284}
280285
281- // createInstallationToken creates a repository-scoped installation
282- // access token with the given permissions.
286+ // createInstallationToken creates an installation access token with the
287+ // given permissions. When repository is non-empty the token is scoped to
288+ // that single repo; when empty it covers all repos the installation can access.
283289func (c * Client ) createInstallationToken (ctx context.Context , id int64 , repository string , permissions map [string ]string ) (* TokenResponse , error ) {
284290 appJWT , err := c .generateAppJWT ()
285291 if err != nil {
@@ -296,21 +302,20 @@ func (c *Client) createInstallationToken(ctx context.Context, id int64, reposito
296302 return nil , fmt .Errorf ("converting permissions: %w" , err )
297303 }
298304
299- if repository == "" {
300- return nil , ErrRepositoryRequired
301- }
302-
303305 opts := & gogithub.InstallationTokenOptions {
304- Permissions : installationPermissions ,
305- Repositories : []string {repository },
306+ Permissions : installationPermissions ,
307+ }
308+ if repository != "" {
309+ opts .Repositories = []string {repository }
306310 }
307311
308312 token , resp , err := gh .Apps .CreateInstallationToken (ctx , id , opts )
309313 if err != nil {
310- // 404 (no installation) and 422 (repo outside installation scope)
311- // match getInstallationID's ErrRepositoryNotFound contract.
312314 if resp != nil && (resp .StatusCode == http .StatusNotFound || resp .StatusCode == http .StatusUnprocessableEntity ) {
313- return nil , fmt .Errorf ("%w: %s" , ErrRepositoryNotFound , repository )
315+ if repository != "" {
316+ return nil , fmt .Errorf ("%w: %s" , ErrRepositoryNotFound , repository )
317+ }
318+ return nil , fmt .Errorf ("%w (installation %d)" , ErrInstallationNotFound , id )
314319 }
315320 return nil , fmt .Errorf ("creating installation token: %w" , err )
316321 }
@@ -404,7 +409,7 @@ func newClient(opts Options) (ClientIface, error) {
404409 baseURL : strings .TrimRight (opts .BaseURL , "/" ),
405410 httpClient : & http.Client {Timeout : opts .Timeout , Transport : transport },
406411 installations : newInstallationCache (),
407- contentsTokens : newTokenCache (),
412+ tokens : newTokenCache (),
408413 tokenReadyDelay : DefaultTokenReadyDelay ,
409414 }, nil
410415}
0 commit comments