// Package webhook provides webhook handling services. package webhook import ( "context" "database/sql" "fmt" "log/slog" "go.uber.org/fx" "sneak.berlin/go/upaas/internal/database" "sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/service/deploy" ) // ServiceParams contains dependencies for Service. type ServiceParams struct { fx.In Logger *logger.Logger Database *database.Database Deploy *deploy.Service } // Service provides webhook handling functionality. type Service struct { log *slog.Logger db *database.Database deploy *deploy.Service params *ServiceParams } // New creates a new webhook Service. func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { return &Service{ log: params.Logger.Get(), db: params.Database, deploy: params.Deploy, params: ¶ms, }, nil } // HandleWebhook processes a webhook request from any supported source // (Gitea, GitHub, or GitLab). The source parameter determines which // payload format to use for parsing. func (svc *Service) HandleWebhook( ctx context.Context, app *models.App, source Source, eventType string, payload []byte, ) error { svc.log.Info("processing webhook", "app", app.Name, "source", source.String(), "event", eventType, ) // Parse payload into normalized push event pushEvent, parseErr := ParsePushPayload(source, payload) if parseErr != nil { svc.log.Warn("failed to parse webhook payload", "error", parseErr, "source", source.String(), ) // Continue with empty push event to still log the webhook pushEvent = &PushEvent{Source: source} } // Check if branch matches matched := pushEvent.Branch == app.Branch // Create webhook event record event := models.NewWebhookEvent(svc.db) event.AppID = app.ID event.EventType = eventType event.Branch = pushEvent.Branch event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""} event.CommitURL = sql.NullString{ String: pushEvent.CommitURL.String(), Valid: pushEvent.CommitURL != "", } event.Payload = sql.NullString{String: string(payload), Valid: true} event.Matched = matched event.Processed = false saveErr := event.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save webhook event: %w", saveErr) } svc.log.Info("webhook event recorded", "app", app.Name, "source", source.String(), "branch", pushEvent.Branch, "matched", matched, "commit", pushEvent.After, ) // If branch matches, trigger deployment if matched { svc.triggerDeployment(ctx, app, event) } return nil } func (svc *Service) triggerDeployment( ctx context.Context, app *models.App, event *models.WebhookEvent, ) { // Capture values for goroutine eventID := event.ID appName := app.Name go func() { // Use context.WithoutCancel to ensure deployment completes // even if the HTTP request context is cancelled. deployCtx := context.WithoutCancel(ctx) deployErr := svc.deploy.Deploy(deployCtx, app, &eventID, true) if deployErr != nil { svc.log.Error("deployment failed", "error", deployErr, "app", appName) } // Mark event as processed event.Processed = true _ = event.Save(deployCtx) }() }