From d5c4857484b341cdb89992b6eb324f1b261428e9 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 20 Oct 2025 08:06:32 +0200 Subject: [PATCH] Auto-refresh GitHub tokens --- internal/bot/bot_sprinkler.go | 24 ++++++++++++++++++------ internal/config/config.go | 22 +++++++++++++++++----- internal/github/github.go | 25 ++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/internal/bot/bot_sprinkler.go b/internal/bot/bot_sprinkler.go index aa594a7..b6deed0 100644 --- a/internal/bot/bot_sprinkler.go +++ b/internal/bot/bot_sprinkler.go @@ -318,12 +318,24 @@ func (c *Coordinator) RunWithSprinklerClient(ctx context.Context) error { if startErr != nil { errCount++ - // Check if it's an authentication error - if strings.Contains(startErr.Error(), "403") || strings.Contains(startErr.Error(), "401") { - slog.Warn("authentication failed, refreshing token", - "organization", organization, - "consecutive_errors", errCount, - "error", startErr) + // Check if it's an authentication error OR if we've had many failures (token might be expired) + // After 5 consecutive failures, proactively refresh the token + isAuthError := strings.Contains(startErr.Error(), "403") || strings.Contains(startErr.Error(), "401") + shouldRefreshToken := isAuthError || errCount >= 5 + + if shouldRefreshToken { + if isAuthError { + slog.Warn("authentication failed, refreshing token", + "organization", organization, + "consecutive_errors", errCount, + "error", startErr) + } else { + slog.Info("multiple connection failures, proactively refreshing token", + "organization", organization, + "consecutive_errors", errCount, + "error", startErr, + "reason", "token may have expired") + } sprinklerClient.Stop() // Stop old client before creating new one diff --git a/internal/config/config.go b/internal/config/config.go index 577ea73..6697a61 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync" "time" @@ -427,6 +428,9 @@ func (m *Manager) ChannelsForRepo(org, repo string) []string { // First, check explicit YAML configuration for channelName, channelConfig := range config.Channels { + // Normalize channel name to lowercase (Slack channels are always lowercase) + normalizedChannelName := strings.ToLower(channelName) + // Check if this channel explicitly includes this repo for _, configRepo := range channelConfig.Repos { if m.matchesRepo(configRepo, repo) { @@ -436,11 +440,11 @@ func (m *Manager) ChannelsForRepo(org, repo string) []string { slog.Debug("skipping explicitly muted channel", logFieldOrg, org, "repo", repo, - "channel", channelName, + "channel", normalizedChannelName, "muted", true) continue } - channels = append(channels, channelName) + channels = append(channels, normalizedChannelName) break // Don't add the same channel multiple times } } @@ -467,7 +471,15 @@ func (m *Manager) ChannelsForRepo(org, repo string) []string { } // Check if auto-discovered channel is explicitly muted - if channelConfig, exists := config.Channels[autoChannel]; exists && channelConfig.Mute { + // Need to check case-insensitively since YAML keys preserve case + var channelMuted bool + for yamlChannelName, channelConfig := range config.Channels { + if strings.EqualFold(yamlChannelName, autoChannel) && channelConfig.Mute { + channelMuted = true + break + } + } + if channelMuted { slog.Info("auto-discovered channel is explicitly muted in config", logFieldOrg, org, "repo", repo, @@ -518,8 +530,8 @@ func (*Manager) matchesRepo(pattern, repo string) bool { // For example: repo "goose" -> channel "#goose", repo "my-service" -> channel "#my-service". func (*Manager) autoDiscoverChannels(org, repo string) []string { // Convert repo name to channel name - // Most repos will match their channel name directly - channelName := repo + // Slack channel names are always lowercase + channelName := strings.ToLower(repo) slog.Debug("attempting auto-discovery of channel for repo", logFieldOrg, org, diff --git a/internal/github/github.go b/internal/github/github.go index 0ae5fd5..f827f9d 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -37,6 +37,22 @@ type Client struct { tokenMutex sync.RWMutex } +// refreshingTokenSource implements oauth2.TokenSource that automatically refreshes tokens. +type refreshingTokenSource struct { + client *Client +} + +// Token returns a fresh token, refreshing if necessary. +func (ts *refreshingTokenSource) Token() (*oauth2.Token, error) { + // Use a background context for token refresh - token operations should complete + // independently of request contexts to avoid breaking long-running connections + token := ts.client.InstallationToken(context.Background()) + if token == "" { + return nil, errors.New("no token available") + } + return &oauth2.Token{AccessToken: token}, nil +} + // userAgentTransport adds a custom User-Agent header to requests. type userAgentTransport struct { base http.RoundTripper @@ -186,16 +202,19 @@ func (c *Client) authenticate(ctx context.Context) error { "installation_id", c.installationID) } - // Create installation client with custom user-agent. - ts = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token.GetToken()}) + // Create installation client with auto-refreshing token source and custom user-agent. + // The refreshingTokenSource will automatically call InstallationToken() which handles + // token expiry checking and refreshing. + ts = &refreshingTokenSource{client: c} tc = oauth2.NewClient(ctx, ts) tc.Transport = &userAgentTransport{base: tc.Transport} c.client = github.NewClient(tc) // Store the token with expiry (GitHub tokens expire after 1 hour). + // For security, refresh every 30 minutes instead of waiting until near expiry. c.tokenMutex.Lock() c.installationToken = token.GetToken() - c.tokenExpiry = time.Now().Add(55 * time.Minute) // Refresh 5 minutes before expiry + c.tokenExpiry = time.Now().Add(30 * time.Minute) // Refresh every 30 minutes for security c.tokenMutex.Unlock() // Test the token by making a simple API call