// Package webhook provides webhook handling services. package webhook import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/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 } // GiteaPushPayload represents a Gitea push webhook payload. // //nolint:tagliatelle // Field names match Gitea API (snake_case) type GiteaPushPayload struct { Ref string `json:"ref"` Before string `json:"before"` After string `json:"after"` CompareURL string `json:"compare_url"` Repository struct { FullName string `json:"full_name"` CloneURL string `json:"clone_url"` SSHURL string `json:"ssh_url"` HTMLURL string `json:"html_url"` } `json:"repository"` Pusher struct { Username string `json:"username"` Email string `json:"email"` } `json:"pusher"` Commits []struct { ID string `json:"id"` URL string `json:"url"` Message string `json:"message"` Author struct { Name string `json:"name"` Email string `json:"email"` } `json:"author"` } `json:"commits"` } // HandleWebhook processes a webhook request. func (svc *Service) HandleWebhook( ctx context.Context, app *models.App, eventType string, payload []byte, ) error { svc.log.Info("processing webhook", "app", app.Name, "event", eventType) // Parse payload var pushPayload GiteaPushPayload unmarshalErr := json.Unmarshal(payload, &pushPayload) if unmarshalErr != nil { svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr) // Continue anyway to log the event } // Extract branch from ref branch := extractBranch(pushPayload.Ref) commitSHA := pushPayload.After commitURL := extractCommitURL(pushPayload) // Check if branch matches matched := branch == app.Branch // Create webhook event record event := models.NewWebhookEvent(svc.db) event.AppID = app.ID event.EventType = eventType event.Branch = branch event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} event.CommitURL = sql.NullString{String: commitURL, Valid: 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, "branch", branch, "matched", matched, "commit", commitSHA, ) // 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) }() } // extractBranch extracts the branch name from a git ref. func extractBranch(ref string) string { // refs/heads/main -> main const prefix = "refs/heads/" if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix { return ref[len(prefix):] } return ref } // extractCommitURL extracts the commit URL from the webhook payload. // Prefers the URL from the head commit, falls back to constructing from repo URL. func extractCommitURL(payload GiteaPushPayload) string { // Try to find the URL from the head commit (matching After SHA) for _, commit := range payload.Commits { if commit.ID == payload.After && commit.URL != "" { return commit.URL } } // Fall back to constructing URL from repo HTML URL if payload.Repository.HTMLURL != "" && payload.After != "" { return payload.Repository.HTMLURL + "/commit/" + payload.After } return "" }