diff --git a/cmd/web.go b/cmd/web.go index e0ef3a76a3..275d3fb90e 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -284,9 +284,11 @@ func runWeb(*cli.Context) { r.Route("/collaboration", "GET,POST", repo.SettingsCollaboration) r.Get("/hooks", repo.Webhooks) r.Get("/hooks/new", repo.WebHooksNew) - r.Post("/hooks/new", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksNewPost) + r.Post("/hooks/gogs/new", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksNewPost) + r.Post("/hooks/slack/new", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksNewPost) r.Get("/hooks/:id", repo.WebHooksEdit) - r.Post("/hooks/:id", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksEditPost) + r.Post("/hooks/gogs/:id", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksEditPost) + r.Post("/hooks/slack/:id", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksEditPost) }) }, reqSignIn, middleware.RepoAssignment(true), reqTrueOwner) diff --git a/conf/locale/locale_en-US.ini b/conf/locale/locale_en-US.ini index a99eb92e7e..946d560461 100644 --- a/conf/locale/locale_en-US.ini +++ b/conf/locale/locale_en-US.ini @@ -234,6 +234,11 @@ settings.update_webhook = Update Webhook settings.update_hook_success = Webhook has been updated. settings.delete_webhook = Delete Webhook settings.recent_deliveries = Recent Deliveries +settings.hook_type = Hook Type +settings.add_slack_hook_desc = Add Slack integration to your repository. +settings.slack_token = Token +settings.slack_domain = Domain +settings.slack_channel = Channel [org] org_name_holder = Organization Name diff --git a/models/action.go b/models/action.go index b5f692c49f..d536c84dd0 100644 --- a/models/action.go +++ b/models/action.go @@ -266,14 +266,33 @@ func CommitRepoAction(userId, repoUserId int64, userName, actEmail string, continue } - p.Secret = w.Secret - CreateHookTask(&HookTask{ - Type: WEBHOOK, - Url: w.Url, - Payload: p, - ContentType: w.ContentType, - IsSsl: w.IsSsl, - }) + switch w.HookTaskType { + case SLACK: + { + s, err := GetSlackPayload(p, w.Meta) + if err != nil { + return errors.New("action.GetSlackPayload: " + err.Error()) + } + CreateHookTask(&HookTask{ + Type: w.HookTaskType, + Url: w.Url, + BasePayload: s, + ContentType: w.ContentType, + IsSsl: w.IsSsl, + }) + } + default: + { + p.Secret = w.Secret + CreateHookTask(&HookTask{ + Type: w.HookTaskType, + Url: w.Url, + BasePayload: p, + ContentType: w.ContentType, + IsSsl: w.IsSsl, + }) + } + } } return nil } diff --git a/models/slack.go b/models/slack.go new file mode 100644 index 0000000000..0a55740947 --- /dev/null +++ b/models/slack.go @@ -0,0 +1,114 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + SLACK_COLOR string = "#dd4b39" +) + +type Slack struct { + Domain string `json:"domain"` + Token string `json:"token"` + Channel string `json:"channel"` +} + +type SlackPayload struct { + Channel string `json:"channel"` + Text string `json:"text"` + Username string `json:"username"` + IconUrl string `json:"icon_url"` + UnfurlLinks int `json:"unfurl_links"` + LinkNames int `json:"link_names"` + Attachments []SlackAttachment `json:"attachments"` +} + +type SlackAttachment struct { + Color string `json:"color"` + Text string `json:"text"` +} + +func GetSlackURL(domain string, token string) string { + return fmt.Sprintf( + "https://%s.slack.com/services/hooks/incoming-webhook?token=%s", + domain, + token, + ) +} + +func (p SlackPayload) GetJSONPayload() ([]byte, error) { + data, err := json.Marshal(p) + if err != nil { + return []byte{}, err + } + return data, nil +} + +func GetSlackPayload(p *Payload, meta string) (*SlackPayload, error) { + slack := &Slack{} + slackPayload := &SlackPayload{} + if err := json.Unmarshal([]byte(meta), &slack); err != nil { + return slackPayload, errors.New("GetSlackPayload meta json:" + err.Error()) + } + + // TODO: handle different payload types: push, new branch, delete branch etc. + // when they are added to gogs. Only handles push now + return getSlackPushPayload(p, slack) +} + +func getSlackPushPayload(p *Payload, slack *Slack) (*SlackPayload, error) { + // n new commits + refSplit := strings.Split(p.Ref, "/") + branchName := refSplit[len(refSplit)-1] + var commitString string + + // TODO: add commit compare before/after link when gogs adds it + if len(p.Commits) == 1 { + commitString = "1 new commit" + } else { + commitString = fmt.Sprintf("%d new commits", len(p.Commits)) + } + + text := fmt.Sprintf("[%s:%s] %s pushed by %s", p.Repo.Name, branchName, commitString, p.Pusher.Name) + var attachmentText string + + // for each commit, generate attachment text + for i, commit := range p.Commits { + attachmentText += fmt.Sprintf("<%s|%s>: %s - %s", commit.Url, commit.Id[:7], SlackFormatter(commit.Message), commit.Author.Name) + // add linebreak to each commit but the last + if i < len(p.Commits)-1 { + attachmentText += "\n" + } + } + + slackAttachments := []SlackAttachment{{Color: SLACK_COLOR, Text: attachmentText}} + + return &SlackPayload{ + Channel: slack.Channel, + Text: text, + Username: "gogs", + IconUrl: "https://raw.githubusercontent.com/gogits/gogs/master/public/img/favicon.png", + UnfurlLinks: 0, + LinkNames: 0, + Attachments: slackAttachments, + }, nil +} + +// see: https://api.slack.com/docs/formatting +func SlackFormatter(s string) string { + // take only first line of commit + first := strings.Split(s, "\n")[0] + // replace & < > + first = strings.Replace(first, "&", "&", -1) + first = strings.Replace(first, "<", "<", -1) + first = strings.Replace(first, ">", ">", -1) + return first +} diff --git a/models/webhook.go b/models/webhook.go index ced7936646..55ed4844ed 100644 --- a/models/webhook.go +++ b/models/webhook.go @@ -7,6 +7,7 @@ package models import ( "encoding/json" "errors" + "io/ioutil" "time" "github.com/gogits/gogs/modules/httplib" @@ -33,15 +34,17 @@ type HookEvent struct { // Webhook represents a web hook object. type Webhook struct { - Id int64 - RepoId int64 - Url string `xorm:"TEXT"` - ContentType HookContentType - Secret string `xorm:"TEXT"` - Events string `xorm:"TEXT"` - *HookEvent `xorm:"-"` - IsSsl bool - IsActive bool + Id int64 + RepoId int64 + Url string `xorm:"TEXT"` + ContentType HookContentType + Secret string `xorm:"TEXT"` + Events string `xorm:"TEXT"` + *HookEvent `xorm:"-"` + IsSsl bool + IsActive bool + HookTaskType HookTaskType + Meta string `xorm:"TEXT"` // store hook-specific attributes } // GetEvent handles conversion from Events to HookEvent. @@ -52,6 +55,14 @@ func (w *Webhook) GetEvent() { } } +func (w *Webhook) GetSlackHook() *Slack { + s := &Slack{} + if err := json.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error(4, "webhook.GetSlackHook(%d): %v", w.Id, err) + } + return s +} + // UpdateEvent handles conversion from HookEvent to Events. func (w *Webhook) UpdateEvent() error { data, err := json.Marshal(w.HookEvent) @@ -119,8 +130,8 @@ func DeleteWebhook(hookId int64) error { type HookTaskType int const ( - WEBHOOK HookTaskType = iota + 1 - SERVICE + GOGS HookTaskType = iota + 1 + SLACK ) type HookEventType string @@ -152,6 +163,10 @@ type PayloadRepo struct { Private bool `json:"private"` } +type BasePayload interface { + GetJSONPayload() ([]byte, error) +} + // Payload represents a payload information of hook. type Payload struct { Secret string `json:"secret"` @@ -161,25 +176,33 @@ type Payload struct { Pusher *PayloadAuthor `json:"pusher"` } +func (p Payload) GetJSONPayload() ([]byte, error) { + data, err := json.Marshal(p) + if err != nil { + return []byte{}, err + } + return data, nil +} + // HookTask represents a hook task. type HookTask struct { Id int64 Uuid string Type HookTaskType Url string - *Payload `xorm:"-"` + BasePayload `xorm:"-"` PayloadContent string `xorm:"TEXT"` ContentType HookContentType EventType HookEventType IsSsl bool - IsDeliveried bool + IsDelivered bool IsSucceed bool } // CreateHookTask creates a new hook task, // it handles conversion from Payload to PayloadContent. func CreateHookTask(t *HookTask) error { - data, err := json.Marshal(t.Payload) + data, err := t.BasePayload.GetJSONPayload() if err != nil { return err } @@ -198,7 +221,7 @@ func UpdateHookTask(t *HookTask) error { // DeliverHooks checks and delivers undelivered hooks. func DeliverHooks() { timeout := time.Duration(setting.WebhookDeliverTimeout) * time.Second - x.Where("is_deliveried=?", false).Iterate(new(HookTask), + x.Where("is_delivered=?", false).Iterate(new(HookTask), func(idx int, bean interface{}) error { t := bean.(*HookTask) req := httplib.Post(t.Url).SetTimeout(timeout, timeout). @@ -212,13 +235,36 @@ func DeliverHooks() { req.Param("payload", t.PayloadContent) } - t.IsDeliveried = true + t.IsDelivered = true // TODO: record response. - if _, err := req.Response(); err != nil { - log.Error(4, "Delivery: %v", err) - } else { - t.IsSucceed = true + switch t.Type { + case GOGS: + { + if _, err := req.Response(); err != nil { + log.Error(4, "Delivery: %v", err) + } else { + t.IsSucceed = true + } + } + case SLACK: + { + if res, err := req.Response(); err != nil { + log.Error(4, "Delivery: %v", err) + } else { + defer res.Body.Close() + contents, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Error(4, "%s", err) + } else { + if string(contents) != "ok" { + log.Error(4, "slack failed with: %s", string(contents)) + } else { + t.IsSucceed = true + } + } + } + } } if err := UpdateHookTask(t); err != nil { diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 3eb0cbc564..5fd1114052 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -69,17 +69,31 @@ func (f *RepoSettingForm) Validate(ctx *macaron.Context, errs *binding.Errors, l // \/ \/ \/ \/ \/ \/ type NewWebhookForm struct { - PayloadUrl string `form:"payload_url" binding:"Required;Url"` - ContentType string `form:"content_type" binding:"Required"` - Secret string `form:"secret"` - PushOnly bool `form:"push_only"` - Active bool `form:"active"` + HookTaskType string `form:"hook_type" binding:"Required"` + PayloadUrl string `form:"payload_url" binding:"Required;Url"` + ContentType string `form:"content_type" binding:"Required"` + Secret string `form:"secret"` + PushOnly bool `form:"push_only"` + Active bool `form:"active"` } func (f *NewWebhookForm) Validate(ctx *macaron.Context, errs *binding.Errors, l i18n.Locale) { validate(errs, ctx.Data, f, l) } +type NewSlackHookForm struct { + HookTaskType string `form:"hook_type" binding:"Required"` + Domain string `form:"domain" binding:"Required` + Token string `form:"token" binding:"Required"` + Channel string `form:"channel" binding:"Required"` + PushOnly bool `form:"push_only"` + Active bool `form:"active"` +} + +func (f *NewSlackHookForm) Validate(ctx *macaron.Context, errs *binding.Errors, l i18n.Locale) { + validate(errs, ctx.Data, f, l) +} + // .___ // | | ______ ________ __ ____ // | |/ ___// ___/ | \_/ __ \ diff --git a/public/ng/css/gogs.css b/public/ng/css/gogs.css index d81d6f3149..0840833755 100644 --- a/public/ng/css/gogs.css +++ b/public/ng/css/gogs.css @@ -1403,14 +1403,16 @@ The register and sign-in page style #auth-setting-form, #org-setting-form, #repo-setting-form, -#user-profile-form { +#user-profile-form, +.repo-setting-form { background-color: #FFF; padding: 30px 0; } #auth-setting-form textarea, #org-setting-form textarea, #repo-setting-form textarea, -#user-profile-form textarea { +#user-profile-form textarea, +.repo-setting-form textarea { margin-left: 4px; height: 100px; } @@ -1418,24 +1420,38 @@ The register and sign-in page style #org-setting-form label, #repo-setting-form label, #user-profile-form label, +.repo-setting-form label, #auth-setting-form .form-label, #org-setting-form .form-label, #repo-setting-form .form-label, -#user-profile-form .form-label { +#user-profile-form .form-label, +.repo-setting-form .form-label { width: 240px; } #auth-setting-form .ipt, #org-setting-form .ipt, #repo-setting-form .ipt, -#user-profile-form .ipt { +#user-profile-form .ipt, +.repo-setting-form .ipt { width: 360px; } #auth-setting-form .field, #org-setting-form .field, #repo-setting-form .field, -#user-profile-form .field { +#user-profile-form .field, +.repo-setting-form .field { margin-bottom: 24px; } +#hook-type { + padding: 10px 0 0 0; + background-color: #fff; +} +#hook-type .field { + margin-bottom: 24px; +} +#hook-type label { + width: 240px; +} #repo-hooks-panel, #repo-hooks-history-panel, #user-social-panel, diff --git a/public/ng/js/gogs.js b/public/ng/js/gogs.js index bade9f3420..c08a887a4c 100644 --- a/public/ng/js/gogs.js +++ b/public/ng/js/gogs.js @@ -359,6 +359,22 @@ function initRepoSetting() { return true; } }); + + // web hook type change + $('select#hook-type').on("change", function () { + hookTypes = ['Gogs','Slack']; + + var curHook = $(this).val(); + hookTypes.forEach(function(hookType) { + if (curHook === hookType) { + $('div#'+hookType.toLowerCase()).toggleShow(); + } + else { + $('div#'+hookType.toLowerCase()).toggleHide(); + } + }); + }); + $('#transfer-button').click(function () { $('#transfer-form').show(); }); @@ -594,4 +610,4 @@ function homepage() { } $('#promo-form').attr('action', '/user/sign_up'); }); -} \ No newline at end of file +} diff --git a/public/ng/less/gogs/settings.less b/public/ng/less/gogs/settings.less index b246a947ec..80c00f2dbe 100644 --- a/public/ng/less/gogs/settings.less +++ b/public/ng/less/gogs/settings.less @@ -34,7 +34,8 @@ #auth-setting-form, #org-setting-form, #repo-setting-form, -#user-profile-form { +#user-profile-form, +.repo-setting-form { background-color: #FFF; padding: 30px 0; textarea { @@ -53,6 +54,17 @@ } } +#hook-type { + padding: 10px 0 0 0; + background-color: #fff; + .field { + margin-bottom: 24px; + } + label { + width: 240px; + } +} + #repo-hooks-panel, #repo-hooks-history-panel, #user-social-panel, @@ -109,4 +121,4 @@ .field { margin-bottom: 24px; } -} \ No newline at end of file +} diff --git a/routers/repo/setting.go b/routers/repo/setting.go index 24c1b13a5e..fba9eed6a2 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -5,6 +5,7 @@ package repo import ( + "encoding/json" "fmt" "strings" "time" @@ -272,11 +273,17 @@ func Webhooks(ctx *middleware.Context) { ctx.HTML(200, HOOKS) } +func renderHookTypes(ctx *middleware.Context) { + ctx.Data["HookTypes"] = []string{"Gogs", "Slack"} + ctx.Data["HookType"] = "Gogs" +} + func WebHooksNew(ctx *middleware.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings") ctx.Data["PageIsSettingsHooks"] = true ctx.Data["PageIsSettingsHooksNew"] = true ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + renderHookTypes(ctx) ctx.HTML(200, HOOK_NEW) } @@ -304,8 +311,11 @@ func WebHooksNewPost(ctx *middleware.Context, form auth.NewWebhookForm) { HookEvent: &models.HookEvent{ PushOnly: form.PushOnly, }, - IsActive: form.Active, + IsActive: form.Active, + HookTaskType: models.GOGS, + Meta: "", } + if err := w.UpdateEvent(); err != nil { ctx.Handle(500, "UpdateEvent", err) return @@ -338,6 +348,19 @@ func WebHooksEdit(ctx *middleware.Context) { } return } + + // set data per HookTaskType + switch w.HookTaskType { + case models.SLACK: + { + ctx.Data["SlackHook"] = w.GetSlackHook() + ctx.Data["HookType"] = "slack" + } + default: + { + ctx.Data["HookType"] = "gogs" + } + } w.GetEvent() ctx.Data["Webhook"] = w ctx.HTML(200, HOOK_NEW) @@ -394,3 +417,104 @@ func WebHooksEditPost(ctx *middleware.Context, form auth.NewWebhookForm) { ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) ctx.Redirect(fmt.Sprintf("%s/settings/hooks/%d", ctx.Repo.RepoLink, hookId)) } + +func SlackHooksNewPost(ctx *middleware.Context, form auth.NewSlackHookForm) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + + if ctx.HasError() { + ctx.HTML(200, HOOK_NEW) + return + } + + meta, err := json.Marshal(&models.Slack{ + Domain: form.Domain, + Channel: form.Channel, + Token: form.Token, + }) + if err != nil { + ctx.Handle(500, "SlackHooksNewPost: JSON marshal failed: ", err) + return + } + + w := &models.Webhook{ + RepoId: ctx.Repo.Repository.Id, + Url: models.GetSlackURL(form.Domain, form.Token), + ContentType: models.JSON, + Secret: "", + HookEvent: &models.HookEvent{ + PushOnly: form.PushOnly, + }, + IsActive: form.Active, + HookTaskType: models.SLACK, + Meta: string(meta), + } + if err := w.UpdateEvent(); err != nil { + ctx.Handle(500, "UpdateEvent", err) + return + } else if err := models.CreateWebhook(w); err != nil { + ctx.Handle(500, "CreateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks") +} + +func SlackHooksEditPost(ctx *middleware.Context, form auth.NewSlackHookForm) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + hookId := com.StrTo(ctx.Params(":id")).MustInt64() + fmt.Println("hookId slack=%d", hookId) + if hookId == 0 { + ctx.Handle(404, "setting.WebHooksEditPost", nil) + return + } + + w, err := models.GetWebhookById(hookId) + if err != nil { + if err == models.ErrWebhookNotExist { + ctx.Handle(404, "GetWebhookById", nil) + } else { + ctx.Handle(500, "GetWebhookById", err) + } + return + } + w.GetEvent() + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(200, HOOK_NEW) + return + } + meta, err := json.Marshal(&models.Slack{ + Domain: form.Domain, + Channel: form.Channel, + Token: form.Token, + }) + if err != nil { + ctx.Handle(500, "SlackHooksNewPost: JSON marshal failed: ", err) + return + } + + w.Url = models.GetSlackURL(form.Domain, form.Token) + w.Meta = string(meta) + w.HookEvent = &models.HookEvent{ + PushOnly: form.PushOnly, + } + w.IsActive = form.Active + if err := w.UpdateEvent(); err != nil { + ctx.Handle(500, "UpdateEvent", err) + return + } else if err := models.UpdateWebhook(w); err != nil { + ctx.Handle(500, "SlackHooksEditPost", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/settings/hooks/%d", ctx.Repo.RepoLink, hookId)) +} diff --git a/templates/repo/settings/gogs_hook.tmpl b/templates/repo/settings/gogs_hook.tmpl new file mode 100644 index 0000000000..678d640baf --- /dev/null +++ b/templates/repo/settings/gogs_hook.tmpl @@ -0,0 +1,23 @@ +
+
+ {{.CsrfTokenHtml}} + +
{{.i18n.Tr "repo.settings.add_webhook_desc" | Str2html}}
+
+ + +
+
+ + +
+
+ + +
+ {{template "repo/settings/hook_settings" .}} +
+
diff --git a/templates/repo/settings/hook_new.tmpl b/templates/repo/settings/hook_new.tmpl index 2cd0eacb6a..7a450282b1 100644 --- a/templates/repo/settings/hook_new.tmpl +++ b/templates/repo/settings/hook_new.tmpl @@ -13,40 +13,9 @@
{{if .PageIsSettingsHooksNew}}{{.i18n.Tr "repo.settings.add_webhook"}}{{else}}{{.i18n.Tr "repo.settings.update_webhook"}}{{end}}
-
- {{.CsrfTokenHtml}} -
{{.i18n.Tr "repo.settings.add_webhook_desc" | Str2html}}
-
- - -
-
- - -
-
- - -
-
-

{{.i18n.Tr "repo.settings.event_desc"}}

- - {{.i18n.Tr "repo.settings.event_push_only" | Str2html}} -
-
- - - {{.i18n.Tr "repo.settings.active_helper"}} -
-
- - - {{if .PageIsSettingsHooksEdit}}{{.i18n.Tr "repo.settings.delete_webhook"}}{{end}} -
-
+ {{template "repo/settings/hook_types" .}} + {{template "repo/settings/gogs_hook" .}} + {{template "repo/settings/slack_hook" .}} {{if .PageIsSettingsHooksEdit}} @@ -67,4 +36,4 @@ -{{template "ng/base/footer" .}} \ No newline at end of file +{{template "ng/base/footer" .}} diff --git a/templates/repo/settings/hook_settings.tmpl b/templates/repo/settings/hook_settings.tmpl new file mode 100644 index 0000000000..7bf4e2a36c --- /dev/null +++ b/templates/repo/settings/hook_settings.tmpl @@ -0,0 +1,15 @@ +
+

{{.i18n.Tr "repo.settings.event_desc"}}

+ + {{.i18n.Tr "repo.settings.event_push_only" | Str2html}} +
+
+ + +{{.i18n.Tr "repo.settings.active_helper"}} +
+
+ + + {{if .PageIsSettingsHooksEdit}}{{.i18n.Tr "repo.settings.delete_webhook"}}{{end}} +
diff --git a/templates/repo/settings/hook_types.tmpl b/templates/repo/settings/hook_types.tmpl new file mode 100644 index 0000000000..782e2a4e73 --- /dev/null +++ b/templates/repo/settings/hook_types.tmpl @@ -0,0 +1,11 @@ +{{if .PageIsSettingsHooksNew}} +
+ + +
+{{end}} diff --git a/templates/repo/settings/slack_hook.tmpl b/templates/repo/settings/slack_hook.tmpl new file mode 100644 index 0000000000..e68571a081 --- /dev/null +++ b/templates/repo/settings/slack_hook.tmpl @@ -0,0 +1,20 @@ +
+
+ {{.CsrfTokenHtml}} + +
{{.i18n.Tr "repo.settings.add_slack_hook_desc" | Str2html}}
+
+ + +
+
+ + +
+
+ + +
+ {{template "repo/settings/hook_settings" .}} +
+