Skip to content

Commit 7cd5748

Browse files
committed
Implement webhook branch filter
See #2025, #3998.
1 parent aead030 commit 7cd5748

File tree

14 files changed

+203
-8
lines changed

14 files changed

+203
-8
lines changed

models/fixtures/webhook.yml

+7
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@
2222
content_type: 1 # json
2323
events: '{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}'
2424
is_active: true
25+
-
26+
id: 4
27+
repo_id: 2
28+
url: www.example.com/url4
29+
content_type: 1 # json
30+
events: '{"push_only":true,"branch_filter":"(master|release)"}'
31+
is_active: true

models/webhook.go

+48-3
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import (
1616
"net"
1717
"net/http"
1818
"net/url"
19+
"regexp"
1920
"strings"
2021
"time"
2122

23+
"code.gitea.io/gitea/modules/git"
2224
"code.gitea.io/gitea/modules/log"
2325
"code.gitea.io/gitea/modules/setting"
2426
api "code.gitea.io/gitea/modules/structs"
@@ -83,9 +85,10 @@ type HookEvents struct {
8385

8486
// HookEvent represents events that will delivery hook.
8587
type HookEvent struct {
86-
PushOnly bool `json:"push_only"`
87-
SendEverything bool `json:"send_everything"`
88-
ChooseEvents bool `json:"choose_events"`
88+
PushOnly bool `json:"push_only"`
89+
SendEverything bool `json:"send_everything"`
90+
ChooseEvents bool `json:"choose_events"`
91+
BranchFilter string `json:"branch_filter"`
8992

9093
HookEvents `json:"events"`
9194
}
@@ -255,6 +258,20 @@ func (w *Webhook) EventsArray() []string {
255258
return events
256259
}
257260

261+
func (w *Webhook) checkBranch(branch string) bool {
262+
if w.BranchFilter == "" {
263+
return true
264+
}
265+
pattern := "^" + w.BranchFilter + "$"
266+
matched, err := regexp.MatchString(pattern, branch)
267+
if err != nil {
268+
// should not really happen as BranchFilter is validated
269+
log.Error("CheckBranch failed: %s", err)
270+
return false
271+
}
272+
return matched
273+
}
274+
258275
// CreateWebhook creates a new web hook.
259276
func CreateWebhook(w *Webhook) error {
260277
return createWebhook(x, w)
@@ -650,6 +667,25 @@ func PrepareWebhook(w *Webhook, repo *Repository, event HookEventType, p api.Pay
650667
return prepareWebhook(x, w, repo, event, p)
651668
}
652669

670+
// getPayloadBranch returns branch for hook event, if applicable.
671+
func getPayloadBranch(p api.Payloader) (string, bool) {
672+
switch pp := p.(type) {
673+
case *api.CreatePayload:
674+
if pp.RefType == "branch" {
675+
return pp.Ref, true
676+
}
677+
case *api.DeletePayload:
678+
if pp.RefType == "branch" {
679+
return pp.Ref, true
680+
}
681+
case *api.PushPayload:
682+
if strings.HasPrefix(pp.Ref, git.BranchPrefix) {
683+
return pp.Ref[len(git.BranchPrefix):], true
684+
}
685+
}
686+
return "", false
687+
}
688+
653689
func prepareWebhook(e Engine, w *Webhook, repo *Repository, event HookEventType, p api.Payloader) error {
654690
for _, e := range w.eventCheckers() {
655691
if event == e.typ {
@@ -659,6 +695,15 @@ func prepareWebhook(e Engine, w *Webhook, repo *Repository, event HookEventType,
659695
}
660696
}
661697

698+
// If payload has no associated branch (e.g. it's a new tag, issue, etc.),
699+
// branch filter has no effect.
700+
if branch, ok := getPayloadBranch(p); ok {
701+
if !w.checkBranch(branch) {
702+
log.Info("Branch %q doesn't match branch filter %q, skipping", branch, w.BranchFilter)
703+
return nil
704+
}
705+
}
706+
662707
var payloader api.Payloader
663708
var err error
664709
// Use separate objects so modifications won't be made on payload on non-Gogs/Gitea type hooks.

models/webhook_test.go

+33
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,39 @@ func TestPrepareWebhooks(t *testing.T) {
270270
}
271271
}
272272

273+
func TestPrepareWebhooksBranchFilterMatch(t *testing.T) {
274+
assert.NoError(t, PrepareTestDatabase())
275+
276+
repo := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository)
277+
hookTasks := []*HookTask{
278+
{RepoID: repo.ID, HookID: 4, EventType: HookEventPush},
279+
}
280+
for _, hookTask := range hookTasks {
281+
AssertNotExistsBean(t, hookTask)
282+
}
283+
assert.NoError(t, PrepareWebhooks(repo, HookEventPush, &api.PushPayload{Ref: "refs/heads/master"}))
284+
for _, hookTask := range hookTasks {
285+
AssertExistsAndLoadBean(t, hookTask)
286+
}
287+
}
288+
289+
func TestPrepareWebhooksBranchFilterNoMatch(t *testing.T) {
290+
assert.NoError(t, PrepareTestDatabase())
291+
292+
repo := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository)
293+
hookTasks := []*HookTask{
294+
{RepoID: repo.ID, HookID: 4, EventType: HookEventPush},
295+
}
296+
for _, hookTask := range hookTasks {
297+
AssertNotExistsBean(t, hookTask)
298+
}
299+
assert.NoError(t, PrepareWebhooks(repo, HookEventPush, &api.PushPayload{Ref: "refs/heads/fix_weird_bug"}))
300+
301+
for _, hookTask := range hookTasks {
302+
AssertNotExistsBean(t, hookTask)
303+
}
304+
}
305+
273306
// TODO TestHookTask_deliver
274307

275308
// TODO TestDeliverHooks

modules/auth/auth.go

+2
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,8 @@ func validate(errs binding.Errors, data map[string]interface{}, f Form, l macaro
352352
data["ErrorMsg"] = trName + l.Tr("form.url_error")
353353
case binding.ERR_INCLUDE:
354354
data["ErrorMsg"] = trName + l.Tr("form.include_error", GetInclude(field))
355+
case validation.ErrRegexp:
356+
data["ErrorMsg"] = trName + l.Tr("form.regexp_error", errs[0].Message)
355357
default:
356358
data["ErrorMsg"] = l.Tr("form.unknown_error") + " " + errs[0].Classification
357359
}

modules/auth/repo_form.go

+1
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ type WebhookForm struct {
182182
PullRequest bool
183183
Repository bool
184184
Active bool
185+
BranchFilter string `binding:"Regexp"`
185186
}
186187

187188
// PushOnly if the hook will be triggered when push

modules/structs/hook.go

+7-5
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,19 @@ type CreateHookOption struct {
4040
// enum: gitea,gogs,slack,discord
4141
Type string `json:"type" binding:"Required"`
4242
// required: true
43-
Config map[string]string `json:"config" binding:"Required"`
44-
Events []string `json:"events"`
43+
Config map[string]string `json:"config" binding:"Required"`
44+
Events []string `json:"events"`
45+
BranchFilter string `json:"branch_filter" binding:"Regexp"`
4546
// default: false
4647
Active bool `json:"active"`
4748
}
4849

4950
// EditHookOption options when modify one hook
5051
type EditHookOption struct {
51-
Config map[string]string `json:"config"`
52-
Events []string `json:"events"`
53-
Active *bool `json:"active"`
52+
Config map[string]string `json:"config"`
53+
Events []string `json:"events"`
54+
BranchFilter string `json:"branch_filter" binding:"Regexp"`
55+
Active *bool `json:"active"`
5456
}
5557

5658
// Payloader payload is some part of one hook

modules/validation/binding.go

+24
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import (
1515
const (
1616
// ErrGitRefName is git reference name error
1717
ErrGitRefName = "GitRefNameError"
18+
19+
// ErrRegexp is returned when regexp is invalid
20+
ErrRegexp = "RegexpErr"
1821
)
1922

2023
var (
@@ -28,6 +31,7 @@ var (
2831
func AddBindingRules() {
2932
addGitRefNameBindingRule()
3033
addValidURLBindingRule()
34+
addRegexpRule()
3135
}
3236

3337
func addGitRefNameBindingRule() {
@@ -82,6 +86,26 @@ func addValidURLBindingRule() {
8286
})
8387
}
8488

89+
func addRegexpRule() {
90+
binding.AddRule(&binding.Rule{
91+
IsMatch: func(rule string) bool {
92+
return strings.HasPrefix(rule, "Regexp")
93+
},
94+
IsValid: func(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
95+
str := fmt.Sprintf("%v", val)
96+
97+
if str != "" {
98+
if _, err := regexp.Compile(str); err != nil {
99+
errs.Add([]string{name}, ErrRegexp, err.Error())
100+
return false, errs
101+
}
102+
}
103+
104+
return true, errs
105+
},
106+
})
107+
}
108+
85109
func portOnly(hostport string) string {
86110
colon := strings.IndexByte(hostport, ':')
87111
if colon == -1 {

modules/validation/binding_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type (
2929
TestForm struct {
3030
BranchName string `form:"BranchName" binding:"GitRefName"`
3131
URL string `form:"ValidUrl" binding:"ValidUrl"`
32+
Regexp string `form:"Regexp" binding:"Regexp"`
3233
}
3334
)
3435

modules/validation/regexp_test.go

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package validation
2+
3+
import (
4+
"regexp"
5+
"testing"
6+
7+
"github.com/go-macaron/binding"
8+
)
9+
10+
func getRegexpErrorString(pattern string) string {
11+
// It would be unwise to rely on that regexp
12+
// compilation errors don't ever change across Go releases.
13+
_, err := regexp.Compile(pattern)
14+
if err != nil {
15+
return err.Error()
16+
}
17+
return ""
18+
}
19+
20+
var regexpValidationTestCases = []validationTestCase{
21+
{
22+
description: "Empty regexp",
23+
data: TestForm{
24+
Regexp: "",
25+
},
26+
expectedErrors: binding.Errors{},
27+
},
28+
{
29+
description: "Valid regexp",
30+
data: TestForm{
31+
Regexp: "(master|release)",
32+
},
33+
expectedErrors: binding.Errors{},
34+
},
35+
36+
{
37+
description: "Invalid regexp",
38+
data: TestForm{
39+
Regexp: "master)(",
40+
},
41+
expectedErrors: binding.Errors{
42+
binding.Error{
43+
FieldNames: []string{"Regexp"},
44+
Classification: ErrRegexp,
45+
Message: getRegexpErrorString("master)("),
46+
},
47+
},
48+
},
49+
}
50+
51+
func Test_RegexpValidation(t *testing.T) {
52+
AddBindingRules()
53+
54+
for _, testCase := range regexpValidationTestCases {
55+
t.Run(testCase.description, func(t *testing.T) {
56+
performValidationTest(t, testCase)
57+
})
58+
}
59+
}

options/locale/locale_en-US.ini

+3
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ max_size_error = ` must contain at most %s characters.`
300300
email_error = ` is not a valid email address.`
301301
url_error = ` is not a valid URL.`
302302
include_error = ` must contain substring '%s'.`
303+
regexp_error = ` regular expression is invalid: %s.`
303304
unknown_error = Unknown error:
304305
captcha_incorrect = The CAPTCHA code is incorrect.
305306
password_not_match = The passwords do not match.
@@ -1245,6 +1246,8 @@ settings.event_pull_request = Pull Request
12451246
settings.event_pull_request_desc = Pull request opened, closed, reopened, edited, approved, rejected, review comment, assigned, unassigned, label updated, label cleared or synchronized.
12461247
settings.event_push = Push
12471248
settings.event_push_desc = Git push to a repository.
1249+
settings.branch_filter = Branch filter
1250+
settings.branch_filter_desc = Branch whitelist for push, branch creation and branch deletion events, specified as regular expression. If empty, events for all branches are reported. Regular expression is implicitly anchored, that is, partial match is not allowed. Add <code>.*</code> explicitly, if necessary.
12481251
settings.event_repository = Repository
12491252
settings.event_repository_desc = Repository created or deleted.
12501253
settings.active = Active

routers/api/v1/utils/hook.go

+2
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, orgID, repoID
112112
Repository: com.IsSliceContainsStr(form.Events, string(models.HookEventRepository)),
113113
Release: com.IsSliceContainsStr(form.Events, string(models.HookEventRelease)),
114114
},
115+
BranchFilter: form.BranchFilter,
115116
},
116117
IsActive: form.Active,
117118
HookTaskType: models.ToHookTaskType(form.Type),
@@ -236,6 +237,7 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *models.Webho
236237
w.PullRequest = com.IsSliceContainsStr(form.Events, string(models.HookEventPullRequest))
237238
w.Repository = com.IsSliceContainsStr(form.Events, string(models.HookEventRepository))
238239
w.Release = com.IsSliceContainsStr(form.Events, string(models.HookEventRelease))
240+
w.BranchFilter = form.BranchFilter
239241

240242
if err := w.UpdateEvent(); err != nil {
241243
ctx.Error(500, "UpdateEvent", err)

routers/repo/webhook.go

+1
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ func ParseHookEvent(form auth.WebhookForm) *models.HookEvent {
145145
PullRequest: form.PullRequest,
146146
Repository: form.Repository,
147147
},
148+
BranchFilter: form.BranchFilter,
148149
}
149150
}
150151

templates/repo/settings/webhook/settings.tmpl

+7
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@
116116
</div>
117117
</div>
118118

119+
<!-- Branch filter -->
120+
<div class="field">
121+
<label for="branch_filter">{{.i18n.Tr "repo.settings.branch_filter"}}</label>
122+
<input name="branch_filter" type="text" tabindex="0" value="{{.Webhook.BranchFilter}}">
123+
<span class="help">{{.i18n.Tr "repo.settings.branch_filter_desc" | Str2html}}</span>
124+
</div>
125+
119126
<div class="ui divider"></div>
120127

121128
<div class="inline field">

templates/swagger/v1_json.tmpl

+8
Original file line numberDiff line numberDiff line change
@@ -7277,6 +7277,10 @@
72777277
"default": false,
72787278
"x-go-name": "Active"
72797279
},
7280+
"branch_filter": {
7281+
"type": "string",
7282+
"x-go-name": "BranchFilter"
7283+
},
72807284
"config": {
72817285
"type": "object",
72827286
"additionalProperties": {
@@ -7868,6 +7872,10 @@
78687872
"type": "boolean",
78697873
"x-go-name": "Active"
78707874
},
7875+
"branch_filter": {
7876+
"type": "string",
7877+
"x-go-name": "BranchFilter"
7878+
},
78717879
"config": {
78727880
"type": "object",
78737881
"additionalProperties": {

0 commit comments

Comments
 (0)