Files
gitea-notification-hub/internal/identity/cache/cache.go
T
JimKarvo 673cefeb90 fix: add MANUAL_USER_MAPPINGS support for users with mismatched emails
JimKarvo's Gitea email (dimitris@jksoftware.gr) differs from Slack
email (jimkarvo@gmail.com), so the email-based Slack lookup fails.
Add MANUAL_USER_MAPPINGS env var (format: GiteaUsername:SlackID,...)
to bypass email lookup when a manual mapping exists.

Changes:
- cache.go: ParseManualMappings() parses env var format
- cache.go: CachedResolver checks manual mappings first (step 0)
- main.go: reads MANUAL_USER_MAPPINGS env var, passes to resolver
2026-06-29 22:34:22 +03:00

171 lines
5.1 KiB
Go

package cache
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog"
"github.com/vincentc-afk/gitea-notification-hub/internal/event"
"github.com/vincentc-afk/gitea-notification-hub/internal/identity"
"github.com/vincentc-afk/gitea-notification-hub/internal/storage"
)
// ParseManualMappings parses a MANUAL_USER_MAPPINGS env var string
// Format: "GiteaUsername:SlackID,GiteaUser2:SlackID2"
func ParseManualMappings(raw string) map[string]string {
mappings := make(map[string]string)
raw = strings.TrimSpace(raw)
if raw == "" {
return mappings
}
for _, pair := range strings.Split(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.SplitN(pair, ":", 2)
if len(parts) == 2 {
username := strings.TrimSpace(parts[0])
slackID := strings.TrimSpace(parts[1])
if username != "" && slackID != "" {
mappings[username] = slackID
}
}
}
return mappings
}
// CachedResolver implements identity.Resolver with caching
// It stores resolved identities in the database and only queries
// external APIs when a user is not found in the cache
type CachedResolver struct {
repo storage.Repository
emailLookup identity.EmailLookup
slackLookup identity.SlackLookup
manualMappings map[string]string // GiteaUsername -> SlackID
logger zerolog.Logger
}
// NewCachedResolver creates a new cached identity resolver
func NewCachedResolver(
repo storage.Repository,
emailLookup identity.EmailLookup,
slackLookup identity.SlackLookup,
manualMappings map[string]string,
logger zerolog.Logger,
) *CachedResolver {
if manualMappings == nil {
manualMappings = make(map[string]string)
}
return &CachedResolver{
repo: repo,
emailLookup: emailLookup,
slackLookup: slackLookup,
manualMappings: manualMappings,
logger: logger.With().Str("component", "identity-resolver").Logger(),
}
}
// Resolve returns the external identity for a Gitea user
// It follows this strategy:
// 0. Check manual mappings (MANUAL_USER_MAPPINGS env var) - highest priority
// 1. Check DB by Gitea username - if found with Slack ID, return cached result
// 2. If not found, use Gitea API to get the real email (not the webhook email which may be noreply)
// 3. Lookup Slack by email
// 4. Cache the result for future lookups
func (r *CachedResolver) Resolve(ctx context.Context, user event.User) (*identity.ResolvedIdentity, error) {
logger := r.logger.With().
Str("gitea_username", user.GiteaUsername).
Str("webhook_email", user.Email).
Logger()
// Step 0: Check manual mappings first (highest priority)
if user.GiteaUsername != "" {
if slackID, ok := r.manualMappings[user.GiteaUsername]; ok {
logger.Info().
Str("slack_id", slackID).
Msg("resolved user via manual mapping")
return &identity.ResolvedIdentity{
Email: user.Email,
SlackID: slackID,
SlackName: "",
}, nil
}
}
// Step 1: Try to find by Gitea username in cache
if user.GiteaUsername != "" {
dbUser, err := r.repo.GetUserByGiteaUsername(ctx, user.GiteaUsername)
if err == nil && dbUser.SlackID != "" {
logger.Debug().
Str("cached_email", dbUser.Email).
Str("slack_id", dbUser.SlackID).
Msg("found user in cache by username")
return &identity.ResolvedIdentity{
Email: dbUser.Email,
SlackID: dbUser.SlackID,
SlackName: dbUser.SlackName,
}, nil
}
if err != nil && !storage.IsNotFound(err) {
logger.Error().Err(err).Msg("error querying user by username")
}
}
// Step 2: Not in cache - need to lookup real email via Gitea API
// We don't trust the webhook email because it might be a noreply address
logger.Info().Msg("user not in cache, querying Gitea API for real email")
if user.GiteaUsername == "" {
return nil, fmt.Errorf("no username available to lookup user")
}
if r.emailLookup == nil {
return nil, fmt.Errorf("no email lookup provider configured")
}
// Get real email from Gitea API
email, err := r.emailLookup.LookupEmail(ctx, user.GiteaUsername)
if err != nil {
logger.Error().Err(err).Msg("failed to lookup email from Gitea API")
return nil, fmt.Errorf("looking up email for %s: %w", user.GiteaUsername, err)
}
logger.Info().Str("real_email", email).Msg("got real email from Gitea API")
// Step 3: Lookup Slack ID by real email
slackID, slackName, err := r.slackLookup.LookupSlackIDByEmail(ctx, email)
if err != nil {
logger.Warn().Err(err).Str("email", email).Msg("failed to lookup Slack ID")
return nil, fmt.Errorf("looking up Slack ID for %s: %w", email, err)
}
// Step 4: Cache the result
dbUser := &storage.User{
GiteaUsername: user.GiteaUsername,
GiteaID: user.GiteaID,
Email: strings.ToLower(email),
FullName: user.FullName,
SlackID: slackID,
SlackName: slackName,
}
if err := r.repo.UpsertUser(ctx, dbUser); err != nil {
logger.Error().Err(err).Msg("failed to cache user identity")
// Continue anyway, we have the identity
} else {
logger.Info().
Str("slack_id", slackID).
Str("slack_name", slackName).
Msg("cached user identity")
}
return &identity.ResolvedIdentity{
Email: email,
SlackID: slackID,
SlackName: slackName,
}, nil
}