66 "fmt"
77 "log/slog"
88 "net/http"
9+ "strconv"
910 "strings"
1011 "sync"
1112 "time"
@@ -26,13 +27,18 @@ func NewTokenManagerProvider(configs []Config, logger *slog.Logger) TokenManager
2627 })
2728}
2829
30+ const githubAPIBase = "https://api.github.com"
31+
2932// appState holds token management state for a single GitHub App.
3033type appState struct {
3134 appID string
3235 jwtGenerator * JWTGenerator
3336 cacheConfig TokenCacheConfig
3437 httpClient * http.Client
35- orgs map [string ]string // org -> installation ID
38+ apiBase string
39+
40+ installationMu sync.RWMutex
41+ installationCache map [string ]string // org -> installation ID (dynamically discovered)
3642
3743 mu sync.RWMutex
3844 tokens map [string ]* cachedToken
@@ -45,23 +51,25 @@ type cachedToken struct {
4551
4652// TokenManager manages GitHub App installation tokens across one or more apps.
4753type TokenManager struct {
48- orgToApp map [string ]* appState
54+ mu sync.RWMutex
55+ orgToApp map [string ]* appState
56+
57+ apps []* appState // all configured apps, for dynamic installation discovery
4958 fallbackApp * appState
5059 fallbackOrg string
5160}
5261
5362func newTokenManager (configs []Config , logger * slog.Logger ) (* TokenManager , error ) {
54- orgToApp := map [ string ]* appState {}
63+ var apps [ ]* appState
5564
5665 for _ , config := range configs {
57- hasAny := config .AppID != "" || config .PrivateKeyPath != "" || len (config .Installations ) > 0
58- hasAll := config .AppID != "" && config .PrivateKeyPath != "" && len (config .Installations ) > 0
66+ hasAny := config .AppID != "" || config .PrivateKeyPath != ""
5967 if ! hasAny {
6068 continue
6169 }
62- if ! hasAll {
63- return nil , errors .Errorf ("github-app: incomplete configuration (app-id=%q, private-key-path=%q, installations=%d )" ,
64- config .AppID , config .PrivateKeyPath , len ( config . Installations ) )
70+ if config . AppID == "" || config . PrivateKeyPath == "" {
71+ return nil , errors .Errorf ("github-app: incomplete configuration (app-id=%q, private-key-path=%q)" ,
72+ config .AppID , config .PrivateKeyPath )
6573 }
6674
6775 cacheConfig := DefaultTokenCacheConfig ()
@@ -70,38 +78,34 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro
7078 return nil , errors .Wrapf (err , "github app %q" , config .AppID )
7179 }
7280
73- app := & appState {
74- appID : config .AppID ,
75- jwtGenerator : jwtGen ,
76- cacheConfig : cacheConfig ,
77- httpClient : http .DefaultClient ,
78- orgs : config .Installations ,
79- tokens : make (map [string ]* cachedToken ),
80- }
81-
82- for org := range config .Installations {
83- if existing , exists := orgToApp [org ]; exists {
84- return nil , errors .Errorf ("org %q is configured in both github-app %q and %q" , org , existing .appID , config .AppID )
85- }
86- orgToApp [org ] = app
87- }
88-
89- logger .Info ("GitHub App configured" , "app_id" , config .AppID , "orgs" , len (config .Installations ))
81+ apps = append (apps , & appState {
82+ appID : config .AppID ,
83+ jwtGenerator : jwtGen ,
84+ cacheConfig : cacheConfig ,
85+ httpClient : http .DefaultClient ,
86+ apiBase : githubAPIBase ,
87+ installationCache : make (map [string ]string ),
88+ tokens : make (map [string ]* cachedToken ),
89+ })
90+
91+ logger .Info ("GitHub App configured" , "app_id" , config .AppID )
9092 }
9193
92- if len (orgToApp ) == 0 {
94+ if len (apps ) == 0 {
9395 return nil , nil //nolint:nilnil
9496 }
9597
96- tm := & TokenManager {orgToApp : orgToApp }
98+ tm := & TokenManager {
99+ orgToApp : make (map [string ]* appState ),
100+ apps : apps ,
101+ }
97102
98- for _ , config := range configs {
103+ for i , config := range configs {
99104 if config .FallbackOrg != "" {
100- app , ok := orgToApp [config .FallbackOrg ]
101- if ! ok {
102- return nil , errors .Errorf ("fallback-org %q is not in the installations map for app %q" , config .FallbackOrg , config .AppID )
105+ if i >= len (apps ) {
106+ continue
103107 }
104- tm .fallbackApp = app
108+ tm .fallbackApp = apps [ i ]
105109 tm .fallbackOrg = config .FallbackOrg
106110 logger .Info ("GitHub App fallback configured" , "fallback_org" , config .FallbackOrg , "app_id" , config .AppID )
107111 break
@@ -112,24 +116,48 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro
112116}
113117
114118// GetTokenForOrg returns an installation token for the given GitHub organization.
115- // If no installation is configured for the org, it falls back to the fallback org's
116- // token to ensure authenticated rate limits.
119+ // It dynamically discovers the installation ID via the GitHub API on first use,
120+ // caches the result, and falls back to the fallback org's token for orgs where
121+ // the app is not installed.
117122func (tm * TokenManager ) GetTokenForOrg (ctx context.Context , org string ) (string , error ) {
118123 if tm == nil {
119124 return "" , errors .New ("token manager not initialized" )
120125 }
121126
127+ logger := logging .FromContext (ctx )
128+
129+ // Check cached discovery first
130+ tm .mu .RLock ()
122131 app , ok := tm .orgToApp [org ]
123- if ! ok {
124- if tm .fallbackApp == nil {
125- return "" , errors .Errorf ("no GitHub App configured for org: %s" , org )
132+ tm .mu .RUnlock ()
133+ if ok {
134+ return app .getToken (ctx , org )
135+ }
136+
137+ // Discover installation via GitHub API
138+ for _ , app := range tm .apps {
139+ installationID , err := app .lookupInstallationID (ctx , org )
140+ if err != nil {
141+ logger .DebugContext (ctx , "Dynamic installation lookup failed" , "org" , org , "app_id" , app .appID , "error" , err )
142+ continue
126143 }
127- logging .FromContext (ctx ).InfoContext (ctx , "Using fallback org token" , "requested_org" , org ,
128- "fallback_org" , tm .fallbackOrg )
144+
145+ logger .InfoContext (ctx , "Dynamically discovered GitHub App installation" , "org" , org , "app_id" , app .appID , "installation_id" , installationID )
146+
147+ // Cache the mapping for future requests
148+ tm .mu .Lock ()
149+ tm .orgToApp [org ] = app
150+ tm .mu .Unlock ()
151+ return app .getToken (ctx , org )
152+ }
153+
154+ // Fall back to fallback org
155+ if tm .fallbackApp != nil {
156+ logger .InfoContext (ctx , "Using fallback org token" , "requested_org" , org , "fallback_org" , tm .fallbackOrg )
129157 return tm .fallbackApp .getToken (ctx , tm .fallbackOrg )
130158 }
131159
132- return app . getToken ( ctx , org )
160+ return "" , errors . Errorf ( "no GitHub App installation found for org: %s" , org )
133161}
134162
135163// GetTokenForURL extracts the org from a GitHub URL and returns an installation token.
@@ -148,7 +176,9 @@ func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string,
148176func (a * appState ) getToken (ctx context.Context , org string ) (string , error ) {
149177 logger := logging .FromContext (ctx ).With ("org" , org , "app_id" , a .appID )
150178
151- installationID := a .orgs [org ]
179+ a .installationMu .RLock ()
180+ installationID := a .installationCache [org ]
181+ a .installationMu .RUnlock ()
152182 if installationID == "" {
153183 return "" , errors .Errorf ("no installation ID for org: %s" , org )
154184 }
@@ -187,7 +217,7 @@ func (a *appState) fetchInstallationToken(ctx context.Context, installationID st
187217 return "" , time.Time {}, errors .Wrap (err , "generate JWT" )
188218 }
189219
190- url := fmt .Sprintf ("https://api.github.com/ app/installations/%s/access_tokens" , installationID )
220+ url := fmt .Sprintf ("%s/ app/installations/%s/access_tokens" , a . apiBase , installationID )
191221 req , err := http .NewRequestWithContext (ctx , http .MethodPost , url , nil )
192222 if err != nil {
193223 return "" , time.Time {}, errors .Wrap (err , "create request" )
@@ -218,6 +248,70 @@ func (a *appState) fetchInstallationToken(ctx context.Context, installationID st
218248 return result .Token , result .ExpiresAt , nil
219249}
220250
251+ // lookupInstallationID queries the GitHub API to find the installation ID for the
252+ // given org. It tries /orgs/{org}/installation first, then /users/{user}/installation.
253+ // Results are cached in installationCache.
254+ func (a * appState ) lookupInstallationID (ctx context.Context , org string ) (string , error ) {
255+ // Check cache first
256+ a .installationMu .RLock ()
257+ if id , ok := a .installationCache [org ]; ok {
258+ a .installationMu .RUnlock ()
259+ return id , nil
260+ }
261+ a .installationMu .RUnlock ()
262+
263+ jwt , err := a .jwtGenerator .GenerateJWT ()
264+ if err != nil {
265+ return "" , errors .Wrap (err , "generate JWT" )
266+ }
267+
268+ // Try org endpoint first, then user endpoint
269+ for _ , endpoint := range []string {
270+ fmt .Sprintf ("%s/orgs/%s/installation" , a .apiBase , org ),
271+ fmt .Sprintf ("%s/users/%s/installation" , a .apiBase , org ),
272+ } {
273+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , endpoint , nil )
274+ if err != nil {
275+ return "" , errors .Wrap (err , "create request" )
276+ }
277+
278+ req .Header .Set ("Accept" , "application/vnd.github+json" )
279+ req .Header .Set ("Authorization" , "Bearer " + jwt )
280+ req .Header .Set ("X-Github-Api-Version" , "2022-11-28" )
281+
282+ resp , err := a .httpClient .Do (req )
283+ if err != nil {
284+ return "" , errors .Wrap (err , "execute request" )
285+ }
286+ defer resp .Body .Close ()
287+
288+ if resp .StatusCode == http .StatusNotFound {
289+ continue
290+ }
291+
292+ if resp .StatusCode != http .StatusOK {
293+ return "" , errors .Errorf ("GitHub API returned status %d for %s" , resp .StatusCode , endpoint )
294+ }
295+
296+ var result struct {
297+ ID int64 `json:"id"`
298+ }
299+ if err := json .NewDecoder (resp .Body ).Decode (& result ); err != nil {
300+ return "" , errors .Wrap (err , "decode response" )
301+ }
302+
303+ installationID := strconv .FormatInt (result .ID , 10 )
304+
305+ a .installationMu .Lock ()
306+ a .installationCache [org ] = installationID
307+ a .installationMu .Unlock ()
308+
309+ return installationID , nil
310+ }
311+
312+ return "" , errors .Errorf ("no GitHub App installation found for %s" , org )
313+ }
314+
221315func extractOrgFromURL (url string ) (string , error ) {
222316 url = strings .TrimPrefix (url , "https://" )
223317 url = strings .TrimPrefix (url , "http://" )
0 commit comments