Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions internal/bot/bot_sprinkler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 17 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 22 additions & 3 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down