upaas/internal/service/webhook/webhook.go
sneak c4362c3143 Add commit URL to Slack notifications with link and backtick formatting
- Add commit_url column to webhook_events and deployments tables
- Extract commit URL from webhook payload (from commit object or repo URL)
- Format Slack messages with backticks for branch and commit SHA
- Link commit SHA to the actual commit URL on the git server
- Keep plain text format for ntfy notifications
2025-12-31 16:29:22 -08:00

186 lines
4.6 KiB
Go

// 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: &params,
}, 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)
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 ""
}