17 Commits

Author SHA1 Message Date
JimKarvo 4882221530 test: trigger webhook notification test 2026-06-29 20:44:36 +00:00
JimKarvo c288c85b84 fix: replace base64 content with actual shell script 2026-06-29 20:42:11 +00:00
JimKarvo 39f49fbba8 fix: write actual shell script instead of base64 content 2026-06-29 20:41:06 +00:00
JimKarvo aafb820299 fix: correct docker-entrypoint.sh - replace base64-encoded content with actual shell script 2026-06-29 20:39:28 +00:00
JimKarvo 718569eda7 fix: restore events.go whitespace to canonical form 2026-06-29 20:24:48 +00:00
JimKarvo 72b50b88f2 fix: restore processor.go to correct EventHandler architecture (was slack.Client corruption) 2026-06-29 20:24:40 +00:00
JimKarvo c8b2dbe9dd fix: restore main.go to correct architecture (was urfave/cli corruption) 2026-06-29 20:24:18 +00:00
JimKarvo d33b1abd7e fix: rewrite main.go with raw Go source (was base64-corrupted) 2026-06-29 20:16:40 +00:00
JimKarvo e7d6abb942 fix: rewrite processor.go with raw Go source (was base64-corrupted) 2026-06-29 20:16:20 +00:00
JimKarvo a4af4f4f78 fix: rewrite cache.go with raw Go source (was base64-corrupted) 2026-06-29 20:15:13 +00:00
JimKarvo 357cecca84 fix: rewrite events.go with raw Go source (was base64-corrupted) 2026-06-29 20:14:47 +00:00
JimKarvo b706bc2338 fix: align docker-entrypoint.sh MANUAL_USER_MAPPINGS format with Go code
The entrypoint used jq to parse MANUAL_USER_MAPPINGS as JSON, but
ParseManualMappings() in Go uses "GiteaUsername:SlackID,GiteaUser2:SlackID2"
format. The mismatch caused "set -e" to kill the shell when jq failed
on non-JSON input, preventing the Go binary from ever starting.

Fix: replace jq JSON parsing with comma/colon splitting to match
the Go code's expected format.
2026-06-29 20:05:30 +00:00
JimKarvo 1ab139baa4 feat: support LOG_LEVEL env var for runtime log level config
Adds LOG_LEVEL env var support (debug/info/warn/error) that overrides
the --debug flag, enabling dynamic log level changes without rebuild.
2026-06-29 19:51:02 +00:00
JimKarvo 895b82096c fix: also check singular RequestedReviewer field for review_requested events
Gitea sends `requested_reviewer` (singular) field for `review_requested`
webhook actions, not always the `requested_reviewers` (plural/array).
The processor only checked the array form, so `usersToNotify` was always
empty and no notification was sent.

Also add more INFO-level logging for debugging the review_requested path.

Fixes: manual mappings worked but were never reached because the
processor never added the reviewer to the notification list.
2026-06-29 19:42:19 +00:00
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
JimKarvo 9e26698068 fix: use event.Reviewers instead of e.RequestedReviewer for review_requested 2026-06-29 18:54:59 +00:00
JimKarvo 8f08345bb2 chore: disable Renovate completely 2026-06-25 08:52:10 +00:00
6 changed files with 650 additions and 28 deletions
+4
View File
@@ -0,0 +1,4 @@
# Test Webhook Notification
This file exists only to trigger a test webhook notification.
Delete after testing.
+9 -2
View File
@@ -67,8 +67,15 @@ func main() {
slackClient := slacknotifier.New(&cfg.Notification.Slack, logger) slackClient := slacknotifier.New(&cfg.Notification.Slack, logger)
logger.Info().Msg("Slack notifier initialized") logger.Info().Msg("Slack notifier initialized")
// Initialize cached identity resolver // Initialize cached identity resolver with optional manual mappings
resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, logger) manualMappings := cache.ParseManualMappings(os.Getenv("MANUAL_USER_MAPPINGS"))
if len(manualMappings) > 0 {
logger.Info().Int("count", len(manualMappings)).Msg("manual user mappings configured")
for username := range manualMappings {
logger.Debug().Str("username", username).Msg("manual mapping registered")
}
}
resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, manualMappings, logger)
logger.Info().Msg("identity resolver initialized") logger.Info().Msg("identity resolver initialized")
// Create processor adapter that implements webhook.EventHandler // Create processor adapter that implements webhook.EventHandler
+8 -7
View File
@@ -3,7 +3,6 @@ set -e
# Generate config.yaml from environment variables # Generate config.yaml from environment variables
mkdir -p /app/config mkdir -p /app/config
cat > /app/config/config.yaml << 'YAMLEOF' cat > /app/config/config.yaml << 'YAMLEOF'
server: server:
port: 8080 port: 8080
@@ -38,21 +37,23 @@ rules:
notify_reviewers: true notify_reviewers: true
YAMLEOF YAMLEOF
# Seed manual user mappings from MANUAL_USER_MAPPINGS env var (JSON: {"gitea_username":"slack_id",...}) # Seed manual user mappings from MANUAL_USER_MAPPINGS env var
# Format: "GiteaUsername:SlackID,GiteaUser2:SlackID2" (same as Go's ParseManualMappings)
# These bypass the Gitea API email lookup + Slack email lookup # These bypass the Gitea API email lookup + Slack email lookup
if [ -n "$MANUAL_USER_MAPPINGS" ]; then if [ -n "$MANUAL_USER_MAPPINGS" ]; then
# Create table and indexes using single-line calls to avoid sh issues with multi-line SQL
sqlite3 /app/data/notifications.db "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, gitea_username TEXT UNIQUE, gitea_id INTEGER, email TEXT UNIQUE, full_name TEXT, slack_id TEXT, slack_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);" 2>/dev/null || true sqlite3 /app/data/notifications.db "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, gitea_username TEXT UNIQUE, gitea_id INTEGER, email TEXT UNIQUE, full_name TEXT, slack_id TEXT, slack_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);" 2>/dev/null || true sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);" 2>/dev/null || true sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);" 2>/dev/null || true sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);" 2>/dev/null || true
echo "$MANUAL_USER_MAPPINGS" | jq -r 'to_entries[] | "\(.key) \(.value)"' 2>/dev/null | \ echo "$MANUAL_USER_MAPPINGS" | tr ',' '\n' | while IFS=':' read -r name slack_id; do
while read -r gitea_user slack_id; do name=$(echo "$name" | xargs)
[ -z "$gitea_user" ] && continue slack_id=$(echo "$slack_id" | xargs)
[ -z "$name" ] && continue
[ -z "$slack_id" ] && continue
sqlite3 /app/data/notifications.db \ sqlite3 /app/data/notifications.db \
"INSERT OR REPLACE INTO users(gitea_username, slack_id, slack_name, updated_at) VALUES('$gitea_user','$slack_id','$gitea_user',datetime('now'));" \ "INSERT OR REPLACE INTO users(gitea_username, slack_id, slack_name, updated_at) VALUES('$name','$slack_id','$name',datetime('now'));" \
2>/dev/null || true 2>/dev/null || true
done done
fi fi
File diff suppressed because one or more lines are too long
+46
View File
@@ -12,6 +12,31 @@ import (
"github.com/vincentc-afk/gitea-notification-hub/internal/storage" "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 // CachedResolver implements identity.Resolver with caching
// It stores resolved identities in the database and only queries // It stores resolved identities in the database and only queries
// external APIs when a user is not found in the cache // external APIs when a user is not found in the cache
@@ -19,6 +44,7 @@ type CachedResolver struct {
repo storage.Repository repo storage.Repository
emailLookup identity.EmailLookup emailLookup identity.EmailLookup
slackLookup identity.SlackLookup slackLookup identity.SlackLookup
manualMappings map[string]string // GiteaUsername -> SlackID
logger zerolog.Logger logger zerolog.Logger
} }
@@ -27,18 +53,24 @@ func NewCachedResolver(
repo storage.Repository, repo storage.Repository,
emailLookup identity.EmailLookup, emailLookup identity.EmailLookup,
slackLookup identity.SlackLookup, slackLookup identity.SlackLookup,
manualMappings map[string]string,
logger zerolog.Logger, logger zerolog.Logger,
) *CachedResolver { ) *CachedResolver {
if manualMappings == nil {
manualMappings = make(map[string]string)
}
return &CachedResolver{ return &CachedResolver{
repo: repo, repo: repo,
emailLookup: emailLookup, emailLookup: emailLookup,
slackLookup: slackLookup, slackLookup: slackLookup,
manualMappings: manualMappings,
logger: logger.With().Str("component", "identity-resolver").Logger(), logger: logger.With().Str("component", "identity-resolver").Logger(),
} }
} }
// Resolve returns the external identity for a Gitea user // Resolve returns the external identity for a Gitea user
// It follows this strategy: // 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 // 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) // 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 // 3. Lookup Slack by email
@@ -49,6 +81,20 @@ func (r *CachedResolver) Resolve(ctx context.Context, user event.User) (*identit
Str("webhook_email", user.Email). Str("webhook_email", user.Email).
Logger() 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 // Step 1: Try to find by Gitea username in cache
if user.GiteaUsername != "" { if user.GiteaUsername != "" {
dbUser, err := r.repo.GetUserByGiteaUsername(ctx, user.GiteaUsername) dbUser, err := r.repo.GetUserByGiteaUsername(ctx, user.GiteaUsername)