diff --git a/internal/githubapp/config.go b/internal/githubapp/config.go index 78e6bff..5547ae0 100644 --- a/internal/githubapp/config.go +++ b/internal/githubapp/config.go @@ -8,6 +8,7 @@ type Config struct { AppID string `hcl:"app-id,optional" help:"GitHub App ID"` PrivateKeyPath string `hcl:"private-key-path,optional" help:"Path to GitHub App private key (PEM format)"` Installations map[string]string `hcl:"installations,optional" help:"Mapping of org names to installation IDs"` + FallbackOrg string `hcl:"fallback-org,optional" help:"Org whose installation token is used for orgs without their own installation (ensures authenticated rate limits)"` } // TokenCacheConfig configures token caching behavior. diff --git a/internal/githubapp/tokens.go b/internal/githubapp/tokens.go index c87a255..58d54b9 100644 --- a/internal/githubapp/tokens.go +++ b/internal/githubapp/tokens.go @@ -45,7 +45,9 @@ type cachedToken struct { // TokenManager manages GitHub App installation tokens across one or more apps. type TokenManager struct { - orgToApp map[string]*appState + orgToApp map[string]*appState + fallbackApp *appState + fallbackOrg string } func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, error) { @@ -93,10 +95,29 @@ func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, erro return nil, nil //nolint:nilnil } - return &TokenManager{orgToApp: orgToApp}, nil + tm := &TokenManager{orgToApp: orgToApp} + + for _, config := range configs { + if config.FallbackOrg != "" { + app, ok := orgToApp[config.FallbackOrg] + if !ok { + return nil, errors.Errorf("fallback-org %q is not in the installations map for app %q", config.FallbackOrg, config.AppID) + } + tm.fallbackApp = app + tm.fallbackOrg = config.FallbackOrg + logger.Info("GitHub App fallback configured", + "fallback_org", config.FallbackOrg, + "app_id", config.AppID) + break + } + } + + return tm, nil } // GetTokenForOrg returns an installation token for the given GitHub organization. +// If no installation is configured for the org, it falls back to the fallback org's +// token to ensure authenticated rate limits. func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, error) { if tm == nil { return "", errors.New("token manager not initialized") @@ -104,7 +125,13 @@ func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, app, ok := tm.orgToApp[org] if !ok { - return "", errors.Errorf("no GitHub App configured for org: %s", org) + if tm.fallbackApp == nil { + return "", errors.Errorf("no GitHub App configured for org: %s", org) + } + logging.FromContext(ctx).InfoContext(ctx, "Using fallback org token", + slog.String("requested_org", org), + slog.String("fallback_org", tm.fallbackOrg)) + return tm.fallbackApp.getToken(ctx, tm.fallbackOrg) } return app.getToken(ctx, org) diff --git a/internal/githubapp/tokens_test.go b/internal/githubapp/tokens_test.go index 1a8d0d1..b7f0cb9 100644 --- a/internal/githubapp/tokens_test.go +++ b/internal/githubapp/tokens_test.go @@ -13,6 +13,7 @@ import ( "github.com/alecthomas/assert/v2" "github.com/block/cachew/internal/githubapp" + "github.com/block/cachew/internal/logging" ) func generateTestKey(t *testing.T) string { @@ -144,6 +145,63 @@ func TestGetTokenForOrgRouting(t *testing.T) { assert.Contains(t, err.Error(), "no GitHub App configured for org") } +func TestGetTokenForOrgFallback(t *testing.T) { + keyPath := generateTestKey(t) + logger := slog.Default() + + t.Run("FallbackUsedForUnknownOrg", func(t *testing.T) { + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + AppID: "111", + PrivateKeyPath: keyPath, + Installations: map[string]string{"squareup": "inst-sq"}, + FallbackOrg: "squareup", + }, + }, logger) + tm, err := provider() + assert.NoError(t, err) + + ctx := logging.ContextWithLogger(t.Context(), slog.Default()) + // Unknown org should not error when fallback is configured + // (will fail at the HTTP level but not at the routing level) + _, err = tm.GetTokenForOrg(ctx, "cashapp") + // Error is expected here because we don't have a real GitHub API, + // but it should NOT be "no GitHub App configured for org" + assert.Error(t, err) + assert.NotContains(t, err.Error(), "no GitHub App configured for org") + }) + + t.Run("FallbackOrgNotInInstallations", func(t *testing.T) { + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + AppID: "111", + PrivateKeyPath: keyPath, + Installations: map[string]string{"squareup": "inst-sq"}, + FallbackOrg: "nonexistent", + }, + }, logger) + _, err := provider() + assert.Error(t, err) + assert.Contains(t, err.Error(), "fallback-org \"nonexistent\" is not in the installations map") + }) + + t.Run("NoFallbackStillErrorsForUnknownOrg", func(t *testing.T) { + provider := githubapp.NewTokenManagerProvider([]githubapp.Config{ + { + AppID: "111", + PrivateKeyPath: keyPath, + Installations: map[string]string{"squareup": "inst-sq"}, + }, + }, logger) + tm, err := provider() + assert.NoError(t, err) + + _, err = tm.GetTokenForOrg(t.Context(), "unknown-org") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no GitHub App configured for org") + }) +} + func TestGetTokenForOrgNilManager(t *testing.T) { var tm *githubapp.TokenManager _, err := tm.GetTokenForOrg(t.Context(), "any")