Initial import with modified Dockerfile for env-based config generation

This commit is contained in:
2026-06-22 11:56:10 +03:00
parent 28fa7537ac
commit 6bf27aa40e
25 changed files with 3228 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config holds all configuration for the application
type Config struct {
Server ServerConfig `yaml:"server"`
Identity IdentityConfig `yaml:"identity"`
Notification NotificationConfig `yaml:"notification"`
Database DatabaseConfig `yaml:"database"`
Rules RulesConfig `yaml:"rules"`
}
type ServerConfig struct {
Port int `yaml:"port"`
WebhookSecret string `yaml:"webhook_secret"`
}
type IdentityConfig struct {
Provider string `yaml:"provider"`
CacheTTL time.Duration `yaml:"cache_ttl"`
Gitea GiteaConfig `yaml:"gitea"`
}
type GiteaConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
}
type NotificationConfig struct {
Provider string `yaml:"provider"`
Slack SlackConfig `yaml:"slack"`
}
type SlackConfig struct {
BotToken string `yaml:"bot_token"`
DefaultChannel string `yaml:"default_channel"`
}
type DatabaseConfig struct {
Driver string `yaml:"driver"`
DSN string `yaml:"dsn"`
}
type RulesConfig struct {
PR PRRules `yaml:"pr"`
Issue IssueRules `yaml:"issue"`
Comment CommentRules `yaml:"comment"`
}
type PRRules struct {
NotifyOwner bool `yaml:"notify_owner"`
NotifyReviewers bool `yaml:"notify_reviewers"`
NotifyAssignees bool `yaml:"notify_assignees"`
}
type IssueRules struct {
NotifyAssignees bool `yaml:"notify_assignees"`
}
type CommentRules struct {
NotifyMentioned bool `yaml:"notify_mentioned"`
NotifyThreadOwner bool `yaml:"notify_thread_owner"`
NotifyReviewers bool `yaml:"notify_reviewers"`
}
// Load reads configuration from a YAML file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
// Expand environment variables
data = []byte(os.ExpandEnv(string(data)))
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Set defaults
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Identity.CacheTTL == 0 {
cfg.Identity.CacheTTL = 24 * time.Hour
}
if cfg.Database.Driver == "" {
cfg.Database.Driver = "sqlite"
}
return &cfg, nil
}
+86
View File
@@ -0,0 +1,86 @@
package event
import (
"regexp"
"strings"
)
// mentionRegex matches @username patterns
// Supports alphanumeric characters, underscores, and hyphens
var mentionRegex = regexp.MustCompile(`@([a-zA-Z0-9_-]+)`)
// ExtractMentions extracts all @mentioned usernames from text
func ExtractMentions(text string) []string {
if text == "" {
return nil
}
matches := mentionRegex.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
// Deduplicate mentions
seen := make(map[string]bool)
var usernames []string
for _, match := range matches {
if len(match) < 2 {
continue
}
username := strings.ToLower(match[1])
// Skip common false positives
if isCommonFalsePositive(username) {
continue
}
if !seen[username] {
seen[username] = true
usernames = append(usernames, username)
}
}
return usernames
}
// ReplaceMentionsWithSlackIDs replaces @username with <@SLACK_ID> format
func ReplaceMentionsWithSlackIDs(text string, usernameToSlackID map[string]string) string {
if text == "" || len(usernameToSlackID) == 0 {
return text
}
result := mentionRegex.ReplaceAllStringFunc(text, func(match string) string {
username := strings.ToLower(strings.TrimPrefix(match, "@"))
if slackID, ok := usernameToSlackID[username]; ok {
return "<@" + slackID + ">"
}
return match // Keep original if no mapping found
})
return result
}
// isCommonFalsePositive checks if a mention is likely not a real username
func isCommonFalsePositive(username string) bool {
// Common patterns that look like mentions but aren't
falsePositives := map[string]bool{
"param": true,
"returns": true,
"throws": true,
"deprecated": true,
"see": true,
"link": true,
"code": true,
"example": true,
"todo": true,
"fixme": true,
"note": true,
"warning": true,
"author": true,
"version": true,
"since": true,
}
return falsePositives[username]
}
+564
View File
@@ -0,0 +1,564 @@
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
}
+100
View File
@@ -0,0 +1,100 @@
package event
import "time"
// Type represents the normalized event type
type Type string
const (
TypePROpened Type = "pr_opened"
TypePRClosed Type = "pr_closed"
TypePRMerged Type = "pr_merged"
TypePRAssigned Type = "pr_assigned"
TypePRReviewRequested Type = "pr_review_requested"
TypePRReviewed Type = "pr_reviewed"
TypePRCommented Type = "pr_commented"
TypePRSynchronized Type = "pr_synchronized" // New commits pushed
TypeIssueOpened Type = "issue_opened"
TypeIssueClosed Type = "issue_closed"
TypeIssueCommented Type = "issue_commented"
)
// User represents a user in our normalized event model
type User struct {
GiteaID int64 `json:"gitea_id"`
GiteaUsername string `json:"gitea_username"`
Email string `json:"email"`
FullName string `json:"full_name"`
}
// Event is a normalized event from Gitea
type Event struct {
ID string `json:"id"` // Delivery ID from Gitea
Type Type `json:"type"` // Normalized event type
Timestamp time.Time `json:"timestamp"` // When the event occurred
// The user who triggered the event
Actor User `json:"actor"`
// Repository info
RepoName string `json:"repo_name"`
RepoFullName string `json:"repo_full_name"`
RepoURL string `json:"repo_url"`
// PR/Issue info
Number int64 `json:"number"` // PR or Issue number
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
// Users to potentially notify (based on roles)
Owner *User `json:"owner,omitempty"` // PR/Issue author
Assignees []User `json:"assignees,omitempty"` // Assigned users
Reviewers []User `json:"reviewers,omitempty"` // Requested reviewers (PR only)
// For review events
ReviewState string `json:"review_state,omitempty"` // APPROVED, CHANGES_REQUESTED, COMMENT
// For comment events
CommentBody string `json:"comment_body,omitempty"`
CommentURL string `json:"comment_url,omitempty"`
// For synchronize events (new commits pushed)
Commits []CommitInfo `json:"commits,omitempty"`
}
// CommitInfo represents a commit in a synchronized event
type CommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
URL string `json:"url"`
Author string `json:"author"`
}
// Notification represents a notification to be sent
type Notification struct {
// Target user to notify
TargetUser User `json:"target_user"`
// The event that triggered this notification
Event *Event `json:"event"`
// Why this user is being notified
Reason NotificationReason `json:"reason"`
// Message content
Message string `json:"message"`
// Channel override (empty = DM)
Channel string `json:"channel,omitempty"`
}
// NotificationReason explains why a user is being notified
type NotificationReason string
const (
ReasonOwner NotificationReason = "owner" // PR/Issue author
ReasonAssignee NotificationReason = "assignee" // Assigned to PR/Issue
ReasonReviewer NotificationReason = "reviewer" // Requested reviewer
ReasonMention NotificationReason = "mention" // @mentioned in comment
)
+124
View File
@@ -0,0 +1,124 @@
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"
)
// 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
logger zerolog.Logger
}
// NewCachedResolver creates a new cached identity resolver
func NewCachedResolver(
repo storage.Repository,
emailLookup identity.EmailLookup,
slackLookup identity.SlackLookup,
logger zerolog.Logger,
) *CachedResolver {
return &CachedResolver{
repo: repo,
emailLookup: emailLookup,
slackLookup: slackLookup,
logger: logger.With().Str("component", "identity-resolver").Logger(),
}
}
// Resolve returns the external identity for a Gitea user
// It follows this strategy:
// 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 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
}
+125
View File
@@ -0,0 +1,125 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/rs/zerolog"
"github.com/vincentc-afk/gitea-notification-hub/internal/config"
)
// GiteaUser represents a user from Gitea API
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
// Provider implements email lookup via Gitea API
type Provider struct {
baseURL string
token string
httpClient *http.Client
logger zerolog.Logger
}
// New creates a new Gitea API provider
func New(cfg *config.GiteaConfig, logger zerolog.Logger) *Provider {
return &Provider{
baseURL: cfg.URL,
token: cfg.Token,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
logger: logger.With().Str("component", "gitea-provider").Logger(),
}
}
// LookupEmail fetches the real email for a Gitea username via API
func (p *Provider) LookupEmail(ctx context.Context, username string) (string, error) {
url := fmt.Sprintf("%s/api/v1/users/%s", p.baseURL, username)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
// Add authorization header if token is provided
if p.token != "" {
req.Header.Set("Authorization", "token "+p.token)
}
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetching user from Gitea: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("user %s not found in Gitea", username)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Gitea API returned status %d", resp.StatusCode)
}
var user GiteaUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return "", fmt.Errorf("decoding Gitea response: %w", err)
}
if user.Email == "" {
return "", fmt.Errorf("user %s has no email in Gitea", username)
}
p.logger.Debug().
Str("username", username).
Str("email", user.Email).
Str("full_name", user.FullName).
Msg("found user email via Gitea API")
return user.Email, nil
}
// GetUser fetches full user info from Gitea API
func (p *Provider) GetUser(ctx context.Context, username string) (*GiteaUser, error) {
url := fmt.Sprintf("%s/api/v1/users/%s", p.baseURL, username)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
if p.token != "" {
req.Header.Set("Authorization", "token "+p.token)
}
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching user from Gitea: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("user %s not found in Gitea", username)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Gitea API returned status %d", resp.StatusCode)
}
var user GiteaUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decoding Gitea response: %w", err)
}
return &user, nil
}
+33
View File
@@ -0,0 +1,33 @@
package identity
import (
"context"
"github.com/vincentc-afk/gitea-notification-hub/internal/event"
)
// ResolvedIdentity represents a resolved external identity
type ResolvedIdentity struct {
Email string `json:"email"`
SlackID string `json:"slack_id"`
SlackName string `json:"slack_name"`
}
// Resolver resolves Gitea users to external identities (e.g., Slack)
// This interface allows for different identity providers (Gitea API, LDAP, etc.)
type Resolver interface {
// Resolve returns the external identity for a Gitea user
Resolve(ctx context.Context, user event.User) (*ResolvedIdentity, error)
}
// EmailLookup provides email lookup functionality
type EmailLookup interface {
// LookupEmail looks up a user's email address by username
LookupEmail(ctx context.Context, username string) (string, error)
}
// SlackLookup provides Slack user lookup functionality
type SlackLookup interface {
// LookupSlackIDByEmail finds a Slack user ID by email
LookupSlackIDByEmail(ctx context.Context, email string) (slackID, slackName string, err error)
}
+17
View File
@@ -0,0 +1,17 @@
package notifier
import (
"context"
"github.com/vincentc-afk/gitea-notification-hub/internal/event"
)
// Notifier defines the interface for sending notifications
// This abstraction allows for different notification providers (Slack, Teams, Discord, etc.)
type Notifier interface {
// SendDirect sends a direct message to a user
SendDirect(ctx context.Context, userID string, msg *event.Notification) error
// SendChannel sends a message to a channel
SendChannel(ctx context.Context, channel string, msg *event.Notification) error
}
+291
View File
@@ -0,0 +1,291 @@
package slack
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog"
"github.com/slack-go/slack"
"github.com/vincentc-afk/gitea-notification-hub/internal/config"
"github.com/vincentc-afk/gitea-notification-hub/internal/event"
)
// Notifier implements notifier.Notifier and identity.SlackLookup using Slack API
type Notifier struct {
client *slack.Client
defaultChannel string
logger zerolog.Logger
}
// New creates a new Slack notifier
func New(cfg *config.SlackConfig, logger zerolog.Logger) *Notifier {
client := slack.New(cfg.BotToken)
return &Notifier{
client: client,
defaultChannel: cfg.DefaultChannel,
logger: logger.With().Str("component", "slack-notifier").Logger(),
}
}
// LookupSlackIDByEmail finds a Slack user ID by email address
// This implements identity.SlackLookup interface
func (n *Notifier) LookupSlackIDByEmail(ctx context.Context, email string) (slackID, slackName string, err error) {
user, err := n.client.GetUserByEmailContext(ctx, email)
if err != nil {
return "", "", fmt.Errorf("looking up Slack user by email %s: %w", email, err)
}
return user.ID, user.RealName, nil
}
// SendDirect sends a direct message to a Slack user
func (n *Notifier) SendDirect(ctx context.Context, userID string, msg *event.Notification) error {
// Open a DM channel with the user
channel, _, _, err := n.client.OpenConversationContext(ctx, &slack.OpenConversationParameters{
Users: []string{userID},
})
if err != nil {
return fmt.Errorf("opening DM channel: %w", err)
}
// Build message blocks
blocks := n.buildMessageBlocks(msg)
// Send message
_, _, err = n.client.PostMessageContext(ctx,
channel.ID,
slack.MsgOptionBlocks(blocks...),
slack.MsgOptionText(msg.Message, false), // Fallback text
)
if err != nil {
return fmt.Errorf("sending DM: %w", err)
}
return nil
}
// SendChannel sends a message to a Slack channel
func (n *Notifier) SendChannel(ctx context.Context, channel string, msg *event.Notification) error {
if channel == "" {
channel = n.defaultChannel
}
// Build message blocks
blocks := n.buildMessageBlocks(msg)
// Send message
_, _, err := n.client.PostMessageContext(ctx,
channel,
slack.MsgOptionBlocks(blocks...),
slack.MsgOptionText(msg.Message, false), // Fallback text
)
if err != nil {
return fmt.Errorf("sending channel message: %w", err)
}
return nil
}
// buildMessageBlocks creates Slack Block Kit message blocks
func (n *Notifier) buildMessageBlocks(msg *event.Notification) []slack.Block {
e := msg.Event
// Header with emoji based on event type
emoji := n.getEventEmoji(e.Type)
headerText := fmt.Sprintf("%s %s", emoji, n.getEventTitle(e, msg.Reason))
headerBlock := slack.NewSectionBlock(
slack.NewTextBlockObject(slack.MarkdownType, headerText, false, false),
nil, nil,
)
// Repository and PR/Issue info
contextElements := []slack.MixedElement{
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("*<%s|%s>*", e.RepoURL, e.RepoFullName), false, false),
}
if e.Number > 0 {
contextElements = append(contextElements,
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("<%s|#%d %s>", e.URL, e.Number, truncate(e.Title, 50)), false, false))
}
contextBlock := slack.NewContextBlock("", contextElements...)
blocks := []slack.Block{headerBlock, contextBlock}
// Add comment body if present
if e.CommentBody != "" {
commentText := truncate(e.CommentBody, 300)
// Replace @mentions with Slack-style if we have them
commentBlock := slack.NewSectionBlock(
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf(">>> %s", commentText), false, false),
nil, nil,
)
blocks = append(blocks, commentBlock)
}
// Add review state if present
if e.ReviewState != "" {
reviewEmoji := n.getReviewStateEmoji(e.ReviewState)
reviewBlock := slack.NewContextBlock("",
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("%s Review: *%s*", reviewEmoji, e.ReviewState), false, false))
blocks = append(blocks, reviewBlock)
}
// Add commits list if present (for synchronize events)
if len(e.Commits) > 0 {
var commitLines []string
maxCommits := 5 // Show max 5 commits
for i, c := range e.Commits {
if i >= maxCommits {
commitLines = append(commitLines, fmt.Sprintf("_... and %d more commits_", len(e.Commits)-maxCommits))
break
}
commitLines = append(commitLines, fmt.Sprintf("• `%s` %s", c.SHA, truncate(c.Message, 60)))
}
commitsText := strings.Join(commitLines, "\n")
commitsBlock := slack.NewSectionBlock(
slack.NewTextBlockObject(slack.MarkdownType, commitsText, false, false),
nil, nil,
)
blocks = append(blocks, commitsBlock)
}
// Add action button
buttonBlock := slack.NewActionBlock("actions",
slack.NewButtonBlockElement("view", e.URL,
slack.NewTextBlockObject(slack.PlainTextType, "View in Gitea", false, false)).
WithURL(e.URL),
)
blocks = append(blocks, buttonBlock)
return blocks
}
// getEventEmoji returns an emoji for the event type
func (n *Notifier) getEventEmoji(eventType event.Type) string {
switch eventType {
case event.TypePROpened:
return ":git-pull-request:"
case event.TypePRClosed:
return ":git-pull-request-closed:"
case event.TypePRMerged:
return ":git-merge:"
case event.TypePRAssigned:
return ":bust_in_silhouette:"
case event.TypePRReviewRequested:
return ":eyes:"
case event.TypePRReviewed:
return ":memo:"
case event.TypePRCommented:
return ":speech_balloon:"
case event.TypePRSynchronized:
return ":arrows_counterclockwise:"
case event.TypeIssueOpened:
return ":issue-opened:"
case event.TypeIssueClosed:
return ":issue-closed:"
case event.TypeIssueCommented:
return ":speech_balloon:"
default:
return ":bell:"
}
}
// getEventTitle returns a human-readable title for the event
func (n *Notifier) getEventTitle(e *event.Event, reason event.NotificationReason) string {
actorName := e.Actor.GiteaUsername
if e.Actor.FullName != "" {
actorName = e.Actor.FullName
}
switch e.Type {
case event.TypePROpened:
if reason == event.ReasonAssignee {
return fmt.Sprintf("*%s* assigned you to a pull request", actorName)
}
if reason == event.ReasonReviewer {
return fmt.Sprintf("*%s* requested your review on a pull request", actorName)
}
return fmt.Sprintf("*%s* opened a pull request", actorName)
case event.TypePRClosed:
return fmt.Sprintf("*%s* closed your pull request", actorName)
case event.TypePRMerged:
return fmt.Sprintf("*%s* merged your pull request", actorName)
case event.TypePRAssigned:
return fmt.Sprintf("*%s* assigned you to a pull request", actorName)
case event.TypePRReviewRequested:
return fmt.Sprintf("*%s* requested your review", actorName)
case event.TypePRReviewed:
return fmt.Sprintf("*%s* reviewed your pull request", actorName)
case event.TypePRCommented:
if reason == event.ReasonMention {
return fmt.Sprintf("*%s* mentioned you in a PR comment", actorName)
}
return fmt.Sprintf("*%s* commented on your pull request", actorName)
case event.TypePRSynchronized:
commitCount := len(e.Commits)
if commitCount == 1 {
return fmt.Sprintf("*%s* pushed 1 new commit to PR #%d", actorName, e.Number)
}
return fmt.Sprintf("*%s* pushed %d new commits to PR #%d", actorName, commitCount, e.Number)
case event.TypeIssueOpened:
if reason == event.ReasonAssignee {
return fmt.Sprintf("*%s* assigned you to an issue", actorName)
}
return fmt.Sprintf("*%s* opened an issue", actorName)
case event.TypeIssueClosed:
return fmt.Sprintf("*%s* closed your issue", actorName)
case event.TypeIssueCommented:
if reason == event.ReasonMention {
return fmt.Sprintf("*%s* mentioned you in an issue comment", actorName)
}
return fmt.Sprintf("*%s* commented on your issue", actorName)
default:
return fmt.Sprintf("New notification from *%s*", actorName)
}
}
// getReviewStateEmoji returns an emoji for the review state
func (n *Notifier) getReviewStateEmoji(state string) string {
switch strings.ToUpper(state) {
case "APPROVED":
return ":white_check_mark:"
case "CHANGES_REQUESTED":
return ":x:"
case "COMMENT":
return ":speech_balloon:"
default:
return ":memo:"
}
}
// truncate truncates a string to the specified length
func truncate(s string, maxLen int) string {
// Remove newlines for preview
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
+97
View File
@@ -0,0 +1,97 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/vincentc-afk/gitea-notification-hub/internal/config"
)
// Server represents the HTTP server
type Server struct {
cfg *config.Config
logger zerolog.Logger
router *chi.Mux
srv *http.Server
}
// New creates a new Server instance
func New(cfg *config.Config, logger zerolog.Logger) *Server {
r := chi.NewRouter()
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(requestLogger(logger))
s := &Server{
cfg: cfg,
logger: logger,
router: r,
}
// Health check endpoint
r.Get("/health", s.handleHealth)
return s
}
// Router returns the chi router for registering additional routes
func (s *Server) Router() *chi.Mux {
return s.router
}
// Start starts the HTTP server
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.cfg.Server.Port)
s.srv = &http.Server{
Addr: addr,
Handler: s.router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
s.logger.Info().Str("addr", addr).Msg("starting HTTP server")
return s.srv.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info().Msg("shutting down HTTP server")
return s.srv.Shutdown(ctx)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// requestLogger returns a middleware that logs HTTP requests
func requestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
logger.Info().
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", ww.Status()).
Dur("duration", time.Since(start)).
Msg("request")
}()
next.ServeHTTP(ww, r)
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package storage
import "time"
// User represents a user with identity mappings
type User struct {
ID int64 `json:"id"`
GiteaUsername string `json:"gitea_username"`
GiteaID int64 `json:"gitea_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
SlackID string `json:"slack_id"`
SlackName string `json:"slack_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+35
View File
@@ -0,0 +1,35 @@
package storage
import "context"
// Repository defines the interface for user storage operations
// This abstraction allows swapping SQLite for PostgreSQL or other databases
type Repository interface {
// User operations
GetUserByEmail(ctx context.Context, email string) (*User, error)
GetUserByGiteaUsername(ctx context.Context, username string) (*User, error)
GetUserBySlackID(ctx context.Context, slackID string) (*User, error)
UpsertUser(ctx context.Context, user *User) error
ListUsers(ctx context.Context) ([]*User, error)
// Migrations
Migrate(ctx context.Context) error
// Close the connection
Close() error
}
// ErrNotFound is returned when a user is not found
type ErrNotFound struct {
Message string
}
func (e *ErrNotFound) Error() string {
return e.Message
}
// IsNotFound checks if an error is a not found error
func IsNotFound(err error) bool {
_, ok := err.(*ErrNotFound)
return ok
}
+230
View File
@@ -0,0 +1,230 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/vincentc-afk/gitea-notification-hub/internal/storage"
)
// Repository implements storage.Repository using SQLite
type Repository struct {
db *sql.DB
}
// New creates a new SQLite repository
func New(dsn string) (*Repository, error) {
// Ensure directory exists
dir := filepath.Dir(dsn)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
}
db, err := sql.Open("sqlite3", dsn+"?_foreign_keys=on&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
// Test connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
return &Repository{db: db}, nil
}
// Migrate runs database migrations
func (r *Repository) Migrate(ctx context.Context) error {
schema := `
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
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);
CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);
`
_, err := r.db.ExecContext(ctx, schema)
if err != nil {
return fmt.Errorf("running migrations: %w", err)
}
return nil
}
// GetUserByEmail retrieves a user by email
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*storage.User, error) {
query := `
SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
FROM users WHERE email = ?
`
var user storage.User
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.GiteaUsername,
&user.GiteaID,
&user.Email,
&user.FullName,
&user.SlackID,
&user.SlackName,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with email %s not found", email)}
}
if err != nil {
return nil, fmt.Errorf("querying user by email: %w", err)
}
return &user, nil
}
// GetUserByGiteaUsername retrieves a user by Gitea username
func (r *Repository) GetUserByGiteaUsername(ctx context.Context, username string) (*storage.User, error) {
query := `
SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
FROM users WHERE gitea_username = ?
`
var user storage.User
err := r.db.QueryRowContext(ctx, query, username).Scan(
&user.ID,
&user.GiteaUsername,
&user.GiteaID,
&user.Email,
&user.FullName,
&user.SlackID,
&user.SlackName,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with username %s not found", username)}
}
if err != nil {
return nil, fmt.Errorf("querying user by username: %w", err)
}
return &user, nil
}
// GetUserBySlackID retrieves a user by Slack ID
func (r *Repository) GetUserBySlackID(ctx context.Context, slackID string) (*storage.User, error) {
query := `
SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
FROM users WHERE slack_id = ?
`
var user storage.User
err := r.db.QueryRowContext(ctx, query, slackID).Scan(
&user.ID,
&user.GiteaUsername,
&user.GiteaID,
&user.Email,
&user.FullName,
&user.SlackID,
&user.SlackName,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with slack_id %s not found", slackID)}
}
if err != nil {
return nil, fmt.Errorf("querying user by slack_id: %w", err)
}
return &user, nil
}
// UpsertUser inserts or updates a user
func (r *Repository) UpsertUser(ctx context.Context, user *storage.User) error {
query := `
INSERT INTO users (gitea_username, gitea_id, email, full_name, slack_id, slack_name, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
gitea_username = COALESCE(excluded.gitea_username, users.gitea_username),
gitea_id = COALESCE(excluded.gitea_id, users.gitea_id),
full_name = COALESCE(excluded.full_name, users.full_name),
slack_id = COALESCE(excluded.slack_id, users.slack_id),
slack_name = COALESCE(excluded.slack_name, users.slack_name),
updated_at = excluded.updated_at
`
_, err := r.db.ExecContext(ctx, query,
user.GiteaUsername,
user.GiteaID,
user.Email,
user.FullName,
user.SlackID,
user.SlackName,
time.Now(),
)
if err != nil {
return fmt.Errorf("upserting user: %w", err)
}
return nil
}
// ListUsers returns all users
func (r *Repository) ListUsers(ctx context.Context) ([]*storage.User, error) {
query := `
SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
FROM users ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("querying users: %w", err)
}
defer rows.Close()
var users []*storage.User
for rows.Next() {
var user storage.User
if err := rows.Scan(
&user.ID,
&user.GiteaUsername,
&user.GiteaID,
&user.Email,
&user.FullName,
&user.SlackID,
&user.SlackName,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning user: %w", err)
}
users = append(users, &user)
}
return users, nil
}
// Close closes the database connection
func (r *Repository) Close() error {
return r.db.Close()
}
+146
View File
@@ -0,0 +1,146 @@
package webhook
import "time"
// GiteaEventType represents the type of Gitea webhook event
type GiteaEventType string
const (
EventPullRequest GiteaEventType = "pull_request"
EventPullRequestReview GiteaEventType = "pull_request_review"
EventPullRequestComment GiteaEventType = "pull_request_comment"
EventIssues GiteaEventType = "issues"
EventIssueComment GiteaEventType = "issue_comment"
)
// GiteaUser represents a Gitea user in webhook payloads
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
Username string `json:"username"`
}
// Repository represents a Gitea repository
type Repository struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
}
// PullRequest represents a Gitea pull request
type PullRequest struct {
ID int64 `json:"id"`
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
User GiteaUser `json:"user"`
Assignees []GiteaUser `json:"assignees"`
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
Merged bool `json:"merged"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Issue represents a Gitea issue
type Issue struct {
ID int64 `json:"id"`
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
User GiteaUser `json:"user"`
Assignees []GiteaUser `json:"assignees"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Comment represents a comment on PR or Issue
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User GiteaUser `json:"user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Review represents a PR review
type Review struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"` // APPROVED, CHANGES_REQUESTED, COMMENT
HTMLURL string `json:"html_url"`
User GiteaUser `json:"user"`
SubmittedAt time.Time `json:"submitted_at"`
}
// Commit represents a Git commit in webhook payloads
type Commit struct {
ID string `json:"id"` // SHA
Message string `json:"message"`
URL string `json:"url"`
Author GitUser `json:"author"`
Committer GitUser `json:"committer"`
Timestamp time.Time `json:"timestamp"`
}
// GitUser represents a git author/committer (different from GiteaUser)
type GitUser struct {
Name string `json:"name"`
Email string `json:"email"`
Username string `json:"username"`
}
// PullRequestEvent is the payload for pull_request webhooks
type PullRequestEvent struct {
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
Number int64 `json:"number"`
PullRequest PullRequest `json:"pull_request"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
Commits []Commit `json:"commits"` // Present on synchronize action
}
// PullRequestReviewEvent is the payload for pull_request_review webhooks
type PullRequestReviewEvent struct {
Action string `json:"action"` // submitted
Review Review `json:"review"`
PullRequest PullRequest `json:"pull_request"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
}
// PullRequestCommentEvent is the payload for pull_request_comment webhooks
type PullRequestCommentEvent struct {
Action string `json:"action"` // created, edited, deleted
Comment Comment `json:"comment"`
PullRequest PullRequest `json:"pull_request"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
}
// IssueEvent is the payload for issues webhooks
type IssueEvent struct {
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, etc.
Issue Issue `json:"issue"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
}
// IssueCommentEvent is the payload for issue_comment webhooks
type IssueCommentEvent struct {
Action string `json:"action"` // created, edited, deleted
Comment Comment `json:"comment"`
Issue Issue `json:"issue"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
}
+137
View File
@@ -0,0 +1,137 @@
package webhook
import (
"encoding/json"
"net/http"
"github.com/rs/zerolog"
)
// EventHandler processes webhook events
type EventHandler interface {
HandlePullRequest(event *PullRequestEvent)
HandlePullRequestReview(event *PullRequestReviewEvent)
HandlePullRequestComment(event *PullRequestCommentEvent)
HandleIssue(event *IssueEvent)
HandleIssueComment(event *IssueCommentEvent)
}
// Handler handles incoming Gitea webhooks
type Handler struct {
validator *Validator
eventHandler EventHandler
logger zerolog.Logger
}
// NewHandler creates a new webhook handler
func NewHandler(secret string, eventHandler EventHandler, logger zerolog.Logger) *Handler {
return &Handler{
validator: NewValidator(secret),
eventHandler: eventHandler,
logger: logger.With().Str("component", "webhook").Logger(),
}
}
// ServeHTTP handles the webhook HTTP request
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Only accept POST requests
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Get event metadata
eventType := GetEventType(r)
deliveryID := GetDeliveryID(r)
logger := h.logger.With().
Str("delivery_id", deliveryID).
Str("event_type", string(eventType)).
Logger()
// Validate signature and read body
body, err := h.validator.ValidateRequest(r)
if err != nil {
logger.Warn().Err(err).Msg("webhook validation failed")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Respond immediately with 200 OK
// Process the event asynchronously
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"accepted"}`))
// Process the event in a goroutine
go h.processEvent(logger, eventType, body)
}
// processEvent parses and routes the event to the appropriate handler
func (h *Handler) processEvent(logger zerolog.Logger, eventType GiteaEventType, body []byte) {
var err error
switch eventType {
case EventPullRequest:
var event PullRequestEvent
if err = json.Unmarshal(body, &event); err == nil {
logger.Info().
Str("action", event.Action).
Int64("pr_number", event.Number).
Str("repo", event.Repository.FullName).
Msg("processing pull request event")
h.eventHandler.HandlePullRequest(&event)
}
case EventPullRequestReview:
var event PullRequestReviewEvent
if err = json.Unmarshal(body, &event); err == nil {
logger.Info().
Str("action", event.Action).
Str("review_state", event.Review.State).
Str("repo", event.Repository.FullName).
Msg("processing pull request review event")
h.eventHandler.HandlePullRequestReview(&event)
}
case EventPullRequestComment:
var event PullRequestCommentEvent
if err = json.Unmarshal(body, &event); err == nil {
logger.Info().
Str("action", event.Action).
Int64("pr_number", event.PullRequest.Number).
Str("repo", event.Repository.FullName).
Msg("processing pull request comment event")
h.eventHandler.HandlePullRequestComment(&event)
}
case EventIssues:
var event IssueEvent
if err = json.Unmarshal(body, &event); err == nil {
logger.Info().
Str("action", event.Action).
Int64("issue_number", event.Issue.Number).
Str("repo", event.Repository.FullName).
Msg("processing issue event")
h.eventHandler.HandleIssue(&event)
}
case EventIssueComment:
var event IssueCommentEvent
if err = json.Unmarshal(body, &event); err == nil {
logger.Info().
Str("action", event.Action).
Int64("issue_number", event.Issue.Number).
Str("repo", event.Repository.FullName).
Msg("processing issue comment event")
h.eventHandler.HandleIssueComment(&event)
}
default:
logger.Debug().Msg("ignoring unknown event type")
return
}
if err != nil {
logger.Error().Err(err).Msg("failed to parse event payload")
}
}
+75
View File
@@ -0,0 +1,75 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"strings"
)
var (
ErrMissingSignature = errors.New("missing X-Gitea-Signature header")
ErrInvalidSignature = errors.New("invalid webhook signature")
)
// Validator validates Gitea webhook signatures
type Validator struct {
secret []byte
}
// NewValidator creates a new webhook validator with the given secret
func NewValidator(secret string) *Validator {
return &Validator{
secret: []byte(secret),
}
}
// ValidateRequest validates the signature of an incoming webhook request
// It returns the request body if valid, or an error if validation fails
func (v *Validator) ValidateRequest(r *http.Request) ([]byte, error) {
// Read the body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
// Get signature from header
signature := r.Header.Get("X-Gitea-Signature")
if signature == "" {
return nil, ErrMissingSignature
}
// Validate signature
if !v.validateSignature(body, signature) {
return nil, ErrInvalidSignature
}
return body, nil
}
// validateSignature checks if the HMAC-SHA256 signature matches
func (v *Validator) validateSignature(payload []byte, signature string) bool {
// Gitea sends the signature as a hex-encoded HMAC-SHA256
mac := hmac.New(sha256.New, v.secret)
mac.Write(payload)
expectedMAC := mac.Sum(nil)
expectedSignature := hex.EncodeToString(expectedMAC)
// Handle both with and without "sha256=" prefix
signature = strings.TrimPrefix(signature, "sha256=")
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
// GetEventType extracts the event type from the request headers
func GetEventType(r *http.Request) GiteaEventType {
return GiteaEventType(r.Header.Get("X-Gitea-Event"))
}
// GetDeliveryID extracts the unique delivery ID from the request headers
func GetDeliveryID(r *http.Request) string {
return r.Header.Get("X-Gitea-Delivery")
}