diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 1b1558f39d1ce..e4dc47c15cd02 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -394,6 +394,7 @@ func prepareMigrationTasks() []*migration { // 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, "Extend comment tree_path length limit", v1_25.ExtendCommentTreePathLength), + newMigration(323, "Add webhook payload optimization JSON field", v1_25.AddWebhookPayloadOptimizationColumns), } return preparedMigrations } diff --git a/models/migrations/v1_25/v323.go b/models/migrations/v1_25/v323.go new file mode 100644 index 0000000000000..c1e99bb0e53a8 --- /dev/null +++ b/models/migrations/v1_25/v323.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..3c4a29be595d9 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -22,6 +22,35 @@ import ( "xorm.io/builder" ) +// MetaSettings represents the metadata settings for webhook +type MetaSettings struct { + PayloadConfig PayloadConfig `json:"payload_config"` // Payload configuration +} + +// PayloadConfig represents the configuration for webhook payload +type PayloadConfig struct { + Files PayloadConfigItem `json:"files"` // Files configuration + Commits PayloadConfigItem `json:"commits"` // Commits configuration +} + +// PayloadConfigItem represents a single payload configuration item +type PayloadConfigItem struct { + Enable bool `json:"enable"` // Whether to enable this configuration + 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{ + PayloadConfig: DefaultPayloadConfig(), + } +} + +// DefaultPayloadConfig returns the default payload configuration +func DefaultPayloadConfig() PayloadConfig { + return PayloadConfig{} +} + // ErrWebhookNotExist represents a "WebhookNotExist" kind of error. type ErrWebhookNotExist struct { ID int64 @@ -139,6 +168,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 +378,83 @@ 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() + } + + return settings +} + +// GetPayloadConfig returns the payload configuration +func (w *Webhook) GetPayloadConfig() PayloadConfig { + return w.GetMetaSettings().PayloadConfig +} + +// SetMetaSettings sets the webhook meta settings +func (w *Webhook) SetMetaSettings(settings MetaSettings) error { + 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 +} + +// SetPayloadConfig sets the payload configuration +func (w *Webhook) SetPayloadConfig(config PayloadConfig) error { + settings := w.GetMetaSettings() + settings.PayloadConfig = config + return w.SetMetaSettings(settings) +} + +// IsPayloadConfigEnabled returns whether payload configuration is enabled +func (w *Webhook) IsPayloadConfigEnabled() bool { + config := w.GetPayloadConfig() + return config.Files.Enable || config.Commits.Enable +} + +// GetPayloadConfigLimit returns the payload configuration limit +func (w *Webhook) GetPayloadConfigLimit() int { + config := w.GetPayloadConfig() + if config.Files.Enable { + return config.Files.Limit + } + if config.Commits.Enable { + return config.Commits.Limit + } + return 0 +} + +// IsFilesConfigEnabled returns whether files configuration is enabled +func (w *Webhook) IsFilesConfigEnabled() bool { + config := w.GetPayloadConfig() + return config.Files.Enable +} + +// GetFilesConfigLimit returns the files configuration limit +func (w *Webhook) GetFilesConfigLimit() int { + config := w.GetPayloadConfig() + return config.Files.Limit +} + +// IsCommitsConfigEnabled returns whether commits configuration is enabled +func (w *Webhook) IsCommitsConfigEnabled() bool { + config := w.GetPayloadConfig() + return config.Commits.Enable +} + +// GetCommitsConfigLimit returns the commits configuration limit +func (w *Webhook) GetCommitsConfigLimit() int { + config := w.GetPayloadConfig() + return config.Commits.Limit +} diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index 71f50017c51fc..f5f90771d7af3 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.GetPayloadConfig() + 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{ + PayloadConfig: PayloadConfig{ + Files: PayloadConfigItem{ + Enable: true, + Limit: 5, + }, + Commits: PayloadConfigItem{ + Enable: true, + Limit: -3, + }, + }, + } + webhook.SetMetaSettings(metaSettings) + + // Test getting configuration + config = webhook.GetPayloadConfig() + 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.IsFilesConfigEnabled()) + assert.Equal(t, 5, webhook.GetFilesConfigLimit()) + assert.True(t, webhook.IsCommitsConfigEnabled()) + assert.Equal(t, -3, webhook.GetCommitsConfigLimit()) + assert.True(t, webhook.IsPayloadConfigEnabled()) + + // Test backward compatibility with direct payload config setting + newConfig := PayloadConfig{ + Files: PayloadConfigItem{ + Enable: false, + Limit: 10, + }, + Commits: PayloadConfigItem{ + Enable: false, + Limit: 20, + }, + } + webhook.SetPayloadConfig(newConfig) + + // Verify the config is properly set through meta settings + config = webhook.GetPayloadConfig() + 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 776e44ccec54d..30fee842eb452 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -33,6 +33,8 @@ type Hook struct { AuthorizationHeader string `json:"authorization_header"` // Whether the webhook is active and will be triggered Active bool `json:"active"` + // MetaSettings webhook metadata settings including payload optimization + MetaSettings map[string]any `json:"meta_settings"` // swagger:strfmt date-time // The date and time when the webhook was last updated Updated time.Time `json:"updated_at"` @@ -63,6 +65,8 @@ type CreateHookOption struct { BranchFilter string `json:"branch_filter" binding:"GlobPattern"` // Authorization header to include in webhook requests AuthorizationHeader string `json:"authorization_header"` + // Webhook metadata settings including payload optimization + MetaSettings map[string]any `json:"meta_settings"` // {"payload_config": {"files": {"enable": bool, "limit": int}, "commits": {"enable": bool, "limit": int}}} // default: false // Whether the webhook should be active upon creation Active bool `json:"active"` @@ -78,6 +82,8 @@ type EditHookOption struct { BranchFilter string `json:"branch_filter" binding:"GlobPattern"` // Authorization header to include in webhook requests AuthorizationHeader string `json:"authorization_header"` + // Webhook metadata settings including payload optimization + MetaSettings *map[string]any `json:"meta_settings"` // {"payload_config": {"files": {"enable": bool, "limit": int}, "commits": {"enable": bool, "limit": int}}} // Whether the webhook is active and will be triggered Active *bool `json:"active"` } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b3eb7b1f4a4fd..5afce75c1d1d3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2434,6 +2434,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 @@ -3297,7 +3304,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..930c48df748bf 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -21,6 +21,33 @@ import ( webhook_service "code.gitea.io/gitea/services/webhook" ) +// getPayloadConfigEnable extracts the "enable" boolean value from a payload config map +func getPayloadConfigEnable(m map[string]any) bool { + if val, ok := m["enable"]; ok { + if boolVal, ok := val.(bool); ok { + return boolVal + } + } + return false +} + +// getPayloadConfigLimit extracts the "limit" integer value from a payload config map +func getPayloadConfigLimit(m map[string]any) 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 0 +} + // ListOwnerHooks lists the webhooks of the provided owner func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { opts := &webhook.ListWebhookOptions{ @@ -227,6 +254,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 config + if payloadOptMap, ok := form.MetaSettings["payload_config"].(map[string]any); ok { + payloadOptConfig := webhook.PayloadConfig{} + + // Parse files config + if filesConfig, ok := payloadOptMap["files"].(map[string]any); ok { + payloadOptConfig.Files = webhook.PayloadConfigItem{ + Enable: getPayloadConfigEnable(filesConfig), + Limit: getPayloadConfigLimit(filesConfig), + } + } else { + payloadOptConfig.Files = webhook.PayloadConfigItem{Enable: false, Limit: 0} + } + + // Parse commits config + if commitsConfig, ok := payloadOptMap["commits"].(map[string]any); ok { + payloadOptConfig.Commits = webhook.PayloadConfigItem{ + Enable: getPayloadConfigEnable(commitsConfig), + Limit: getPayloadConfigLimit(commitsConfig), + } + } else { + payloadOptConfig.Commits = webhook.PayloadConfigItem{Enable: false, Limit: 0} + } + + metaSettings.PayloadConfig = 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 +456,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 config + if payloadOptMap, ok := (*form.MetaSettings)["payload_config"].(map[string]any); ok { + payloadOptConfig := webhook.PayloadConfig{} + + // Parse files config + if filesConfig, ok := payloadOptMap["files"].(map[string]any); ok { + payloadOptConfig.Files = webhook.PayloadConfigItem{ + Enable: getPayloadConfigEnable(filesConfig), + Limit: getPayloadConfigLimit(filesConfig), + } + } else { + payloadOptConfig.Files = webhook.PayloadConfigItem{Enable: false, Limit: 0} + } + + // Parse commits config + if commitsConfig, ok := payloadOptMap["commits"].(map[string]any); ok { + payloadOptConfig.Commits = webhook.PayloadConfigItem{ + Enable: getPayloadConfigEnable(commitsConfig), + Limit: getPayloadConfigLimit(commitsConfig), + } + } else { + payloadOptConfig.Commits = webhook.PayloadConfigItem{Enable: false, Limit: 0} + } + + metaSettings.PayloadConfig = 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..b2cf892ed8df7 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -121,7 +121,15 @@ func checkHookType(ctx *context.Context) string { // WebhooksNew render creating webhook page func WebhooksNew(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") - ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + + // Create a new webhook with default meta settings + newWebhook := &webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + // Initialize meta settings with default values + if err := newWebhook.SetMetaSettings(webhook.DefaultMetaSettings()); err != nil { + ctx.ServerError("SetMetaSettings", err) + return + } + ctx.Data["Webhook"] = newWebhook orCtx, err := getOwnerRepoCtx(ctx) if err != nil { @@ -207,7 +215,14 @@ func createWebhook(ctx *context.Context, params webhookParams) { ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") ctx.Data["PageIsSettingsHooks"] = true ctx.Data["PageIsSettingsHooksNew"] = true - ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + + // Create a webhook with default meta settings for template rendering + newWebhook := &webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} + if err := newWebhook.SetMetaSettings(webhook.DefaultMetaSettings()); err != nil { + ctx.ServerError("SetMetaSettings", err) + return + } + ctx.Data["Webhook"] = newWebhook ctx.Data["HookType"] = params.Type orCtx, err := getOwnerRepoCtx(ctx) @@ -244,6 +259,25 @@ func createWebhook(ctx *context.Context, params webhookParams) { OwnerID: orCtx.OwnerID, IsSystemWebhook: orCtx.IsSystemWebhook, } + + // Set webhook meta settings with payload config + metaSettings := webhook.MetaSettings{ + PayloadConfig: webhook.PayloadConfig{ + Files: webhook.PayloadConfigItem{ + Enable: params.WebhookForm.PayloadOptimizationFilesEnable, + Limit: params.WebhookForm.PayloadOptimizationFilesLimit, + }, + Commits: webhook.PayloadConfigItem{ + 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 +329,24 @@ func editWebhook(ctx *context.Context, params webhookParams) { w.HTTPMethod = params.HTTPMethod w.Meta = string(meta) + // Set webhook meta settings with payload config + metaSettings := webhook.MetaSettings{ + PayloadConfig: webhook.PayloadConfig{ + Files: webhook.PayloadConfigItem{ + Enable: params.WebhookForm.PayloadOptimizationFilesEnable, + Limit: params.WebhookForm.PayloadOptimizationFilesLimit, + }, + Commits: webhook.PayloadConfigItem{ + 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..2041447ce2c57 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 config 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 diff --git a/services/webhook/general.go b/services/webhook/general.go index be457e46f5f7a..1c2a0f304819c 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_config": map[string]any{ + "files": map[string]any{ + "enable": metaSettings.PayloadConfig.Files.Enable, + "limit": metaSettings.PayloadConfig.Files.Limit, + }, + "commits": map[string]any{ + "enable": metaSettings.PayloadConfig.Commits.Enable, + "limit": metaSettings.PayloadConfig.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 80de6b00fdbd1..45e53c5e2de60 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" @@ -641,6 +644,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.GetPayloadConfig() + + // 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) @@ -649,6 +784,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, @@ -888,6 +1026,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 34bcfd3df9d9a..dc1b1dc1c21c7 100644 --- a/services/webhook/webhook_test.go +++ b/services/webhook/webhook_test.go @@ -90,3 +90,171 @@ func TestWebhookUserMail(t *testing.T) { assert.Equal(t, user.GetPlaceholderEmail(), convert.ToUser(t.Context(), user, nil).Email) assert.Equal(t, user.Email, convert.ToUser(t.Context(), 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{ + PayloadConfig: webhook_model.PayloadConfig{ + Files: webhook_model.PayloadConfigItem{Enable: false, Limit: 0}, + Commits: webhook_model.PayloadConfigItem{Enable: false, Limit: 0}, + }, + }) + + err := webhook.UpdateEvent() + assert.NoError(t, err) + err = webhook_model.CreateWebhook(t.Context(), 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(t.Context(), 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{ + PayloadConfig: webhook_model.PayloadConfig{ + Files: webhook_model.PayloadConfigItem{Enable: true, Limit: 0}, + Commits: webhook_model.PayloadConfigItem{Enable: false, Limit: 0}, + }, + }) + err = webhook_model.UpdateWebhook(t.Context(), 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(t.Context(), 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{ + PayloadConfig: webhook_model.PayloadConfig{ + Files: webhook_model.PayloadConfigItem{Enable: false, Limit: 0}, + Commits: webhook_model.PayloadConfigItem{Enable: true, Limit: 1}, + }, + }) + err = webhook_model.UpdateWebhook(t.Context(), 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(t.Context(), 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..cef8b52138887 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -47,6 +47,41 @@ {{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 6dbc7e2a0e817..5faec2b7f196a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22939,6 +22939,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": [ @@ -24172,6 +24178,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" @@ -25576,6 +25588,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": { "description": "The type of the webhook (e.g., gitea, slack, discord)", "type": "string", diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index d54a604655561..1ad1f582fcf12 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -1716,3 +1716,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 config 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_config": 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 config settings + assert.NotNil(t, hook.MetaSettings) + payloadOptConfig := hook.MetaSettings["payload_config"].(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 config options + editHookOption := map[string]any{ + "meta_settings": map[string]any{ + "payload_config": 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 config settings + assert.NotNil(t, updatedHook.MetaSettings) + payloadOptConfig = updatedHook.MetaSettings["payload_config"].(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_config"].(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"]) + }) +} diff --git a/web_src/js/features/repo-settings-webhook.ts b/web_src/js/features/repo-settings-webhook.ts new file mode 100644 index 0000000000000..f7b0ed1482527 --- /dev/null +++ b/web_src/js/features/repo-settings-webhook.ts @@ -0,0 +1,31 @@ +/** + * Webhook settings functionality + */ + +import {toggleElemClass} from '../utils/dom.ts'; + +function setupOptimizationToggle(enableFieldName: string, limitFieldName: string): void { + const enableCheckbox = document.querySelector(`input[name="${enableFieldName}"]`); + if (!enableCheckbox) return; + + enableCheckbox.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + const limitField = document.querySelector(`input[name="${limitFieldName}"]`); + if (limitField) { + limitField.disabled = !target.checked; + // Use toggleElemClass to show/hide the limit field container + const limitFieldContainer = limitField.closest('.field'); + if (limitFieldContainer) { + toggleElemClass(limitFieldContainer, 'tw-hidden', !target.checked); + } + } + }); +} + +export function initRepoSettingsWebhook(): void { + if (!document.querySelector('.page-content.repository.settings.webhook')) return; + + // Setup payload optimization toggles + setupOptimizationToggle('payload_optimization_files_enable', 'payload_optimization_files_limit'); + setupOptimizationToggle('payload_optimization_commits_enable', 'payload_optimization_commits_limit'); +} diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index 43a236b0c6858..7b9bd01b25410 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -2,6 +2,7 @@ import {createMonaco} from './codeeditor.ts'; import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; +import {initRepoSettingsWebhook} from './repo-settings-webhook.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {globMatch} from '../utils/glob.ts'; @@ -152,4 +153,5 @@ export function initRepoSettings() { initRepoSettingsSearchTeamBox(); initRepoSettingsGitHook(); initRepoSettingsBranchesDrag(); + initRepoSettingsWebhook(); }