Add webhook name field to improve webhook identification (#37025) (#37040)

Add an optional Name field to webhooks so users can give them
human-readable labels instead of relying only on URLs. The webhook
overview page now displays names when available, or falls back to the
URL for unnamed webhooks.

Fixes #37025

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Nicolas
2026-04-01 03:56:20 +02:00
committed by GitHub
parent 0df3213766
commit 35b654c9d6
13 changed files with 110 additions and 1 deletions

View File

@@ -404,6 +404,7 @@ func prepareMigrationTasks() []*migration {
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
}
return preparedMigrations
}

View File

@@ -0,0 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"xorm.io/xorm"
)
func AddNameToWebhook(x *xorm.Engine) error {
type Webhook struct {
Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(Webhook))
return err
}

View File

@@ -126,6 +126,7 @@ type Webhook struct {
OwnerID int64 `xorm:"INDEX"`
IsSystemWebhook bool
URL string `xorm:"url TEXT"`
Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
HTTPMethod string `xorm:"http_method"`
ContentType HookContentType
Secret string `xorm:"TEXT"`

View File

@@ -19,6 +19,8 @@ var ErrInvalidReceiveHook = errors.New("Invalid JSON payload received over webho
type Hook struct {
// The unique identifier of the webhook
ID int64 `json:"id"`
// Optional human-readable name for the webhook
Name string `json:"name"`
// The type of the webhook (e.g., gitea, slack, discord)
Type string `json:"type"`
// Branch filter pattern to determine which branches trigger the webhook
@@ -66,6 +68,8 @@ type CreateHookOption struct {
// default: false
// Whether the webhook should be active upon creation
Active bool `json:"active"`
// Optional human-readable name for the webhook
Name string `json:"name" binding:"MaxSize(255)"`
}
// EditHookOption options when modify one hook
@@ -80,6 +84,8 @@ type EditHookOption struct {
AuthorizationHeader string `json:"authorization_header"`
// Whether the webhook is active and will be triggered
Active *bool `json:"active"`
// Optional human-readable name
Name *string `json:"name,omitzero" binding:"MaxSize(255)"`
}
// Payloader payload is some part of one hook

View File

@@ -2258,6 +2258,9 @@
"repo.settings.payload_url": "Target URL",
"repo.settings.http_method": "HTTP Method",
"repo.settings.content_type": "POST Content Type",
"repo.settings.webhook.name": "Webhook name",
"repo.settings.webhook.name_helper": "Optionally give this webhook a friendly name",
"repo.settings.webhook.name_empty": "Unnamed Webhook",
"repo.settings.secret": "Secret",
"repo.settings.webhook_secret_desc": "If the webhook server supports using secret, you can follow the webhook's manual and fill in a secret here.",
"repo.settings.slack_username": "Username",

View File

@@ -215,6 +215,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI
w := &webhook.Webhook{
OwnerID: ownerID,
RepoID: repoID,
Name: strings.TrimSpace(form.Name),
URL: form.Config["url"],
ContentType: webhook.ToHookContentType(form.Config["content_type"]),
Secret: form.Config["secret"],
@@ -392,6 +393,10 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh
w.IsActive = *form.Active
}
if form.Name != nil {
w.Name = strings.TrimSpace(*form.Name)
}
if err := webhook.UpdateWebhook(ctx, w); err != nil {
ctx.APIErrorInternal(err)
return false

View File

@@ -234,6 +234,7 @@ func createWebhook(ctx *context.Context, params webhookParams) {
w := &webhook.Webhook{
RepoID: orCtx.RepoID,
URL: params.URL,
Name: strings.TrimSpace(params.WebhookForm.Name),
HTTPMethod: params.HTTPMethod,
ContentType: params.ContentType,
Secret: params.WebhookForm.Secret,
@@ -288,6 +289,7 @@ func editWebhook(ctx *context.Context, params webhookParams) {
}
w.URL = params.URL
w.Name = strings.TrimSpace(params.WebhookForm.Name)
w.ContentType = params.ContentType
w.Secret = params.WebhookForm.Secret
w.HookEvent = ParseHookEvent(params.WebhookForm)

View File

@@ -206,6 +206,7 @@ type ProtectBranchPriorityForm struct {
// WebhookForm form for changing web hook
type WebhookForm struct {
Name string `binding:"MaxSize(255)"`
Events string
Create bool
Delete bool

View File

@@ -411,6 +411,7 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
return &api.Hook{
ID: w.ID,
Name: w.Name,
Type: w.Type,
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
Active: w.IsActive,

View File

@@ -14,7 +14,8 @@
<div class="item">
<span class="{{if eq .LastStatus 1}}tw-text-green{{else if eq .LastStatus 2}}tw-text-red{{else}}tw-text-text-light{{end}}">{{svg "octicon-dot-fill" 22}}</span>
<div class="gt-ellipsis tw-flex-1">
<a title="{{.URL}}" href="{{$.BaseLink}}/{{.ID}}">{{.URL}}</a>
<a title="{{.URL}}" href="{{$.BaseLink}}/{{.ID}}">{{or .Name (ctx.Locale.Tr "repo.settings.webhook.name_empty")}}</a>
<span class="tw-ml-2 tw-text-grey-light">{{.URL}}</span>
</div>
<a class="muted tw-p-2" href="{{$.BaseLink}}/{{.ID}}">{{svg "octicon-pencil"}}</a>
<a class="tw-text-red tw-p-2 link-action"

View File

@@ -6,6 +6,12 @@
*/}}
{{$isNew := not .Webhook.ID}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.webhook.name"}}</label>
<input name="name" type="text" value="{{.Webhook.Name}}" maxlength="255">
<p class="help">{{ctx.Locale.Tr "repo.settings.webhook.name_helper"}}</p>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="active" type="checkbox" {{if or $isNew .Webhook.IsActive}}checked{{end}}>

View File

@@ -23463,6 +23463,11 @@
},
"x-go-name": "Events"
},
"name": {
"description": "Optional human-readable name for the webhook",
"type": "string",
"x-go-name": "Name"
},
"type": {
"type": "string",
"enum": [
@@ -24729,6 +24734,11 @@
"type": "string"
},
"x-go-name": "Events"
},
"name": {
"description": "Optional human-readable name",
"type": "string",
"x-go-name": "Name"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
@@ -26149,6 +26159,11 @@
"format": "int64",
"x-go-name": "ID"
},
"name": {
"description": "Optional human-readable name for the webhook",
"type": "string",
"x-go-name": "Name"
},
"type": {
"description": "The type of the webhook (e.g., gitea, slack, discord)",
"type": "string",

View File

@@ -34,6 +34,7 @@ func TestAPICreateHook(t *testing.T) {
"url": "http://example.com/",
},
AuthorizationHeader: "Bearer s3cr3t",
Name: " CI notifications ",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
@@ -41,4 +42,54 @@ func TestAPICreateHook(t *testing.T) {
DecodeJSON(t, resp, &apiHook)
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
assert.Equal(t, "CI notifications", apiHook.Name)
newName := "Deploy hook"
patchReq := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID), api.EditHookOption{
Name: &newName,
}).AddTokenAuth(token)
patchResp := MakeRequest(t, patchReq, http.StatusOK)
var patched *api.Hook
DecodeJSON(t, patchResp, &patched)
assert.Equal(t, newName, patched.Name)
hooksURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks", owner.Name, repo.Name)
// Create with Name field omitted: Name should be ""
req2 := NewRequestWithJSON(t, "POST", hooksURL, api.CreateHookOption{
Type: "gitea",
Config: api.CreateHookOptionConfig{
"content_type": "json",
"url": "http://example.com/",
},
}).AddTokenAuth(token)
resp2 := MakeRequest(t, req2, http.StatusCreated)
var created *api.Hook
DecodeJSON(t, resp2, &created)
assert.Empty(t, created.Name)
hookURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, created.ID)
// PATCH with Name set: existing Name must be updated
setName := "original"
setReq := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{
Name: &setName,
}).AddTokenAuth(token)
MakeRequest(t, setReq, http.StatusOK)
// PATCH without Name field: name must remain "original"
patchReq2 := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{}).AddTokenAuth(token)
patchResp2 := MakeRequest(t, patchReq2, http.StatusOK)
var notCleared *api.Hook
DecodeJSON(t, patchResp2, &notCleared)
assert.Equal(t, "original", notCleared.Name)
// PATCH with Name: "" explicitly: Name should be cleared to ""
clearReq := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{
Name: new(""),
}).AddTokenAuth(token)
clearResp := MakeRequest(t, clearReq, http.StatusOK)
var cleared *api.Hook
DecodeJSON(t, clearResp, &cleared)
assert.Empty(t, cleared.Name)
}