Skip to content

Commit 86db55c

Browse files
feat: dynamically discover GitHub App installations via API
Instead of requiring org installation IDs to be statically configured in cachew.hcl, dynamically resolve them by querying the GitHub API (GET /orgs/{org}/installation, falling back to /users/{user}/installation). Removes the static Installations map from Config entirely. Installation IDs are discovered on first use and cached in memory. The fallback-org config is retained for orgs where the app is not installed. This fixes clone failures for orgs like AfterpayTouch where the GitHub App is installed but the installation ID wasn't in the config. Ports the same approach used in blox's internal/github/app_auth.go. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d0910-4486-750f-bb6b-0629397da303
1 parent e81e5fe commit 86db55c

4 files changed

Lines changed: 240 additions & 132 deletions

File tree

internal/githubapp/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import "time"
77
type Config struct {
88
AppID string `hcl:"app-id,optional" help:"GitHub App ID"`
99
PrivateKeyPath string `hcl:"private-key-path,optional" help:"Path to GitHub App private key (PEM format)"`
10-
Installations map[string]string `hcl:"installations,optional" help:"Mapping of org names to installation IDs"`
10+
Installations map[string]string `hcl:"installations,optional" help:"Deprecated: installations are now discovered dynamically via the GitHub API"`
1111
FallbackOrg string `hcl:"fallback-org,optional" help:"Org whose installation token is used for orgs without their own installation (ensures authenticated rate limits)"`
1212
}
1313

internal/githubapp/testing.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package githubapp
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"testing"
7+
)
8+
9+
// NewTokenManagerForTest creates a TokenManager with a custom API base URL and HTTP client for testing.
10+
func NewTokenManagerForTest(t *testing.T, configs []Config, logger *slog.Logger, apiBase string, httpClient *http.Client) *TokenManager {
11+
t.Helper()
12+
13+
tm, err := newTokenManager(configs, logger)
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
18+
for _, app := range tm.apps {
19+
app.apiBase = apiBase
20+
app.httpClient = httpClient
21+
}
22+
23+
return tm
24+
}

internal/githubapp/tokens.go

Lines changed: 136 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
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.
3033
type 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.
4753
type 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

5362
func 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.
117122
func (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,
148176
func (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+
221315
func extractOrgFromURL(url string) (string, error) {
222316
url = strings.TrimPrefix(url, "https://")
223317
url = strings.TrimPrefix(url, "http://")

0 commit comments

Comments
 (0)