From 7f355e6df8f28e45499e4c0ada90cf3ee9dcc256 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Thu, 30 Oct 2025 09:15:15 -0400 Subject: [PATCH 1/2] Add reverse Slack->GitHub mapping for a better home experience --- cmd/server/main.go | 19 ++- go.mod | 2 + go.sum | 2 - pkg/config/config.go | 1 + pkg/home/ui.go | 59 +++++++ pkg/slack/home_handler.go | 79 ++++----- pkg/usermapping/reverse.go | 256 ++++++++++++++++++++++++++++ pkg/usermapping/reverse_test.go | 244 ++++++++++++++++++++++++++ pkg/usermapping/usermapping.go | 1 + pkg/usermapping/usermapping_test.go | 8 + 10 files changed, 630 insertions(+), 41 deletions(-) create mode 100644 pkg/usermapping/reverse.go create mode 100644 pkg/usermapping/reverse_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 5052e29..c02aa04 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "github.com/codeGROOVE-dev/slacker/pkg/notify" "github.com/codeGROOVE-dev/slacker/pkg/slack" "github.com/codeGROOVE-dev/slacker/pkg/state" + "github.com/codeGROOVE-dev/slacker/pkg/usermapping" "github.com/codeGROOVE-dev/sprinkler/pkg/client" "github.com/gorilla/mux" "golang.org/x/sync/errgroup" @@ -244,8 +245,24 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi // Initialize event router for multi-workspace event handling. eventRouter := slack.NewEventRouter(slackManager) + // Initialize reverse user mapping service (Slack → GitHub) + // Get GitHub token from one of the installations + var githubToken string + for _, org := range githubManager.AllOrgs() { + if client, ok := githubManager.ClientForOrg(org); ok { + githubToken = client.InstallationToken(ctx) + break + } + } + if githubToken == "" { + slog.Warn("no GitHub installations found - reverse user mapping will not work") + } + // Pass nil for Slack client - it will be provided per-request in HomeHandler + reverseMapping := usermapping.NewReverseService(nil, githubToken) + slog.Info("initialized reverse user mapping service (Slack → GitHub)") + // Initialize home view handler - homeHandler := slack.NewHomeHandler(slackManager, githubManager, configManager, stateStore) + homeHandler := slack.NewHomeHandler(slackManager, githubManager, configManager, stateStore, reverseMapping) slackManager.SetHomeViewHandler(homeHandler.HandleAppHomeOpened) // Initialize OAuth handler for Slack app installation. diff --git a/go.mod b/go.mod index 61a7a94..826abe2 100644 --- a/go.mod +++ b/go.mod @@ -38,3 +38,5 @@ require ( golang.org/x/text v0.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) + +replace github.com/codeGROOVE-dev/gh-mailto => ../gh-mailto diff --git a/go.sum b/go.sum index 68f660f..7cb4e04 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/codeGROOVE-dev/ds9 v0.6.0 h1:JG7vBH17UAKaVoeQilrIvA1I0fg3iNbdUMBSDS7ixgI= github.com/codeGROOVE-dev/ds9 v0.6.0/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= -github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9 h1:eyWcEZd3xyLV2WxShoyKWakFyxQGvOSv89ponU3Ah0I= -github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9/go.mod h1:4Hr2ySB8dcpeZqZq/7UbXdEJ/5RK9coYGHvW90ZfieE= github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22 h1:gtN3rOc6YspO646BkcOxBhPjEqKUz+jl175jIqglfDg= github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22/go.mod h1:KV+w19ubP32PxZPE1hOtlCpTaNpF0Bpb32w5djO8UTg= github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4 h1:DSuoUwP3oyR4cHrX0cUh9c7CtYjXNIcyCmqpIwHilIU= diff --git a/pkg/config/config.go b/pkg/config/config.go index cc37d49..749bbdc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -51,6 +51,7 @@ type RepoConfig struct { ReminderDMDelay int `yaml:"reminder_dm_delay"` // Minutes to wait before sending DM if user tagged in channel (0 = disabled) DailyReminders bool `yaml:"daily_reminders"` } `yaml:"global"` + Users map[string]string `yaml:"users"` // GitHub username -> email address (for manual overrides) } // configCacheEntry represents a cached configuration entry. diff --git a/pkg/home/ui.go b/pkg/home/ui.go index 69b8d7d..6abdf02 100644 --- a/pkg/home/ui.go +++ b/pkg/home/ui.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/codeGROOVE-dev/slacker/pkg/usermapping" "github.com/slack-go/slack" ) @@ -226,3 +227,61 @@ func formatEnhancedPRBlock(pr *PR) slack.Block { nil, ) } + +// BuildBlocksWithDebug creates Slack Block Kit UI with debug information about user mapping. +func BuildBlocksWithDebug(dashboard *Dashboard, primaryOrg string, mapping *usermapping.ReverseMapping) []slack.Block { + // Build standard blocks first + blocks := BuildBlocks(dashboard, primaryOrg) + + // Add debug section if mapping info is available + if mapping != nil { + blocks = append(blocks, + slack.NewDividerBlock(), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("🔍 *Debug Info*\n"+ + "GitHub: `@%s` • Mapped via: `%s` • Confidence: `%d%%`", + mapping.GitHubUsername, + mapping.MatchMethod, + mapping.Confidence), + false, + false, + ), + nil, + nil, + ), + ) + + // Add mapping guidance if confidence is low + if mapping.Confidence < 80 { + blocks = append(blocks, + slack.NewContextBlock("", + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("⚠️ Low confidence mapping. Add manual override to `slack.yaml`:\n```yaml\nusers:\n %s: %s\n```", + mapping.GitHubUsername, + mapping.SlackEmail), + false, + false, + ), + ), + ) + } + } else { + // No mapping found - show error message + blocks = append(blocks, + slack.NewDividerBlock(), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + "❌ *Could not map Slack user to GitHub*\n"+ + "Add your mapping to `.codeGROOVE/slack.yaml`:\n```yaml\nusers:\n your-github-username: your-email@company.com\n```", + false, + false, + ), + nil, + nil, + ), + ) + } + + return blocks +} diff --git a/pkg/slack/home_handler.go b/pkg/slack/home_handler.go index 4494025..d07c095 100644 --- a/pkg/slack/home_handler.go +++ b/pkg/slack/home_handler.go @@ -11,15 +11,17 @@ import ( "github.com/codeGROOVE-dev/slacker/pkg/github" "github.com/codeGROOVE-dev/slacker/pkg/home" "github.com/codeGROOVE-dev/slacker/pkg/state" + "github.com/codeGROOVE-dev/slacker/pkg/usermapping" gogithub "github.com/google/go-github/v50/github" ) // HomeHandler handles app_home_opened events for a workspace. type HomeHandler struct { - slackManager *Manager - githubManager *github.Manager - configManager *config.Manager - stateStore state.Store + slackManager *Manager + githubManager *github.Manager + configManager *config.Manager + stateStore state.Store + reverseMapping *usermapping.ReverseService } // NewHomeHandler creates a new home view handler. @@ -28,12 +30,14 @@ func NewHomeHandler( githubManager *github.Manager, configManager *config.Manager, stateStore state.Store, + reverseMapping *usermapping.ReverseService, ) *HomeHandler { return &HomeHandler{ - slackManager: slackManager, - githubManager: githubManager, - configManager: configManager, - stateStore: stateStore, + slackManager: slackManager, + githubManager: githubManager, + configManager: configManager, + stateStore: stateStore, + reverseMapping: reverseMapping, } } @@ -77,36 +81,35 @@ func (h *HomeHandler) tryHandleAppHomeOpened(ctx context.Context, teamID, slackU return fmt.Errorf("failed to get Slack client: %w", err) } - // Get Slack user info to extract email - slackUser, err := slackClient.API().GetUserInfo(slackUserID) - if err != nil { - // Don't mask invalid_auth errors - let them propagate for retry logic - if strings.Contains(err.Error(), "invalid_auth") { - return fmt.Errorf("failed to get Slack user info: %w", err) - } - slog.Warn("failed to get Slack user info", "user_id", slackUserID, "error", err) - return h.publishPlaceholderHome(ctx, slackClient, slackUserID) - } - - // Extract GitHub username from email (simple heuristic: part before @) - // Works for "username@company.com" -> "username" - email := slackUser.Profile.Email - atIndex := strings.IndexByte(email, '@') - if atIndex <= 0 { - slog.Warn("could not extract GitHub username from Slack email", - "slack_user_id", slackUserID, - "email", email) - return h.publishPlaceholderHome(ctx, slackClient, slackUserID) - } - githubUsername := email[:atIndex] - // Get all orgs for this workspace workspaceOrgs := h.workspaceOrgs(teamID) if len(workspaceOrgs) == 0 { slog.Warn("no workspace orgs found", "team_id", teamID) - return h.publishPlaceholderHome(ctx, slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID, nil) + } + + // Get config for first org to extract domain and user overrides + cfg, exists := h.configManager.Config(workspaceOrgs[0]) + if !exists { + return fmt.Errorf("no config for org: %s", workspaceOrgs[0]) } + // Update reverse mapping overrides from config + if len(cfg.Users) > 0 { + h.reverseMapping.SetOverrides(cfg.Users) + } + + // Map Slack user to GitHub username + mapping, err := h.reverseMapping.LookupGitHub(ctx, slackClient.API(), slackUserID, workspaceOrgs[0], cfg.Global.EmailDomain) + if err != nil { + slog.Warn("failed to map Slack user to GitHub", + "slack_user_id", slackUserID, + "error", err) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID, nil) + } + + githubUsername := mapping.GitHubUsername + // Get GitHub client for first org (they all share the same app) githubClient, ok := h.githubManager.ClientForOrg(workspaceOrgs[0]) if !ok { @@ -130,14 +133,14 @@ func (h *HomeHandler) tryHandleAppHomeOpened(ctx context.Context, teamID, slackU slog.Error("failed to fetch dashboard", "github_user", githubUsername, "error", err) - return h.publishPlaceholderHome(ctx, slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID, mapping) } // Add workspace orgs to dashboard for UI display dashboard.WorkspaceOrgs = workspaceOrgs - // Build Block Kit UI - use first org as primary - blocks := home.BuildBlocks(dashboard, workspaceOrgs[0]) + // Build Block Kit UI - use first org as primary, include debug info + blocks := home.BuildBlocksWithDebug(dashboard, workspaceOrgs[0], mapping) // Publish to Slack if err := slackClient.PublishHomeView(ctx, slackUserID, blocks); err != nil { @@ -177,14 +180,14 @@ func (h *HomeHandler) workspaceOrgs(teamID string) []string { } // publishPlaceholderHome publishes a simple placeholder home view. -func (*HomeHandler) publishPlaceholderHome(ctx context.Context, slackClient *Client, slackUserID string) error { +func (*HomeHandler) publishPlaceholderHome(ctx context.Context, slackClient *Client, slackUserID string, mapping *usermapping.ReverseMapping) error { slog.Debug("publishing placeholder home", "user_id", slackUserID) - blocks := home.BuildBlocks(&home.Dashboard{ + blocks := home.BuildBlocksWithDebug(&home.Dashboard{ IncomingPRs: nil, OutgoingPRs: nil, WorkspaceOrgs: []string{"your-org"}, - }, "your-org") + }, "your-org", mapping) return slackClient.PublishHomeView(ctx, slackUserID, blocks) } diff --git a/pkg/usermapping/reverse.go b/pkg/usermapping/reverse.go new file mode 100644 index 0000000..820a66c --- /dev/null +++ b/pkg/usermapping/reverse.go @@ -0,0 +1,256 @@ +// Package usermapping provides GitHub-to-Slack user mapping functionality. +package usermapping + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + ghmailto "github.com/codeGROOVE-dev/gh-mailto/pkg/gh-mailto" + "github.com/slack-go/slack" +) + +// ReverseService handles Slack-to-GitHub user mapping (reverse of the main Service). +type ReverseService struct { + orgCache *ghmailto.OrgCacheService + cache map[string]*ReverseMapping // slackUserID -> ReverseMapping + overrides map[string]string // githubUsername -> email (from config) + cacheMu sync.RWMutex +} + +// ReverseMapping represents a Slack-to-GitHub user mapping. +type ReverseMapping struct { + CachedAt time.Time + SlackUserID string + SlackUsername string + SlackEmail string + GitHubUsername string + MatchMethod string // "email_lookup", "guess", "config_override" + Confidence int // Match confidence (0-100) +} + +// NewReverseService creates a new reverse mapping service (Slack → GitHub). +func NewReverseService(slackClient *slack.Client, githubToken string) *ReverseService { + return &ReverseService{ + orgCache: ghmailto.NewOrgCacheService(githubToken), + cache: make(map[string]*ReverseMapping), + overrides: make(map[string]string), + } +} + +// SetOverrides updates the manual user mapping overrides from config. +// Format: githubUsername -> email +func (s *ReverseService) SetOverrides(overrides map[string]string) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + s.overrides = overrides + slog.Info("updated user mapping overrides", "count", len(overrides)) +} + +// LookupGitHub attempts to find a GitHub username for a Slack user ID. +// It uses email matching via gh-mailto to find the best GitHub username match. +func (s *ReverseService) LookupGitHub(ctx context.Context, slackClient SlackAPI, slackUserID, organization, domain string) (*ReverseMapping, error) { + // Check cache first + if m := s.cachedMapping(slackUserID); m != nil { + slog.Debug("using cached Slack-to-GitHub mapping", + "slack_user_id", slackUserID, + "github_username", m.GitHubUsername, + "confidence", m.Confidence, + "age_hours", time.Since(m.CachedAt).Hours()) + return m, nil + } + + slog.Debug("performing Slack-to-GitHub user lookup", + "slack_user_id", slackUserID, + "organization", organization, + "domain", domain) + + // Get Slack user info to extract email + slackUser, err := slackClient.GetUserInfo(slackUserID) + if err != nil { + slog.Warn("failed to get Slack user info", + "slack_user_id", slackUserID, + "error", err) + return nil, fmt.Errorf("failed to get Slack user info: %w", err) + } + + email := slackUser.Profile.Email + if email == "" { + slog.Warn("Slack user has no email", + "slack_user_id", slackUserID, + "slack_username", slackUser.Name) + return nil, fmt.Errorf("Slack user has no email: %s", slackUserID) + } + + // Check if this email has a config override + for githubUsername, overrideEmail := range s.overrides { + if strings.EqualFold(overrideEmail, email) { + slog.Info("using config override for user mapping", + "slack_user_id", slackUserID, + "slack_email", email, + "github_username", githubUsername, + "override_email", overrideEmail) + + mapping := &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: slackUserID, + SlackUsername: slackUser.Name, + SlackEmail: email, + GitHubUsername: githubUsername, + MatchMethod: "config_override", + Confidence: 100, + } + s.cacheMapping(mapping) + return mapping, nil + } + } + + // Perform reverse lookup using org-wide cache + githubUsername, confidence, matchMethod, err := s.reverseEmailLookup(ctx, email, organization) + if err != nil { + slog.Warn("reverse email lookup failed", + "slack_user_id", slackUserID, + "slack_email", email, + "error", err) + // Cache negative result + mapping := &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: slackUserID, + SlackUsername: slackUser.Name, + SlackEmail: email, + Confidence: 0, + } + s.cacheMapping(mapping) + return nil, fmt.Errorf("no GitHub user found for email: %s", email) + } + + mapping := &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: slackUserID, + SlackUsername: slackUser.Name, + SlackEmail: email, + GitHubUsername: githubUsername, + MatchMethod: matchMethod, + Confidence: confidence, + } + + slog.Info("successfully mapped Slack user to GitHub", + "slack_user_id", slackUserID, + "slack_email", email, + "github_username", githubUsername, + "confidence", confidence, + "match_method", matchMethod) + + s.cacheMapping(mapping) + return mapping, nil +} + +// reverseEmailLookup performs the actual reverse lookup: email → GitHub username. +// Uses the org-wide identity cache for fast, reliable lookups. +func (s *ReverseService) reverseEmailLookup(ctx context.Context, email, organization string) (username string, confidence int, matchMethod string, err error) { + slog.Info("starting reverse email lookup via org cache", + "email", email, + "organization", organization) + + // Get org-wide identity cache + cache, err := s.orgCache.OrgCache(ctx, organization) + if err != nil { + return "", 0, "", fmt.Errorf("failed to get org cache: %w", err) + } + + // Look up GitHub username from email + githubUsername, found := cache.LookupUsername(email) + if !found { + slog.Warn("email not found in org cache", + "email", email, + "organization", organization, + "total_identities", len(cache.Identities)) + return "", 0, "", fmt.Errorf("email not found in org: %s", email) + } + + // Find the identity to get confidence and source + var identity *ghmailto.OrgIdentity + for i := range cache.Identities { + if cache.Identities[i].GitHubUsername == githubUsername { + identity = &cache.Identities[i] + break + } + } + + // Calculate confidence based on source + confidence = 85 // Default confidence for org cache hit + matchMethod = "org_cache" + if identity != nil { + if identity.Verified { + confidence = 95 + matchMethod = "org_cache_verified" + } + slog.Info("found GitHub user via org cache", + "email", email, + "github_username", githubUsername, + "source", identity.Source, + "verified", identity.Verified, + "confidence", confidence) + } + + return githubUsername, confidence, matchMethod, nil +} + +// cachedMapping retrieves a cached reverse mapping. +func (s *ReverseService) cachedMapping(slackUserID string) *ReverseMapping { + s.cacheMu.RLock() + defer s.cacheMu.RUnlock() + + if mapping, exists := s.cache[slackUserID]; exists { + if time.Since(mapping.CachedAt) < cacheTTL { + return mapping + } + // Expired, remove it lazily + } + + return nil +} + +// cacheMapping stores a reverse mapping in the cache. +func (s *ReverseService) cacheMapping(mapping *ReverseMapping) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + // Clean up expired entries while we have the write lock + now := time.Now() + for key, cached := range s.cache { + if now.Sub(cached.CachedAt) >= cacheTTL { + delete(s.cache, key) + } + } + + // Store the mapping + s.cache[mapping.SlackUserID] = mapping +} + +// ClearCache clears the reverse mapping cache. +func (s *ReverseService) ClearCache() { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + s.cache = make(map[string]*ReverseMapping) + slog.Info("cleared reverse user mapping cache") +} + +// CacheStats returns cache statistics for reverse mappings. +func (s *ReverseService) CacheStats() (total int, expired int) { + s.cacheMu.RLock() + defer s.cacheMu.RUnlock() + + total = len(s.cache) + now := time.Now() + for _, mapping := range s.cache { + if now.Sub(mapping.CachedAt) >= cacheTTL { + expired++ + } + } + + return total, expired +} diff --git a/pkg/usermapping/reverse_test.go b/pkg/usermapping/reverse_test.go new file mode 100644 index 0000000..99e5537 --- /dev/null +++ b/pkg/usermapping/reverse_test.go @@ -0,0 +1,244 @@ +package usermapping + +import ( + "context" + "testing" + "time" + + ghmailto "github.com/codeGROOVE-dev/gh-mailto/pkg/gh-mailto" + "github.com/slack-go/slack" +) + +// mockSlackClient implements SlackAPI for testing. +type mockSlackClient struct { + users map[string]*slack.User +} + +func (m *mockSlackClient) GetUserByEmailContext(_ context.Context, email string) (*slack.User, error) { + for _, user := range m.users { + if user.Profile.Email == email { + return user, nil + } + } + return nil, &slack.SlackErrorResponse{Err: "user_not_found"} +} + +func (m *mockSlackClient) GetUserInfo(userID string) (*slack.User, error) { + if user, exists := m.users[userID]; exists { + return user, nil + } + return nil, &slack.SlackErrorResponse{Err: "user_not_found"} +} + +// mockOrgCache implements the org cache for testing. +func createMockOrgCache(org string, identities []ghmailto.OrgIdentity) *ghmailto.OrgIdentityCache { + cache := &ghmailto.OrgIdentityCache{ + Organization: org, + CachedAt: time.Now(), + Identities: identities, + EmailToGitHub: make(map[string]string), + GitHubToEmail: make(map[string]string), + TotalMembers: len(identities), + } + + for i := range identities { + identity := &identities[i] + if identity.PrimaryEmail != "" { + cache.GitHubToEmail[identity.GitHubUsername] = identity.PrimaryEmail + cache.EmailToGitHub[identity.PrimaryEmail] = identity.GitHubUsername + } + } + + return cache +} + +func TestReverseMapping_ConfigOverride(t *testing.T) { + mockSlack := &mockSlackClient{ + users: map[string]*slack.User{ + "U12345": { + ID: "U12345", + Name: "testuser", + Profile: slack.UserProfile{ + Email: "test@company.com", + }, + }, + }, + } + + service := NewReverseService(nil, "fake-token") + service.SetOverrides(map[string]string{ + "githubuser": "test@company.com", + }) + + ctx := context.Background() + mapping, err := service.LookupGitHub(ctx, mockSlack, "U12345", "test-org", "company.com") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if mapping.GitHubUsername != "githubuser" { + t.Errorf("expected github username 'githubuser', got: %s", mapping.GitHubUsername) + } + + if mapping.MatchMethod != "config_override" { + t.Errorf("expected match method 'config_override', got: %s", mapping.MatchMethod) + } + + if mapping.Confidence != 100 { + t.Errorf("expected confidence 100, got: %d", mapping.Confidence) + } +} + +func TestReverseMapping_CacheHit(t *testing.T) { + mockSlack := &mockSlackClient{ + users: map[string]*slack.User{ + "U12345": { + ID: "U12345", + Name: "testuser", + Profile: slack.UserProfile{ + Email: "test@company.com", + }, + }, + }, + } + + service := NewReverseService(nil, "fake-token") + + // Populate cache manually + service.cache["U12345"] = &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: "U12345", + SlackUsername: "testuser", + SlackEmail: "test@company.com", + GitHubUsername: "cached-user", + MatchMethod: "cached", + Confidence: 90, + } + + ctx := context.Background() + mapping, err := service.LookupGitHub(ctx, mockSlack, "U12345", "test-org", "company.com") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if mapping.GitHubUsername != "cached-user" { + t.Errorf("expected cached username 'cached-user', got: %s", mapping.GitHubUsername) + } + + if mapping.MatchMethod != "cached" { + t.Errorf("expected match method 'cached', got: %s", mapping.MatchMethod) + } +} + +func TestReverseMapping_CacheExpiry(t *testing.T) { + service := NewReverseService(nil, "fake-token") + + // Populate cache with expired entry + service.cache["U12345"] = &ReverseMapping{ + CachedAt: time.Now().Add(-25 * time.Hour), // Expired (>24h) + SlackUserID: "U12345", + GitHubUsername: "expired-user", + } + + // Should return nil for expired cache + mapping := service.cachedMapping("U12345") + if mapping != nil { + t.Errorf("expected nil for expired cache, got: %v", mapping) + } +} + +func TestReverseMapping_CacheStats(t *testing.T) { + service := NewReverseService(nil, "fake-token") + + // Add some cache entries + service.cache["U1"] = &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: "U1", + } + service.cache["U2"] = &ReverseMapping{ + CachedAt: time.Now().Add(-25 * time.Hour), // Expired + SlackUserID: "U2", + } + service.cache["U3"] = &ReverseMapping{ + CachedAt: time.Now(), + SlackUserID: "U3", + } + + total, expired := service.CacheStats() + if total != 3 { + t.Errorf("expected total 3, got: %d", total) + } + if expired != 1 { + t.Errorf("expected expired 1, got: %d", expired) + } +} + +func TestReverseMapping_ClearCache(t *testing.T) { + service := NewReverseService(nil, "fake-token") + + // Add cache entries + service.cache["U1"] = &ReverseMapping{SlackUserID: "U1"} + service.cache["U2"] = &ReverseMapping{SlackUserID: "U2"} + + service.ClearCache() + + total, _ := service.CacheStats() + if total != 0 { + t.Errorf("expected cache to be empty after clear, got: %d entries", total) + } +} + +func TestReverseMapping_SlackUserNotFound(t *testing.T) { + mockSlack := &mockSlackClient{ + users: map[string]*slack.User{}, + } + + service := NewReverseService(nil, "fake-token") + + ctx := context.Background() + _, err := service.LookupGitHub(ctx, mockSlack, "U99999", "test-org", "company.com") + if err == nil { + t.Fatal("expected error for non-existent Slack user, got nil") + } +} + +func TestReverseMapping_NoEmail(t *testing.T) { + mockSlack := &mockSlackClient{ + users: map[string]*slack.User{ + "U12345": { + ID: "U12345", + Name: "testuser", + Profile: slack.UserProfile{ + Email: "", // No email + }, + }, + }, + } + + service := NewReverseService(nil, "fake-token") + + ctx := context.Background() + _, err := service.LookupGitHub(ctx, mockSlack, "U12345", "test-org", "company.com") + if err == nil { + t.Fatal("expected error for user with no email, got nil") + } +} + +func TestReverseMapping_SetOverrides(t *testing.T) { + service := NewReverseService(nil, "fake-token") + + overrides := map[string]string{ + "user1": "user1@example.com", + "user2": "user2@example.com", + } + + service.SetOverrides(overrides) + + if len(service.overrides) != 2 { + t.Errorf("expected 2 overrides, got: %d", len(service.overrides)) + } + + if service.overrides["user1"] != "user1@example.com" { + t.Errorf("expected override for user1, got: %s", service.overrides["user1"]) + } +} diff --git a/pkg/usermapping/usermapping.go b/pkg/usermapping/usermapping.go index b4e1fd2..2bea0b7 100644 --- a/pkg/usermapping/usermapping.go +++ b/pkg/usermapping/usermapping.go @@ -39,6 +39,7 @@ type UserMapping struct { // SlackAPI defines the interface for Slack API operations needed by the mapping service. type SlackAPI interface { GetUserByEmailContext(ctx context.Context, email string) (*slack.User, error) + GetUserInfo(userID string) (*slack.User, error) } // GitHubEmailLookup defines the interface for GitHub email lookup operations. diff --git a/pkg/usermapping/usermapping_test.go b/pkg/usermapping/usermapping_test.go index 4cfcc33..a604a7c 100644 --- a/pkg/usermapping/usermapping_test.go +++ b/pkg/usermapping/usermapping_test.go @@ -17,6 +17,7 @@ var errMockNotFound = errors.New("mock: not found") // MockSlackAPI mocks the Slack API for testing. type MockSlackAPI struct { getUserByEmailFunc func(ctx context.Context, email string) (*slack.User, error) + getUserInfoFunc func(userID string) (*slack.User, error) } func (m *MockSlackAPI) GetUserByEmailContext(ctx context.Context, email string) (*slack.User, error) { @@ -26,6 +27,13 @@ func (m *MockSlackAPI) GetUserByEmailContext(ctx context.Context, email string) return nil, errMockNotFound } +func (m *MockSlackAPI) GetUserInfo(userID string) (*slack.User, error) { + if m.getUserInfoFunc != nil { + return m.getUserInfoFunc(userID) + } + return nil, errMockNotFound +} + // MockGitHubLookup mocks the GitHub email lookup for testing. type MockGitHubLookup struct { lookupFunc func(ctx context.Context, username, organization string) (*ghmailto.Result, error) From c73122d7a2946ae3f8fd1ca4bbe7bd78df7ec1a7 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Thu, 30 Oct 2025 09:25:25 -0400 Subject: [PATCH 2/2] update gh-mailto --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 826abe2..3c1224f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.1 require ( github.com/codeGROOVE-dev/ds9 v0.6.0 - github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251024133418-149270eb16a9 + github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251030132316-7b86852c2928 github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22 github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4 github.com/codeGROOVE-dev/retry v1.3.0 @@ -38,5 +38,3 @@ require ( golang.org/x/text v0.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) - -replace github.com/codeGROOVE-dev/gh-mailto => ../gh-mailto diff --git a/go.sum b/go.sum index 7cb4e04..d997bfd 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/codeGROOVE-dev/ds9 v0.6.0 h1:JG7vBH17UAKaVoeQilrIvA1I0fg3iNbdUMBSDS7ixgI= github.com/codeGROOVE-dev/ds9 v0.6.0/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= +github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251030132316-7b86852c2928 h1:fDiQ7GnN6tDUIrYqXKCmxcatFzZqr+Bp3aNBA0Q2AVk= +github.com/codeGROOVE-dev/gh-mailto v0.0.0-20251030132316-7b86852c2928/go.mod h1:4Hr2ySB8dcpeZqZq/7UbXdEJ/5RK9coYGHvW90ZfieE= github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22 h1:gtN3rOc6YspO646BkcOxBhPjEqKUz+jl175jIqglfDg= github.com/codeGROOVE-dev/gsm v0.0.0-20251019065141-833fe2363d22/go.mod h1:KV+w19ubP32PxZPE1hOtlCpTaNpF0Bpb32w5djO8UTg= github.com/codeGROOVE-dev/prx v0.0.0-20251030022101-ff906928a1e4 h1:DSuoUwP3oyR4cHrX0cUh9c7CtYjXNIcyCmqpIwHilIU=