forked from sneak/upaas
## Summary Adds GitHub and GitLab push webhook support alongside the existing Gitea support. closes #68 ## What Changed ### Auto-detection of webhook source The webhook handler now auto-detects which platform sent the webhook by examining HTTP headers: - **Gitea**: `X-Gitea-Event` - **GitHub**: `X-GitHub-Event` - **GitLab**: `X-Gitlab-Event` Existing Gitea webhooks continue to work unchanged. Unknown sources fall back to Gitea format for backward compatibility. ### Normalized push event All three payload formats are parsed into a unified `PushEvent` struct containing: - Source platform, ref, branch, commit SHA - Repository name, clone URL, HTML URL - Commit URL (with per-platform fallback logic) - Pusher username/name ### New files - **`internal/service/webhook/payloads.go`**: Source-specific payload structs (`GiteaPushPayload`, `GitHubPushPayload`, `GitLabPushPayload`), `ParsePushPayload()` dispatcher, per-platform parsers, branch extraction, and commit URL extraction functions. ### Modified files - **`internal/service/webhook/types.go`**: Added `Source` type (gitea/github/gitlab/unknown), `DetectWebhookSource()`, `DetectEventType()`, and `PushEvent` normalized type. Moved `GiteaPushPayload` to payloads.go. - **`internal/service/webhook/webhook.go`**: `HandleWebhook` now accepts a `Source` parameter and uses `ParsePushPayload()` for unified parsing instead of directly unmarshaling Gitea payloads. - **`internal/handlers/webhook.go`**: Calls `DetectWebhookSource()` and `DetectEventType()` to auto-detect the platform before delegating to the webhook service. - **`internal/service/webhook/webhook_test.go`**: Comprehensive tests for source detection, event type extraction, payload parsing (all 3 platforms), commit URL fallback paths, and integration tests through `HandleWebhook` for GitHub and GitLab sources. - **`README.md`**: Updated description, features, non-goals, and architecture to reflect multi-platform webhook support. ## Test coverage Webhook package: **96.9%** statement coverage. Tests cover: - `DetectWebhookSource` with all header combinations and precedence - `DetectEventType` for each platform - `ParsePushPayload` for Gitea, GitHub, GitLab, unknown source, invalid JSON, empty payloads - Commit URL extraction fallback paths for GitHub and GitLab - Direct struct deserialization for all three payload types - Full `HandleWebhook` integration tests with GitHub and GitLab sources Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: sneak/upaas#170 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
135 lines
3.2 KiB
Go
135 lines
3.2 KiB
Go
// 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)
|
|
}()
|
|
}
|