diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4f899453b5f57..a6b90c0dacc87 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -383,9 +383,9 @@ func prepareMigrationTasks() []*migration { newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), - // Gitea 1.24.0 ends at database version 321 newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), + newMigration(322, "Add webhook payload optimization JSON field", v1_25.AddWebhookPayloadOptimizationColumns), } return preparedMigrations } diff --git a/models/migrations/v1_25/v322.go b/models/migrations/v1_25/v322.go new file mode 100644 index 0000000000000..c1e99bb0e53a8 --- /dev/null +++ b/models/migrations/v1_25/v322.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "xorm.io/xorm" +) + +func AddWebhookPayloadOptimizationColumns(x *xorm.Engine) error { + type Webhook struct { + MetaSettings string `xorm:"meta_settings TEXT"` + } + _, err := x.SyncWithOptions( + xorm.SyncOptions{ + IgnoreConstrains: true, + IgnoreIndices: true, + }, + new(Webhook), + ) + return err +} diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go index 7d4b2e2237db0..423557f5b9ae4 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -22,6 +22,38 @@ import ( "xorm.io/builder" ) +// MetaSettings represents the metadata settings for webhook +type MetaSettings struct { + PayloadOptimization *PayloadOptimizationConfig `json:"payload_optimization,omitempty"` // Payload optimization configuration +} + +// PayloadOptimizationConfig represents the configuration for webhook payload optimization +type PayloadOptimizationConfig struct { + Files *PayloadOptimizationItem `json:"files,omitempty"` // Files optimization config + Commits *PayloadOptimizationItem `json:"commits,omitempty"` // Commits optimization config +} + +// PayloadOptimizationItem represents a single optimization item configuration +type PayloadOptimizationItem struct { + Enable bool `json:"enable"` // Whether to enable optimization for this item + Limit int `json:"limit"` // 0: trim all (none kept), >0: keep N items (forward order), <0: keep N items (reverse order) +} + +// DefaultMetaSettings returns the default webhook meta settings +func DefaultMetaSettings() *MetaSettings { + return &MetaSettings{ + PayloadOptimization: DefaultPayloadOptimizationConfig(), + } +} + +// DefaultPayloadOptimizationConfig returns the default payload optimization configuration +func DefaultPayloadOptimizationConfig() *PayloadOptimizationConfig { + return &PayloadOptimizationConfig{ + Files: &PayloadOptimizationItem{Enable: false, Limit: 0}, + Commits: &PayloadOptimizationItem{Enable: false, Limit: 0}, + } +} + // ErrWebhookNotExist represents a "WebhookNotExist" kind of error. type ErrWebhookNotExist struct { ID int64 @@ -139,6 +171,9 @@ type Webhook struct { // HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization() HeaderAuthorizationEncrypted string `xorm:"TEXT"` + // Webhook metadata settings (JSON format) + MetaSettings string `xorm:"meta_settings TEXT"` // JSON: webhook metadata configuration + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` } @@ -346,3 +381,95 @@ func DeleteWebhookByOwnerID(ctx context.Context, ownerID, id int64) error { } return DeleteWebhookByID(ctx, id) } + +// GetMetaSettings returns the webhook meta settings +func (w *Webhook) GetMetaSettings() *MetaSettings { + if w.MetaSettings == "" { + return DefaultMetaSettings() + } + + var settings MetaSettings + if err := json.Unmarshal([]byte(w.MetaSettings), &settings); err != nil { + log.Error("Failed to unmarshal webhook meta settings: %v", err) + return DefaultMetaSettings() + } + + // Ensure payload optimization config is initialized + if settings.PayloadOptimization == nil { + settings.PayloadOptimization = DefaultPayloadOptimizationConfig() + } + + return &settings +} + +// GetPayloadOptimizationConfig returns the payload optimization configuration +func (w *Webhook) GetPayloadOptimizationConfig() *PayloadOptimizationConfig { + return w.GetMetaSettings().PayloadOptimization +} + +// SetMetaSettings sets the webhook meta settings +func (w *Webhook) SetMetaSettings(settings *MetaSettings) error { + if settings == nil { + settings = DefaultMetaSettings() + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("failed to marshal webhook meta settings: %w", err) + } + + w.MetaSettings = string(data) + return nil +} + +// SetPayloadOptimizationConfig sets the payload optimization configuration +func (w *Webhook) SetPayloadOptimizationConfig(config *PayloadOptimizationConfig) error { + settings := w.GetMetaSettings() + if config == nil { + config = DefaultPayloadOptimizationConfig() + } + settings.PayloadOptimization = config + return w.SetMetaSettings(settings) +} + +// IsPayloadOptimizationEnabled returns whether payload optimization is enabled +func (w *Webhook) IsPayloadOptimizationEnabled() bool { + config := w.GetPayloadOptimizationConfig() + return config.Files.Enable || config.Commits.Enable +} + +// GetPayloadOptimizationLimit returns the payload optimization limit +func (w *Webhook) GetPayloadOptimizationLimit() int { + config := w.GetPayloadOptimizationConfig() + if config.Files.Enable { + return config.Files.Limit + } + if config.Commits.Enable { + return config.Commits.Limit + } + return 0 +} + +// IsFilesOptimizationEnabled returns whether files optimization is enabled +func (w *Webhook) IsFilesOptimizationEnabled() bool { + config := w.GetPayloadOptimizationConfig() + return config.Files.Enable +} + +// GetFilesOptimizationLimit returns the files optimization limit +func (w *Webhook) GetFilesOptimizationLimit() int { + config := w.GetPayloadOptimizationConfig() + return config.Files.Limit +} + +// IsCommitsOptimizationEnabled returns whether commits optimization is enabled +func (w *Webhook) IsCommitsOptimizationEnabled() bool { + config := w.GetPayloadOptimizationConfig() + return config.Commits.Enable +} + +// GetCommitsOptimizationLimit returns the commits optimization limit +func (w *Webhook) GetCommitsOptimizationLimit() int { + config := w.GetPayloadOptimizationConfig() + return config.Commits.Limit +} diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index edad8fc996c14..b6305a049c461 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -330,3 +330,63 @@ func TestCleanupHookTaskTable_OlderThan_LeavesTaskEarlierThanAgeToDelete(t *test assert.NoError(t, CleanupHookTaskTable(t.Context(), OlderThan, 168*time.Hour, 0)) unittest.AssertExistsAndLoadBean(t, hookTask) } + +func TestWebhookPayloadOptimization(t *testing.T) { + webhook := &Webhook{} + + // Test default configuration + config := webhook.GetPayloadOptimizationConfig() + assert.False(t, config.Files.Enable) + assert.Equal(t, 0, config.Files.Limit) + assert.False(t, config.Commits.Enable) + assert.Equal(t, 0, config.Commits.Limit) + + // Test setting configuration via meta settings + metaSettings := &MetaSettings{ + PayloadOptimization: &PayloadOptimizationConfig{ + Files: &PayloadOptimizationItem{ + Enable: true, + Limit: 5, + }, + Commits: &PayloadOptimizationItem{ + Enable: true, + Limit: -3, + }, + }, + } + webhook.SetMetaSettings(metaSettings) + + // Test getting configuration + config = webhook.GetPayloadOptimizationConfig() + assert.True(t, config.Files.Enable) + assert.Equal(t, 5, config.Files.Limit) + assert.True(t, config.Commits.Enable) + assert.Equal(t, -3, config.Commits.Limit) + + // Test individual methods + assert.True(t, webhook.IsFilesOptimizationEnabled()) + assert.Equal(t, 5, webhook.GetFilesOptimizationLimit()) + assert.True(t, webhook.IsCommitsOptimizationEnabled()) + assert.Equal(t, -3, webhook.GetCommitsOptimizationLimit()) + assert.True(t, webhook.IsPayloadOptimizationEnabled()) + + // Test backward compatibility with direct payload optimization config setting + newConfig := &PayloadOptimizationConfig{ + Files: &PayloadOptimizationItem{ + Enable: false, + Limit: 10, + }, + Commits: &PayloadOptimizationItem{ + Enable: false, + Limit: 20, + }, + } + webhook.SetPayloadOptimizationConfig(newConfig) + + // Verify the config is properly set through meta settings + config = webhook.GetPayloadOptimizationConfig() + assert.False(t, config.Files.Enable) + assert.Equal(t, 10, config.Files.Limit) + assert.False(t, config.Commits.Enable) + assert.Equal(t, 20, config.Commits.Limit) +} diff --git a/modules/structs/hook.go b/modules/structs/hook.go index ac779a5740748..14e04d75f0a79 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -25,6 +25,8 @@ type Hook struct { Events []string `json:"events"` AuthorizationHeader string `json:"authorization_header"` Active bool `json:"active"` + // MetaSettings webhook metadata settings including payload optimization + MetaSettings map[string]any `json:"meta_settings"` // swagger:strfmt date-time Updated time.Time `json:"updated_at"` // swagger:strfmt date-time @@ -48,6 +50,8 @@ type CreateHookOption struct { Events []string `json:"events"` BranchFilter string `json:"branch_filter" binding:"GlobPattern"` AuthorizationHeader string `json:"authorization_header"` + // Webhook metadata settings including payload optimization + MetaSettings map[string]any `json:"meta_settings"` // {"payload_optimization": {"files": {"enable": bool, "limit": int}, "commits": {"enable": bool, "limit": int}}} // default: false Active bool `json:"active"` } @@ -58,7 +62,9 @@ type EditHookOption struct { Events []string `json:"events"` BranchFilter string `json:"branch_filter" binding:"GlobPattern"` AuthorizationHeader string `json:"authorization_header"` - Active *bool `json:"active"` + // Webhook metadata settings including payload optimization + MetaSettings *map[string]any `json:"meta_settings"` // {"payload_optimization": {"files": {"enable": bool, "limit": int}, "commits": {"enable": bool, "limit": int}}} + Active *bool `json:"active"` } // Payloader payload is some part of one hook diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index d7e73a0cfbb08..d1777b83580e0 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2425,6 +2425,13 @@ settings.event_package = Package settings.event_package_desc = Package created or deleted in a repository. settings.branch_filter = Branch filter settings.branch_filter_desc = Branch whitelist for push, branch creation and branch deletion events, specified as glob pattern. If empty or *, events for all branches are reported. See %[2]s documentation for syntax. Examples: master, {master,release*}. +settings.payload_optimization = Payload Size Optimization +settings.payload_optimization_files = Files +settings.payload_optimization_commits = Commits +settings.payload_optimization_enable = Enable optimization +settings.payload_optimization_enable_desc = Enable payload size optimization for this item +settings.payload_optimization_limit = Limit +settings.payload_optimization_limit_desc = 0: trim all (none kept), >0: keep N items (forward order), <0: keep N items (reverse order) settings.authorization_header = Authorization Header settings.authorization_header_desc = Will be included as authorization header for requests when present. Examples: %s. settings.active = Active @@ -3283,7 +3290,7 @@ auths.tip.github = Register a new OAuth application on %s auths.tip.gitlab_new = Register a new application on %s auths.tip.google_plus = Obtain OAuth2 client credentials from the Google API console at %s auths.tip.openid_connect = Use the OpenID Connect Discovery URL "https://{server}/.well-known/openid-configuration" to specify the endpoints -auths.tip.twitter = Go to %s, create an application and ensure that the “Allow this application to be used to Sign in with Twitter” option is enabled +auths.tip.twitter = Go to %s, create an application and ensure that the "Allow this application to be used to Sign in with Twitter" option is enabled auths.tip.discord = Register a new application on %s auths.tip.gitea = Register a new OAuth2 application. Guide can be found at %s auths.tip.yandex = Create a new application at %s. Select following permissions from the "Yandex.Passport API" section: "Access to email address", "Access to user avatar" and "Access to username, first name and surname, gender" diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 6f598f14c8b2b..fe12d976f015c 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -21,6 +21,37 @@ import ( webhook_service "code.gitea.io/gitea/services/webhook" ) +// getBoolFromMap extracts a boolean value from a map with a default fallback +// +//nolint:unparam // defaultValue is needed for generic helper function +func getBoolFromMap(m map[string]any, defaultValue bool) bool { + if val, ok := m["enable"]; ok { + if boolVal, ok := val.(bool); ok { + return boolVal + } + } + return defaultValue +} + +// getIntFromMap extracts an integer value from a map with a default fallback +// +//nolint:unparam // defaultValue is needed for generic helper function +func getIntFromMap(m map[string]any, defaultValue int) int { + if val, ok := m["limit"]; ok { + switch v := val.(type) { + case int: + return v + case float64: + return int(v) + case string: + if intVal, err := strconv.Atoi(v); err == nil { + return intVal + } + } + } + return defaultValue +} + // ListOwnerHooks lists the webhooks of the provided owner func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { opts := &webhook.ListWebhookOptions{ @@ -227,6 +258,44 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI IsActive: form.Active, Type: form.Type, } + + // Set webhook meta settings + if form.MetaSettings != nil { + metaSettings := &webhook.MetaSettings{} + + // Parse payload optimization config + if payloadOptMap, ok := form.MetaSettings["payload_optimization"].(map[string]any); ok { + payloadOptConfig := &webhook.PayloadOptimizationConfig{} + + // Parse files config + if filesConfig, ok := payloadOptMap["files"].(map[string]any); ok { + payloadOptConfig.Files = &webhook.PayloadOptimizationItem{ + Enable: getBoolFromMap(filesConfig, false), + Limit: getIntFromMap(filesConfig, 0), + } + } else { + payloadOptConfig.Files = &webhook.PayloadOptimizationItem{Enable: false, Limit: 0} + } + + // Parse commits config + if commitsConfig, ok := payloadOptMap["commits"].(map[string]any); ok { + payloadOptConfig.Commits = &webhook.PayloadOptimizationItem{ + Enable: getBoolFromMap(commitsConfig, false), + Limit: getIntFromMap(commitsConfig, 0), + } + } else { + payloadOptConfig.Commits = &webhook.PayloadOptimizationItem{Enable: false, Limit: 0} + } + + metaSettings.PayloadOptimization = payloadOptConfig + } + + if err := w.SetMetaSettings(metaSettings); err != nil { + ctx.APIErrorInternal(err) + return nil, false + } + } + err := w.SetHeaderAuthorization(form.AuthorizationHeader) if err != nil { ctx.APIErrorInternal(err) @@ -391,6 +460,43 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh w.IsActive = *form.Active } + // Update webhook meta settings + if form.MetaSettings != nil { + metaSettings := &webhook.MetaSettings{} + + // Parse payload optimization config + if payloadOptMap, ok := (*form.MetaSettings)["payload_optimization"].(map[string]any); ok { + payloadOptConfig := &webhook.PayloadOptimizationConfig{} + + // Parse files config + if filesConfig, ok := payloadOptMap["files"].(map[string]any); ok { + payloadOptConfig.Files = &webhook.PayloadOptimizationItem{ + Enable: getBoolFromMap(filesConfig, false), + Limit: getIntFromMap(filesConfig, 0), + } + } else { + payloadOptConfig.Files = &webhook.PayloadOptimizationItem{Enable: false, Limit: 0} + } + + // Parse commits config + if commitsConfig, ok := payloadOptMap["commits"].(map[string]any); ok { + payloadOptConfig.Commits = &webhook.PayloadOptimizationItem{ + Enable: getBoolFromMap(commitsConfig, false), + Limit: getIntFromMap(commitsConfig, 0), + } + } else { + payloadOptConfig.Commits = &webhook.PayloadOptimizationItem{Enable: false, Limit: 0} + } + + metaSettings.PayloadOptimization = payloadOptConfig + } + + if err := w.SetMetaSettings(metaSettings); err != nil { + ctx.APIErrorInternal(err) + return false + } + } + if err := webhook.UpdateWebhook(ctx, w); err != nil { ctx.APIErrorInternal(err) return false diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index f107449749364..735c49f544b6b 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -244,6 +244,25 @@ func createWebhook(ctx *context.Context, params webhookParams) { OwnerID: orCtx.OwnerID, IsSystemWebhook: orCtx.IsSystemWebhook, } + + // Set webhook meta settings with payload optimization config + metaSettings := &webhook.MetaSettings{ + PayloadOptimization: &webhook.PayloadOptimizationConfig{ + Files: &webhook.PayloadOptimizationItem{ + Enable: params.WebhookForm.PayloadOptimizationFilesEnable, + Limit: params.WebhookForm.PayloadOptimizationFilesLimit, + }, + Commits: &webhook.PayloadOptimizationItem{ + Enable: params.WebhookForm.PayloadOptimizationCommitsEnable, + Limit: params.WebhookForm.PayloadOptimizationCommitsLimit, + }, + }, + } + if err := w.SetMetaSettings(metaSettings); err != nil { + ctx.ServerError("SetMetaSettings", err) + return + } + err = w.SetHeaderAuthorization(params.WebhookForm.AuthorizationHeader) if err != nil { ctx.ServerError("SetHeaderAuthorization", err) @@ -295,6 +314,24 @@ func editWebhook(ctx *context.Context, params webhookParams) { w.HTTPMethod = params.HTTPMethod w.Meta = string(meta) + // Set webhook meta settings with payload optimization config + metaSettings := &webhook.MetaSettings{ + PayloadOptimization: &webhook.PayloadOptimizationConfig{ + Files: &webhook.PayloadOptimizationItem{ + Enable: params.WebhookForm.PayloadOptimizationFilesEnable, + Limit: params.WebhookForm.PayloadOptimizationFilesLimit, + }, + Commits: &webhook.PayloadOptimizationItem{ + Enable: params.WebhookForm.PayloadOptimizationCommitsEnable, + Limit: params.WebhookForm.PayloadOptimizationCommitsLimit, + }, + }, + } + if err := w.SetMetaSettings(metaSettings); err != nil { + ctx.ServerError("SetMetaSettings", err) + return + } + err = w.SetHeaderAuthorization(params.WebhookForm.AuthorizationHeader) if err != nil { ctx.ServerError("SetHeaderAuthorization", err) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index cb267f891ccb7..57e7c98db5b4c 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -239,6 +239,11 @@ type WebhookForm struct { BranchFilter string `binding:"GlobPattern"` AuthorizationHeader string Secret string + // Payload optimization settings + PayloadOptimizationFilesEnable bool `form:"payload_optimization_files_enable"` + PayloadOptimizationFilesLimit int `form:"payload_optimization_files_limit"` + PayloadOptimizationCommitsEnable bool `form:"payload_optimization_commits_enable"` + PayloadOptimizationCommitsLimit int `form:"payload_optimization_commits_limit"` } // PushOnly if the hook will be triggered when push @@ -622,7 +627,7 @@ type UpdateAllowEditsForm struct { // | _// __ \| | _/ __ \\__ \ / ___// __ \ // | | \ ___/| |_\ ___/ / __ \_\___ \\ ___/ // |____|_ /\___ >____/\___ >____ /____ >\___ > -// \/ \/ \/ \/ \/ \/ +// \/ \/ \/ \/ \/ \/ // NewReleaseForm form for creating release type NewReleaseForm struct { diff --git a/services/webhook/general.go b/services/webhook/general.go index be457e46f5f7a..d12d5224dbf34 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -409,6 +409,21 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { return nil, err } + // Convert meta settings to map + metaSettings := w.GetMetaSettings() + metaSettingsMap := map[string]any{ + "payload_optimization": map[string]any{ + "files": map[string]any{ + "enable": metaSettings.PayloadOptimization.Files.Enable, + "limit": metaSettings.PayloadOptimization.Files.Limit, + }, + "commits": map[string]any{ + "enable": metaSettings.PayloadOptimization.Commits.Enable, + "limit": metaSettings.PayloadOptimization.Commits.Limit, + }, + }, + } + return &api.Hook{ ID: w.ID, Type: w.Type, @@ -417,6 +432,7 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { Config: config, Events: w.EventsArray(), AuthorizationHeader: authorizationHeader, + MetaSettings: metaSettingsMap, Updated: w.UpdatedUnix.AsTime(), Created: w.CreatedUnix.AsTime(), BranchFilter: w.BranchFilter, diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 672abd5c95d0e..4939cd06a06fa 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -7,6 +7,7 @@ import ( "context" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -15,10 +16,12 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -640,6 +643,138 @@ func (m *webhookNotifier) IssueChangeMilestone(ctx context.Context, doer *user_m } } +// applyWebhookPayloadOptimizations applies payload optimizations based on webhook configurations +func (m *webhookNotifier) applyWebhookPayloadOptimizations(ctx context.Context, repo *repo_model.Repository, apiCommits []*api.PayloadCommit, apiHeadCommit *api.PayloadCommit) ([]*api.PayloadCommit, *api.PayloadCommit) { + // Get all webhooks for this repository + webhooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{ + RepoID: repo.ID, + IsActive: optional.Some(true), + }) + if err != nil { + log.Error("Failed to get webhooks for repository %d: %v", repo.ID, err) + // Continue with default behavior if we can't get webhooks + return apiCommits, apiHeadCommit + } + + // Check if any webhook has payload optimization options enabled + var filesLimit, commitsLimit int + hasFilesLimit := false + hasCommitsLimit := false + optimizationEnabled := false + + for _, webhook := range webhooks { + if webhook.HasEvent(webhook_module.HookEventPush) { + config := webhook.GetPayloadOptimizationConfig() + + // Check files optimization + if config.Files.Enable { + optimizationEnabled = true + if !hasFilesLimit || config.Files.Limit < filesLimit { + filesLimit = config.Files.Limit + hasFilesLimit = true + } + } + + // Check commits optimization + if config.Commits.Enable { + optimizationEnabled = true + if !hasCommitsLimit || config.Commits.Limit < commitsLimit { + commitsLimit = config.Commits.Limit + hasCommitsLimit = true + } + } + } + } + + // Apply payload optimizations based on webhook configurations + // 0: trim all (none kept), >0: trim to N items (forward order), <0: trim to N items (reverse order) + if optimizationEnabled { + // Apply files optimization to all commits + if hasFilesLimit { + for _, commit := range apiCommits { + if commit.Added != nil { + if filesLimit == 0 { + commit.Added = nil + } else if filesLimit > 0 && len(commit.Added) > filesLimit { + commit.Added = commit.Added[:filesLimit] + } else if filesLimit < 0 && len(commit.Added) > -filesLimit { + // Reverse order: keep the last N items + commit.Added = commit.Added[len(commit.Added)+filesLimit:] + } + } + if commit.Removed != nil { + if filesLimit == 0 { + commit.Removed = nil + } else if filesLimit > 0 && len(commit.Removed) > filesLimit { + commit.Removed = commit.Removed[:filesLimit] + } else if filesLimit < 0 && len(commit.Removed) > -filesLimit { + // Reverse order: keep the last N items + commit.Removed = commit.Removed[len(commit.Removed)+filesLimit:] + } + } + if commit.Modified != nil { + if filesLimit == 0 { + commit.Modified = nil + } else if filesLimit > 0 && len(commit.Modified) > filesLimit { + commit.Modified = commit.Modified[:filesLimit] + } else if filesLimit < 0 && len(commit.Modified) > -filesLimit { + // Reverse order: keep the last N items + commit.Modified = commit.Modified[len(commit.Modified)+filesLimit:] + } + } + } + + // Apply files optimization to head commit + if apiHeadCommit != nil { + if apiHeadCommit.Added != nil { + if filesLimit == 0 { + apiHeadCommit.Added = nil + } else if filesLimit > 0 && len(apiHeadCommit.Added) > filesLimit { + apiHeadCommit.Added = apiHeadCommit.Added[:filesLimit] + } else if filesLimit < 0 && len(apiHeadCommit.Added) > -filesLimit { + // Reverse order: keep the last N items + apiHeadCommit.Added = apiHeadCommit.Added[len(apiHeadCommit.Added)+filesLimit:] + } + } + if apiHeadCommit.Removed != nil { + if filesLimit == 0 { + apiHeadCommit.Removed = nil + } else if filesLimit > 0 && len(apiHeadCommit.Removed) > filesLimit { + apiHeadCommit.Removed = apiHeadCommit.Removed[:filesLimit] + } else if filesLimit < 0 && len(apiHeadCommit.Removed) > -filesLimit { + // Reverse order: keep the last N items + apiHeadCommit.Removed = apiHeadCommit.Removed[len(apiHeadCommit.Removed)+filesLimit:] + } + } + if apiHeadCommit.Modified != nil { + if filesLimit == 0 { + apiHeadCommit.Modified = nil + } else if filesLimit > 0 && len(apiHeadCommit.Modified) > filesLimit { + apiHeadCommit.Modified = apiHeadCommit.Modified[:filesLimit] + } else if filesLimit < 0 && len(apiHeadCommit.Modified) > -filesLimit { + // Reverse order: keep the last N items + apiHeadCommit.Modified = apiHeadCommit.Modified[len(apiHeadCommit.Modified)+filesLimit:] + } + } + } + } + + // Apply commits optimization + if hasCommitsLimit { + if commitsLimit == 0 { + apiCommits = nil + } else if commitsLimit > 0 && len(apiCommits) > commitsLimit { + apiCommits = apiCommits[:commitsLimit] + } else if commitsLimit < 0 && len(apiCommits) > -commitsLimit { + // Reverse order: keep the last N commits + apiCommits = apiCommits[len(apiCommits)+commitsLimit:] + } + } + } + + return apiCommits, apiHeadCommit +} + func (m *webhookNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { apiPusher := convert.ToUser(ctx, pusher, nil) apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo) @@ -648,6 +783,9 @@ func (m *webhookNotifier) PushCommits(ctx context.Context, pusher *user_model.Us return } + // Apply payload optimizations + apiCommits, apiHeadCommit = m.applyWebhookPayloadOptimizations(ctx, repo, apiCommits, apiHeadCommit) + if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventPush, &api.PushPayload{ Ref: opts.RefFullName.String(), Before: opts.OldCommitID, @@ -887,6 +1025,9 @@ func (m *webhookNotifier) SyncPushCommits(ctx context.Context, pusher *user_mode return } + // Apply payload optimizations + apiCommits, apiHeadCommit = m.applyWebhookPayloadOptimizations(ctx, repo, apiCommits, apiHeadCommit) + if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventPush, &api.PushPayload{ Ref: opts.RefFullName.String(), Before: opts.OldCommitID, diff --git a/services/webhook/webhook_test.go b/services/webhook/webhook_test.go index 5a805347e38a7..9a9477eeb9e61 100644 --- a/services/webhook/webhook_test.go +++ b/services/webhook/webhook_test.go @@ -91,3 +91,171 @@ func TestWebhookUserMail(t *testing.T) { assert.Equal(t, user.GetPlaceholderEmail(), convert.ToUser(db.DefaultContext, user, nil).Email) assert.Equal(t, user.Email, convert.ToUser(db.DefaultContext, user, user).Email) } + +func TestWebhookPayloadOptimization(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // Create test webhook + webhook := &webhook_model.Webhook{ + RepoID: repo.ID, + URL: "http://example.com/webhook", + HTTPMethod: "POST", + ContentType: webhook_model.ContentTypeJSON, + Secret: "secret", + IsActive: true, + Type: webhook_module.GITEA, + HookEvent: &webhook_module.HookEvent{ + PushOnly: true, + }, + } + + // Test case 1: No optimization enabled + webhook.SetMetaSettings(&webhook_model.MetaSettings{ + PayloadOptimization: &webhook_model.PayloadOptimizationConfig{ + Files: &webhook_model.PayloadOptimizationItem{Enable: false, Limit: 0}, + Commits: &webhook_model.PayloadOptimizationItem{Enable: false, Limit: 0}, + }, + }) + + err := webhook.UpdateEvent() + assert.NoError(t, err) + err = webhook_model.CreateWebhook(db.DefaultContext, webhook) + assert.NoError(t, err) + + apiCommits := []*api.PayloadCommit{ + { + ID: "abc123", + Message: "Test commit", + Added: []string{"file1.txt", "file2.txt"}, + Removed: []string{"oldfile.txt"}, + Modified: []string{"modified.txt"}, + }, + { + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + }, + } + apiHeadCommit := &api.PayloadCommit{ + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + } + + // Should not modify anything when optimization is disabled + optimizedCommits, optimizedHeadCommit := (&webhookNotifier{}).applyWebhookPayloadOptimizations(db.DefaultContext, repo, apiCommits, apiHeadCommit) + if assert.NotNil(t, optimizedCommits) && len(optimizedCommits) == 2 { + assert.Equal(t, []string{"file1.txt", "file2.txt"}, optimizedCommits[0].Added) + assert.Equal(t, []string{"oldfile.txt"}, optimizedCommits[0].Removed) + assert.Equal(t, []string{"modified.txt"}, optimizedCommits[0].Modified) + assert.Equal(t, []string{"file3.txt"}, optimizedCommits[1].Added) + assert.Equal(t, []string{}, optimizedCommits[1].Removed) + assert.Equal(t, []string{"file1.txt"}, optimizedCommits[1].Modified) + } + if assert.NotNil(t, optimizedHeadCommit) { + assert.Equal(t, []string{"file3.txt"}, optimizedHeadCommit.Added) + assert.Equal(t, []string{}, optimizedHeadCommit.Removed) + assert.Equal(t, []string{"file1.txt"}, optimizedHeadCommit.Modified) + } + + // Test case 2: Files optimization enabled, limit = 0 (trim all) + webhook.SetMetaSettings(&webhook_model.MetaSettings{ + PayloadOptimization: &webhook_model.PayloadOptimizationConfig{ + Files: &webhook_model.PayloadOptimizationItem{Enable: true, Limit: 0}, + Commits: &webhook_model.PayloadOptimizationItem{Enable: false, Limit: 0}, + }, + }) + err = webhook_model.UpdateWebhook(db.DefaultContext, webhook) + assert.NoError(t, err) + + apiCommits = []*api.PayloadCommit{ + { + ID: "abc123", + Message: "Test commit", + Added: []string{"file1.txt", "file2.txt"}, + Removed: []string{"oldfile.txt"}, + Modified: []string{"modified.txt"}, + }, + { + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + }, + } + apiHeadCommit = &api.PayloadCommit{ + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + } + + optimizedCommits, optimizedHeadCommit = (&webhookNotifier{}).applyWebhookPayloadOptimizations(db.DefaultContext, repo, apiCommits, apiHeadCommit) + if assert.NotNil(t, optimizedCommits) && len(optimizedCommits) == 2 { + assert.Nil(t, optimizedCommits[0].Added) + assert.Nil(t, optimizedCommits[0].Removed) + assert.Nil(t, optimizedCommits[0].Modified) + assert.Nil(t, optimizedCommits[1].Added) + assert.Nil(t, optimizedCommits[1].Removed) + assert.Nil(t, optimizedCommits[1].Modified) + } + if assert.NotNil(t, optimizedHeadCommit) { + assert.Nil(t, optimizedHeadCommit.Added) + assert.Nil(t, optimizedHeadCommit.Removed) + assert.Nil(t, optimizedHeadCommit.Modified) + } + + // Test case 3: Commits optimization enabled, limit = 1 (keep first) + webhook.SetMetaSettings(&webhook_model.MetaSettings{ + PayloadOptimization: &webhook_model.PayloadOptimizationConfig{ + Files: &webhook_model.PayloadOptimizationItem{Enable: false, Limit: 0}, + Commits: &webhook_model.PayloadOptimizationItem{Enable: true, Limit: 1}, + }, + }) + err = webhook_model.UpdateWebhook(db.DefaultContext, webhook) + assert.NoError(t, err) + + apiCommits = []*api.PayloadCommit{ + { + ID: "abc123", + Message: "Test commit", + Added: []string{"file1.txt", "file2.txt"}, + Removed: []string{"oldfile.txt"}, + Modified: []string{"modified.txt"}, + }, + { + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + }, + } + apiHeadCommit = &api.PayloadCommit{ + ID: "def456", + Message: "Another commit", + Added: []string{"file3.txt"}, + Removed: []string{}, + Modified: []string{"file1.txt"}, + } + + optimizedCommits, optimizedHeadCommit = (&webhookNotifier{}).applyWebhookPayloadOptimizations(db.DefaultContext, repo, apiCommits, apiHeadCommit) + if assert.NotNil(t, optimizedCommits) && len(optimizedCommits) == 1 { + assert.Equal(t, []string{"file1.txt", "file2.txt"}, optimizedCommits[0].Added) + assert.Equal(t, []string{"oldfile.txt"}, optimizedCommits[0].Removed) + assert.Equal(t, []string{"modified.txt"}, optimizedCommits[0].Modified) + } + if assert.NotNil(t, optimizedHeadCommit) { + assert.Equal(t, []string{"file3.txt"}, optimizedHeadCommit.Added) + assert.Equal(t, []string{}, optimizedHeadCommit.Removed) + assert.Equal(t, []string{"file1.txt"}, optimizedHeadCommit.Modified) + } +} diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl index a8ad1d6c9e5cf..0994df6801512 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -47,6 +47,50 @@ {{ctx.Locale.Tr "repo.settings.branch_filter_desc" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}} + +
+

{{ctx.Locale.Tr "repo.settings.payload_optimization"}}

+
+
{{ctx.Locale.Tr "repo.settings.payload_optimization_files"}}
+
+
+ + + {{ctx.Locale.Tr "repo.settings.payload_optimization_enable_desc"}} +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.payload_optimization_limit_desc"}} +
+
+
+
{{ctx.Locale.Tr "repo.settings.payload_optimization_commits"}}
+
+
+ + + {{ctx.Locale.Tr "repo.settings.payload_optimization_enable_desc"}} +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.payload_optimization_limit_desc"}} +
+
+
+ + +

{{ctx.Locale.Tr "repo.settings.event_desc"}}

diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 749d86901de93..573036fc44804 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22818,6 +22818,12 @@ }, "x-go-name": "Events" }, + "meta_settings": { + "description": "Webhook metadata settings including payload optimization", + "type": "object", + "additionalProperties": {}, + "x-go-name": "MetaSettings" + }, "type": { "type": "string", "enum": [ @@ -23979,6 +23985,12 @@ "type": "string" }, "x-go-name": "Events" + }, + "meta_settings": { + "description": "Webhook metadata settings including payload optimization", + "type": "object", + "additionalProperties": {}, + "x-go-name": "MetaSettings" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -25255,6 +25267,12 @@ "format": "int64", "x-go-name": "ID" }, + "meta_settings": { + "description": "MetaSettings webhook metadata settings including payload optimization", + "type": "object", + "additionalProperties": {}, + "x-go-name": "MetaSettings" + }, "type": { "type": "string", "x-go-name": "Type" diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index f1abac8cfa1fb..08953fdd7e42f 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -1293,3 +1293,98 @@ jobs: assert.Equal(t, "user2/repo1", webhookData.payloads[i].Repo.FullName) } } + +func Test_WebhookPayloadOptimizationAPI(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + // Test creating webhook with payload optimization options via API + createHookOption := map[string]any{ + "type": "gitea", + "config": map[string]string{ + "url": "http://example.com/webhook", + "content_type": "json", + }, + "events": []string{"push"}, + "meta_settings": map[string]any{ + "payload_optimization": map[string]any{ + "files": map[string]any{ + "enable": true, + "limit": 2, + }, + "commits": map[string]any{ + "enable": true, + "limit": 1, + }, + }, + }, + "active": true, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/hooks", createHookOption).AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusCreated) + + var hook api.Hook + DecodeJSON(t, resp, &hook) + + // Verify the webhook was created with correct payload optimization settings + assert.NotNil(t, hook.MetaSettings) + payloadOptConfig := hook.MetaSettings["payload_optimization"].(map[string]any) + filesConfig := payloadOptConfig["files"].(map[string]any) + commitsConfig := payloadOptConfig["commits"].(map[string]any) + assert.Equal(t, true, filesConfig["enable"]) + assert.InEpsilon(t, 2.0, filesConfig["limit"], 0.01) + assert.Equal(t, true, commitsConfig["enable"]) + assert.InEpsilon(t, 1.0, commitsConfig["limit"], 0.01) + + // Test updating webhook with different payload optimization options + editHookOption := map[string]any{ + "meta_settings": map[string]any{ + "payload_optimization": map[string]any{ + "files": map[string]any{ + "enable": false, + "limit": 0, + }, + "commits": map[string]any{ + "enable": false, + "limit": 0, + }, + }, + }, + } + + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/user2/repo1/hooks/%d", hook.ID), editHookOption).AddTokenAuth(token) + resp = session.MakeRequest(t, req, http.StatusOK) + + var updatedHook api.Hook + DecodeJSON(t, resp, &updatedHook) + + // Verify the webhook was updated with correct payload optimization settings + assert.NotNil(t, updatedHook.MetaSettings) + payloadOptConfig = updatedHook.MetaSettings["payload_optimization"].(map[string]any) + filesConfig = payloadOptConfig["files"].(map[string]any) + commitsConfig = payloadOptConfig["commits"].(map[string]any) + assert.Equal(t, false, filesConfig["enable"]) + assert.EqualValues(t, 0, filesConfig["limit"]) + assert.Equal(t, false, commitsConfig["enable"]) + assert.EqualValues(t, 0, commitsConfig["limit"]) + + // Test getting webhook to verify the settings are persisted + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/hooks/%d", hook.ID)).AddTokenAuth(token) + resp = session.MakeRequest(t, req, http.StatusOK) + + var retrievedHook api.Hook + DecodeJSON(t, resp, &retrievedHook) + + // Verify the webhook settings are correctly retrieved + assert.NotNil(t, retrievedHook.MetaSettings) + payloadOptConfig = retrievedHook.MetaSettings["payload_optimization"].(map[string]any) + filesConfig = payloadOptConfig["files"].(map[string]any) + commitsConfig = payloadOptConfig["commits"].(map[string]any) + assert.Equal(t, false, filesConfig["enable"]) + assert.EqualValues(t, 0, filesConfig["limit"]) + assert.Equal(t, false, commitsConfig["enable"]) + assert.EqualValues(t, 0, commitsConfig["limit"]) + }) +}