package event import ( "context" "strings" "time" "github.com/rs/zerolog" "github.com/vincentc-afk/gitea-notification-hub/internal/config" "github.com/vincentc-afk/gitea-notification-hub/internal/webhook" ) // IdentityResolver resolves Gitea users to external identities (e.g., Slack) type IdentityResolver interface { Resolve(ctx context.Context, user User) (*ResolvedIdentity, error) } // ResolvedIdentity represents a resolved external identity type ResolvedIdentity struct { Email string `json:"email"` SlackID string `json:"slack_id"` SlackName string `json:"slack_name"` } // Notifier sends notifications type Notifier interface { SendDirect(ctx context.Context, userID string, msg *Notification) error SendChannel(ctx context.Context, channel string, msg *Notification) error } // Processor processes webhook events and generates notifications type Processor struct { cfg *config.Config resolver IdentityResolver notifier Notifier logger zerolog.Logger } // NewProcessor creates a new event processor func NewProcessor(cfg *config.Config, resolver IdentityResolver, notifier Notifier, logger zerolog.Logger) *Processor { return &Processor{ cfg: cfg, resolver: resolver, notifier: notifier, logger: logger.With().Str("component", "processor").Logger(), } } // HandlePullRequest processes pull request events func (p *Processor) HandlePullRequest(e *webhook.PullRequestEvent) { ctx := context.Background() event := p.normalizePREvent(e) p.logger.Debug(). Str("action", e.Action). Int("assignees_count", len(event.Assignees)). Int("reviewers_count", len(event.Reviewers)). Str("actor", event.Actor.GiteaUsername). Msg("processing PR event") var usersToNotify []struct { user User reason NotificationReason } switch e.Action { case "opened": event.Type = TypePROpened // Notify assignees and reviewers if p.cfg.Rules.PR.NotifyAssignees { for _, u := range event.Assignees { p.logger.Debug().Str("assignee", u.GiteaUsername).Msg("adding assignee to notify") usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonAssignee}) } } if p.cfg.Rules.PR.NotifyReviewers { for _, u := range event.Reviewers { p.logger.Debug().Str("reviewer", u.GiteaUsername).Msg("adding reviewer to notify") usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonReviewer}) } } case "closed": if e.PullRequest.Merged { event.Type = TypePRMerged } else { event.Type = TypePRClosed } // Notify owner if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } case "assigned": event.Type = TypePRAssigned // Notify newly assigned user if e.Assignee != nil && p.cfg.Rules.PR.NotifyAssignees { p.logger.Debug().Str("assignee", e.Assignee.Login).Msg("adding newly assigned user to notify") usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{giteaUserToUser(*e.Assignee), ReasonAssignee}) } case "review_requested": event.Type = TypePRReviewRequested // Notify newly requested reviewer (singular - Gitea sends one at a time) if e.RequestedReviewer != nil { p.logger.Debug().Str("requested_reviewer", e.RequestedReviewer.Login).Msg("review_requested event") usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{giteaUserToUser(*e.RequestedReviewer), ReasonReviewer}) } else { p.logger.Debug().Msg("review_requested event but no RequestedReviewer field") } case "synchronize": event.Type = TypePRSynchronized // Add commits info to event event.Commits = make([]CommitInfo, 0, len(e.Commits)) for _, c := range e.Commits { // Get first line of commit message msg := c.Message if idx := strings.Index(msg, "\n"); idx > 0 { msg = msg[:idx] } event.Commits = append(event.Commits, CommitInfo{ SHA: c.ID[:7], // Short SHA Message: msg, URL: c.URL, Author: c.Author.Name, }) } // Notify owner (will be filtered out if they pushed the commits) if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } // Notify reviewers if p.cfg.Rules.PR.NotifyReviewers { for _, u := range event.Reviewers { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonReviewer}) } } // Notify assignees if p.cfg.Rules.PR.NotifyAssignees { for _, u := range event.Assignees { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonAssignee}) } } } // Remove the actor from notifications (don't notify yourself) beforeFilter := len(usersToNotify) usersToNotify = p.filterOutActor(usersToNotify, event.Actor) p.logger.Debug(). Int("before_filter", beforeFilter). Int("after_filter", len(usersToNotify)). Str("actor_filtered", event.Actor.GiteaUsername). Msg("filtered out actor from notifications") // Send notifications p.sendNotifications(ctx, event, usersToNotify) } // HandlePullRequestReview processes PR review events func (p *Processor) HandlePullRequestReview(e *webhook.PullRequestReviewEvent) { ctx := context.Background() event := p.normalizeReviewEvent(e) var usersToNotify []struct { user User reason NotificationReason } // Notify PR owner about the review if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } // Remove the actor usersToNotify = p.filterOutActor(usersToNotify, event.Actor) p.sendNotifications(ctx, event, usersToNotify) } // HandlePullRequestComment processes PR comment events func (p *Processor) HandlePullRequestComment(e *webhook.PullRequestCommentEvent) { ctx := context.Background() event := p.normalizePRCommentEvent(e) if e.Action != "created" { return // Only notify on new comments } var usersToNotify []struct { user User reason NotificationReason } // Notify PR owner if p.cfg.Rules.Comment.NotifyThreadOwner && event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } // Extract and notify mentioned users if p.cfg.Rules.Comment.NotifyMentioned { mentionedUsers := ExtractMentions(e.Comment.Body) for _, username := range mentionedUsers { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{User{GiteaUsername: username}, ReasonMention}) } } // Notify reviewers if p.cfg.Rules.Comment.NotifyReviewers { for _, u := range event.Reviewers { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonReviewer}) } } // Remove the actor usersToNotify = p.filterOutActor(usersToNotify, event.Actor) p.sendNotifications(ctx, event, usersToNotify) } // HandleIssue processes issue events func (p *Processor) HandleIssue(e *webhook.IssueEvent) { ctx := context.Background() event := p.normalizeIssueEvent(e) var usersToNotify []struct { user User reason NotificationReason } switch e.Action { case "opened": event.Type = TypeIssueOpened // Notify assignees if p.cfg.Rules.Issue.NotifyAssignees { for _, u := range event.Assignees { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{u, ReasonAssignee}) } } case "closed": event.Type = TypeIssueClosed // Notify owner if event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } } // Remove the actor usersToNotify = p.filterOutActor(usersToNotify, event.Actor) p.sendNotifications(ctx, event, usersToNotify) } // HandleIssueComment processes issue comment events func (p *Processor) HandleIssueComment(e *webhook.IssueCommentEvent) { ctx := context.Background() event := p.normalizeIssueCommentEvent(e) if e.Action != "created" { return } var usersToNotify []struct { user User reason NotificationReason } // Notify issue owner if p.cfg.Rules.Comment.NotifyThreadOwner && event.Owner != nil { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{*event.Owner, ReasonOwner}) } // Extract and notify mentioned users if p.cfg.Rules.Comment.NotifyMentioned { mentionedUsers := ExtractMentions(e.Comment.Body) for _, username := range mentionedUsers { usersToNotify = append(usersToNotify, struct { user User reason NotificationReason }{User{GiteaUsername: username}, ReasonMention}) } } // Remove the actor usersToNotify = p.filterOutActor(usersToNotify, event.Actor) p.sendNotifications(ctx, event, usersToNotify) } // sendNotifications resolves users and sends notifications func (p *Processor) sendNotifications(ctx context.Context, event *Event, users []struct { user User reason NotificationReason }) { // Deduplicate users seen := make(map[string]bool) for _, u := range users { key := u.user.GiteaUsername if key == "" { key = u.user.Email } if seen[key] { continue } seen[key] = true // Resolve user identity identity, err := p.resolver.Resolve(ctx, u.user) if err != nil { p.logger.Warn(). Err(err). Str("username", u.user.GiteaUsername). Msg("failed to resolve user identity") continue } if identity.SlackID == "" { p.logger.Debug(). Str("username", u.user.GiteaUsername). Msg("user has no Slack ID, skipping notification") continue } // Create notification notification := &Notification{ TargetUser: u.user, Event: event, Reason: u.reason, Message: p.formatMessage(event, u.reason), } // Send DM if err := p.notifier.SendDirect(ctx, identity.SlackID, notification); err != nil { p.logger.Error(). Err(err). Str("slack_id", identity.SlackID). Msg("failed to send notification") } else { p.logger.Info(). Str("slack_id", identity.SlackID). Str("reason", string(u.reason)). Str("event_type", string(event.Type)). Msg("notification sent") } } } // filterOutActor removes the event actor from the notification list func (p *Processor) filterOutActor(users []struct { user User reason NotificationReason }, actor User) []struct { user User reason NotificationReason } { var filtered []struct { user User reason NotificationReason } for _, u := range users { if u.user.GiteaUsername != actor.GiteaUsername { filtered = append(filtered, u) } } return filtered } // formatMessage creates a human-readable notification message func (p *Processor) formatMessage(event *Event, reason NotificationReason) string { // This will be enhanced later with templates switch event.Type { case TypePROpened: return "New PR opened" case TypePRClosed: return "PR closed" case TypePRMerged: return "PR merged" case TypePRReviewRequested: return "Review requested" case TypePRReviewed: return "PR reviewed" case TypePRCommented: return "New comment on PR" case TypeIssueOpened: return "New issue opened" case TypeIssueClosed: return "Issue closed" case TypeIssueCommented: return "New comment on issue" default: return "New notification" } } // Normalization helpers func (p *Processor) normalizePREvent(e *webhook.PullRequestEvent) *Event { owner := giteaUserToUser(e.PullRequest.User) // Get reviewers from both possible locations: // - e.PullRequest.RequestedReviewers: present in opened action // - e.RequestedReviewers: present in review_requested action var reviewers []User if len(e.PullRequest.RequestedReviewers) > 0 { reviewers = giteaUsersToUsers(e.PullRequest.RequestedReviewers) } else if len(e.RequestedReviewers) > 0 { reviewers = giteaUsersToUsers(e.RequestedReviewers) } return &Event{ Timestamp: time.Now(), Actor: giteaUserToUser(e.Sender), RepoName: e.Repository.Name, RepoFullName: e.Repository.FullName, RepoURL: e.Repository.HTMLURL, Number: e.PullRequest.Number, Title: e.PullRequest.Title, Body: e.PullRequest.Body, URL: e.PullRequest.HTMLURL, Owner: &owner, Assignees: giteaUsersToUsers(e.PullRequest.Assignees), Reviewers: reviewers, } } func (p *Processor) normalizeReviewEvent(e *webhook.PullRequestReviewEvent) *Event { owner := giteaUserToUser(e.PullRequest.User) return &Event{ Type: TypePRReviewed, Timestamp: time.Now(), Actor: giteaUserToUser(e.Sender), RepoName: e.Repository.Name, RepoFullName: e.Repository.FullName, RepoURL: e.Repository.HTMLURL, Number: e.PullRequest.Number, Title: e.PullRequest.Title, URL: e.PullRequest.HTMLURL, Owner: &owner, ReviewState: e.Review.State, CommentBody: e.Review.Body, } } func (p *Processor) normalizePRCommentEvent(e *webhook.PullRequestCommentEvent) *Event { owner := giteaUserToUser(e.PullRequest.User) return &Event{ Type: TypePRCommented, Timestamp: time.Now(), Actor: giteaUserToUser(e.Sender), RepoName: e.Repository.Name, RepoFullName: e.Repository.FullName, RepoURL: e.Repository.HTMLURL, Number: e.PullRequest.Number, Title: e.PullRequest.Title, URL: e.PullRequest.HTMLURL, Owner: &owner, Reviewers: giteaUsersToUsers(e.PullRequest.RequestedReviewers), CommentBody: e.Comment.Body, CommentURL: e.Comment.HTMLURL, } } func (p *Processor) normalizeIssueEvent(e *webhook.IssueEvent) *Event { owner := giteaUserToUser(e.Issue.User) return &Event{ Timestamp: time.Now(), Actor: giteaUserToUser(e.Sender), RepoName: e.Repository.Name, RepoFullName: e.Repository.FullName, RepoURL: e.Repository.HTMLURL, Number: e.Issue.Number, Title: e.Issue.Title, Body: e.Issue.Body, URL: e.Issue.HTMLURL, Owner: &owner, Assignees: giteaUsersToUsers(e.Issue.Assignees), } } func (p *Processor) normalizeIssueCommentEvent(e *webhook.IssueCommentEvent) *Event { owner := giteaUserToUser(e.Issue.User) return &Event{ Type: TypeIssueCommented, Timestamp: time.Now(), Actor: giteaUserToUser(e.Sender), RepoName: e.Repository.Name, RepoFullName: e.Repository.FullName, RepoURL: e.Repository.HTMLURL, Number: e.Issue.Number, Title: e.Issue.Title, URL: e.Issue.HTMLURL, Owner: &owner, CommentBody: e.Comment.Body, CommentURL: e.Comment.HTMLURL, } } func giteaUserToUser(u webhook.GiteaUser) User { username := u.Login if username == "" { username = u.Username } return User{ GiteaID: u.ID, GiteaUsername: username, Email: u.Email, FullName: u.FullName, } } func giteaUsersToUsers(users []webhook.GiteaUser) []User { result := make([]User, len(users)) for i, u := range users { result[i] = giteaUserToUser(u) } return result }