mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 07:48:48 +01:00
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:
@@ -404,6 +404,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
|
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
|
||||||
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
|
||||||
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
|
||||||
|
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
16
models/migrations/v1_26/v330.go
Normal file
16
models/migrations/v1_26/v330.go
Normal 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
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ type Webhook struct {
|
|||||||
OwnerID int64 `xorm:"INDEX"`
|
OwnerID int64 `xorm:"INDEX"`
|
||||||
IsSystemWebhook bool
|
IsSystemWebhook bool
|
||||||
URL string `xorm:"url TEXT"`
|
URL string `xorm:"url TEXT"`
|
||||||
|
Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
|
||||||
HTTPMethod string `xorm:"http_method"`
|
HTTPMethod string `xorm:"http_method"`
|
||||||
ContentType HookContentType
|
ContentType HookContentType
|
||||||
Secret string `xorm:"TEXT"`
|
Secret string `xorm:"TEXT"`
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ var ErrInvalidReceiveHook = errors.New("Invalid JSON payload received over webho
|
|||||||
type Hook struct {
|
type Hook struct {
|
||||||
// The unique identifier of the webhook
|
// The unique identifier of the webhook
|
||||||
ID int64 `json:"id"`
|
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)
|
// The type of the webhook (e.g., gitea, slack, discord)
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// Branch filter pattern to determine which branches trigger the webhook
|
// Branch filter pattern to determine which branches trigger the webhook
|
||||||
@@ -66,6 +68,8 @@ type CreateHookOption struct {
|
|||||||
// default: false
|
// default: false
|
||||||
// Whether the webhook should be active upon creation
|
// Whether the webhook should be active upon creation
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
|
// Optional human-readable name for the webhook
|
||||||
|
Name string `json:"name" binding:"MaxSize(255)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditHookOption options when modify one hook
|
// EditHookOption options when modify one hook
|
||||||
@@ -80,6 +84,8 @@ type EditHookOption struct {
|
|||||||
AuthorizationHeader string `json:"authorization_header"`
|
AuthorizationHeader string `json:"authorization_header"`
|
||||||
// Whether the webhook is active and will be triggered
|
// Whether the webhook is active and will be triggered
|
||||||
Active *bool `json:"active"`
|
Active *bool `json:"active"`
|
||||||
|
// Optional human-readable name
|
||||||
|
Name *string `json:"name,omitzero" binding:"MaxSize(255)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payloader payload is some part of one hook
|
// Payloader payload is some part of one hook
|
||||||
|
|||||||
@@ -2258,6 +2258,9 @@
|
|||||||
"repo.settings.payload_url": "Target URL",
|
"repo.settings.payload_url": "Target URL",
|
||||||
"repo.settings.http_method": "HTTP Method",
|
"repo.settings.http_method": "HTTP Method",
|
||||||
"repo.settings.content_type": "POST Content Type",
|
"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.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.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",
|
"repo.settings.slack_username": "Username",
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI
|
|||||||
w := &webhook.Webhook{
|
w := &webhook.Webhook{
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
|
Name: strings.TrimSpace(form.Name),
|
||||||
URL: form.Config["url"],
|
URL: form.Config["url"],
|
||||||
ContentType: webhook.ToHookContentType(form.Config["content_type"]),
|
ContentType: webhook.ToHookContentType(form.Config["content_type"]),
|
||||||
Secret: form.Config["secret"],
|
Secret: form.Config["secret"],
|
||||||
@@ -392,6 +393,10 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh
|
|||||||
w.IsActive = *form.Active
|
w.IsActive = *form.Active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.Name != nil {
|
||||||
|
w.Name = strings.TrimSpace(*form.Name)
|
||||||
|
}
|
||||||
|
|
||||||
if err := webhook.UpdateWebhook(ctx, w); err != nil {
|
if err := webhook.UpdateWebhook(ctx, w); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ func createWebhook(ctx *context.Context, params webhookParams) {
|
|||||||
w := &webhook.Webhook{
|
w := &webhook.Webhook{
|
||||||
RepoID: orCtx.RepoID,
|
RepoID: orCtx.RepoID,
|
||||||
URL: params.URL,
|
URL: params.URL,
|
||||||
|
Name: strings.TrimSpace(params.WebhookForm.Name),
|
||||||
HTTPMethod: params.HTTPMethod,
|
HTTPMethod: params.HTTPMethod,
|
||||||
ContentType: params.ContentType,
|
ContentType: params.ContentType,
|
||||||
Secret: params.WebhookForm.Secret,
|
Secret: params.WebhookForm.Secret,
|
||||||
@@ -288,6 +289,7 @@ func editWebhook(ctx *context.Context, params webhookParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.URL = params.URL
|
w.URL = params.URL
|
||||||
|
w.Name = strings.TrimSpace(params.WebhookForm.Name)
|
||||||
w.ContentType = params.ContentType
|
w.ContentType = params.ContentType
|
||||||
w.Secret = params.WebhookForm.Secret
|
w.Secret = params.WebhookForm.Secret
|
||||||
w.HookEvent = ParseHookEvent(params.WebhookForm)
|
w.HookEvent = ParseHookEvent(params.WebhookForm)
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ type ProtectBranchPriorityForm struct {
|
|||||||
|
|
||||||
// WebhookForm form for changing web hook
|
// WebhookForm form for changing web hook
|
||||||
type WebhookForm struct {
|
type WebhookForm struct {
|
||||||
|
Name string `binding:"MaxSize(255)"`
|
||||||
Events string
|
Events string
|
||||||
Create bool
|
Create bool
|
||||||
Delete bool
|
Delete bool
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
|
|||||||
|
|
||||||
return &api.Hook{
|
return &api.Hook{
|
||||||
ID: w.ID,
|
ID: w.ID,
|
||||||
|
Name: w.Name,
|
||||||
Type: w.Type,
|
Type: w.Type,
|
||||||
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
|
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
|
||||||
Active: w.IsActive,
|
Active: w.IsActive,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<div class="item">
|
<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>
|
<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">
|
<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>
|
</div>
|
||||||
<a class="muted tw-p-2" href="{{$.BaseLink}}/{{.ID}}">{{svg "octicon-pencil"}}</a>
|
<a class="muted tw-p-2" href="{{$.BaseLink}}/{{.ID}}">{{svg "octicon-pencil"}}</a>
|
||||||
<a class="tw-text-red tw-p-2 link-action"
|
<a class="tw-text-red tw-p-2 link-action"
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
*/}}
|
*/}}
|
||||||
{{$isNew := not .Webhook.ID}}
|
{{$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="inline field">
|
||||||
<div class="ui checkbox">
|
<div class="ui checkbox">
|
||||||
<input name="active" type="checkbox" {{if or $isNew .Webhook.IsActive}}checked{{end}}>
|
<input name="active" type="checkbox" {{if or $isNew .Webhook.IsActive}}checked{{end}}>
|
||||||
|
|||||||
15
templates/swagger/v1_json.tmpl
generated
15
templates/swagger/v1_json.tmpl
generated
@@ -23463,6 +23463,11 @@
|
|||||||
},
|
},
|
||||||
"x-go-name": "Events"
|
"x-go-name": "Events"
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Optional human-readable name for the webhook",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "Name"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -24729,6 +24734,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"x-go-name": "Events"
|
"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"
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
@@ -26149,6 +26159,11 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "ID"
|
"x-go-name": "ID"
|
||||||
},
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Optional human-readable name for the webhook",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "Name"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"description": "The type of the webhook (e.g., gitea, slack, discord)",
|
"description": "The type of the webhook (e.g., gitea, slack, discord)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func TestAPICreateHook(t *testing.T) {
|
|||||||
"url": "http://example.com/",
|
"url": "http://example.com/",
|
||||||
},
|
},
|
||||||
AuthorizationHeader: "Bearer s3cr3t",
|
AuthorizationHeader: "Bearer s3cr3t",
|
||||||
|
Name: " CI notifications ",
|
||||||
}).AddTokenAuth(token)
|
}).AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
@@ -41,4 +42,54 @@ func TestAPICreateHook(t *testing.T) {
|
|||||||
DecodeJSON(t, resp, &apiHook)
|
DecodeJSON(t, resp, &apiHook)
|
||||||
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
|
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
|
||||||
assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
|
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, ¬Cleared)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user