Files
forge/services/webhook/webhook.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

265 lines
7.2 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/hanzoai/git/models/db"
repo_model "github.com/hanzoai/git/models/repo"
user_model "github.com/hanzoai/git/models/user"
webhook_model "github.com/hanzoai/git/models/webhook"
"github.com/hanzoai/git/modules/git"
"github.com/hanzoai/git/modules/glob"
"github.com/hanzoai/git/modules/graceful"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/optional"
"github.com/hanzoai/git/modules/queue"
"github.com/hanzoai/git/modules/setting"
api "github.com/hanzoai/git/modules/structs"
"github.com/hanzoai/git/modules/util"
webhook_module "github.com/hanzoai/git/modules/webhook"
)
type Requester func(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error)
var webhookRequesters = map[webhook_module.HookType]Requester{}
func RegisterWebhookRequester(hookType webhook_module.HookType, requester Requester) {
webhookRequesters[hookType] = requester
}
// IsValidHookTaskType returns true if a webhook registered
func IsValidHookTaskType(name string) bool {
if name == webhook_module.NATIVE || name == webhook_module.GOGS {
return true
}
_, ok := webhookRequesters[name]
return ok
}
// hookQueue is a global queue of web hooks
var hookQueue *queue.WorkerPoolQueue[int64]
// getPayloadRef returns the full ref name for hook event, if applicable.
func getPayloadRef(p api.Payloader) git.RefName {
switch pp := p.(type) {
case *api.CreatePayload:
switch pp.RefType {
case "branch":
return git.RefNameFromBranch(pp.Ref)
case "tag":
return git.RefNameFromTag(pp.Ref)
}
case *api.DeletePayload:
switch pp.RefType {
case "branch":
return git.RefNameFromBranch(pp.Ref)
case "tag":
return git.RefNameFromTag(pp.Ref)
}
case *api.PushPayload:
return git.RefName(pp.Ref)
}
return ""
}
// EventSource represents the source of a webhook action. Repository and/or Owner must be set.
type EventSource struct {
Repository *repo_model.Repository
Owner *user_model.User
}
// handle delivers hook tasks
func handler(items ...int64) []int64 {
ctx := graceful.GetManager().HammerContext()
for _, taskID := range items {
task, err := webhook_model.GetHookTaskByID(ctx, taskID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
log.Warn("GetHookTaskByID[%d] warn: %v", taskID, err)
} else {
log.Error("GetHookTaskByID[%d] failed: %v", taskID, err)
}
continue
}
if task.IsDelivered {
// Already delivered in the meantime
log.Trace("Task[%d] has already been delivered", task.ID)
continue
}
if err := Deliver(ctx, task); err != nil {
log.Error("Unable to deliver webhook task[%d]: %v", task.ID, err)
}
}
return nil
}
func enqueueHookTask(taskID int64) error {
err := hookQueue.Push(taskID)
if err != nil && err != queue.ErrAlreadyInQueue {
return err
}
return nil
}
func checkBranchFilter(branchFilter string, ref git.RefName) bool {
if branchFilter == "" || branchFilter == "*" || branchFilter == "**" {
return true
}
g, err := glob.Compile(branchFilter)
if err != nil {
// should not really happen as BranchFilter is validated
log.Debug("checkBranchFilter failed to compile filer %q, err: %s", branchFilter, err)
return false
}
if ref.IsBranch() && g.Match(ref.BranchName()) {
return true
}
return g.Match(ref.String())
}
// PrepareTestWebhook always creates and enqueues a hook task for manual testing.
// Unlike PrepareWebhook, it ignores event subscriptions and branch filters so the
// Test Push Event control can verify delivery even when those gates would suppress
// a real event.
func PrepareTestWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
if setting.DisableWebhooks {
return nil
}
payload, err := p.JSONPayload()
if err != nil {
return fmt.Errorf("JSONPayload for %s: %w", event, err)
}
task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
HookID: w.ID,
PayloadContent: string(payload),
EventType: event,
PayloadVersion: 2,
})
if err != nil {
return fmt.Errorf("CreateHookTask for %s: %w", event, err)
}
return enqueueHookTask(task.ID)
}
// PrepareWebhook creates a hook task and enqueues it for processing.
// The payload is saved as-is. The adjustments depending on the webhook type happen
// right before delivery, in the [Deliver] method.
func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
// Skip sending if webhooks are disabled.
if setting.DisableWebhooks {
return nil
}
if !w.HasEvent(event) {
return nil
}
// Avoid sending "0 new commits" to non-integration relevant webhooks (e.g. slack, discord, etc.).
// Integration webhooks (e.g. drone) still receive the required data.
if pushEvent, ok := p.(*api.PushPayload); ok &&
w.Type != webhook_module.NATIVE && w.Type != webhook_module.GOGS &&
len(pushEvent.Commits) == 0 {
return nil
}
// If payload has no associated branch (e.g. it's a new tag, issue, etc.), branch filter has no effect.
if ref := getPayloadRef(p); ref != "" {
// Check the payload's git ref against the webhook's branch filter.
if !checkBranchFilter(w.BranchFilter, ref) {
return nil
}
}
payload, err := p.JSONPayload()
if err != nil {
return fmt.Errorf("JSONPayload for %s: %w", event, err)
}
task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
HookID: w.ID,
PayloadContent: string(payload),
EventType: event,
PayloadVersion: 2,
})
if err != nil {
return fmt.Errorf("CreateHookTask for %s: %w", event, err)
}
return enqueueHookTask(task.ID)
}
// PrepareWebhooks adds new webhooks to task queue for given payload.
func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_module.HookEventType, p api.Payloader) error {
owner := source.Owner
var ws []*webhook_model.Webhook
if source.Repository != nil {
repoHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{
RepoID: source.Repository.ID,
IsActive: optional.Some(true),
})
if err != nil {
return fmt.Errorf("ListWebhooksByOpts: %w", err)
}
ws = append(ws, repoHooks...)
owner = source.Repository.MustOwner(ctx)
}
// append additional webhooks of a user or organization
if owner != nil {
ownerHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{
OwnerID: owner.ID,
IsActive: optional.Some(true),
})
if err != nil {
return fmt.Errorf("ListWebhooksByOpts: %w", err)
}
ws = append(ws, ownerHooks...)
}
// Add any admin-defined system webhooks
systemHooks, err := webhook_model.GetSystemWebhooks(ctx, optional.Some(true))
if err != nil {
return fmt.Errorf("GetSystemWebhooks: %w", err)
}
ws = append(ws, systemHooks...)
if len(ws) == 0 {
return nil
}
for _, w := range ws {
if err := PrepareWebhook(ctx, w, event, p); err != nil {
return err
}
}
return nil
}
// ReplayHookTask replays a webhook task
func ReplayHookTask(ctx context.Context, w *webhook_model.Webhook, uuid string) error {
task, err := webhook_model.ReplayHookTask(ctx, w.ID, uuid)
if err != nil {
return err
}
return enqueueHookTask(task.ID)
}