Add String() methods to ImageID, ContainerID, and UnparsedURL. Replace all string() casts with .String() method calls throughout the codebase for cleaner interface compliance.
186 lines
4.7 KiB
Go
186 lines
4.7 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: ¶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 UnparsedURL `json:"compare_url"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
CloneURL UnparsedURL `json:"clone_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
HTMLURL UnparsedURL `json:"html_url"`
|
|
} `json:"repository"`
|
|
Pusher struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
} `json:"pusher"`
|
|
Commits []struct {
|
|
ID string `json:"id"`
|
|
URL UnparsedURL `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: 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) UnparsedURL {
|
|
// 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 UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
|
|
}
|
|
|
|
return ""
|
|
}
|