diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2017-08-28 13:06:45 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-08-28 13:06:45 +0800 |
commit | ced50e0ec13085504fa19c82f018a2eecb70ff68 (patch) | |
tree | 10dff3ff685a86bad683c731f8329743f217617e | |
parent | e41da3845d72e3adc7ae2cd3a02fc1f3943ebee6 (diff) | |
download | gitea-ced50e0ec13085504fa19c82f018a2eecb70ff68.tar.gz gitea-ced50e0ec13085504fa19c82f018a2eecb70ff68.zip |
Implementation of discord webhook (#2402)
* implementation of discord webhook
* fix webhooks
* fix typo and unnecessary color values
* fix typo
* fix imports and revert changes to webhook_slack.go
-rw-r--r-- | models/webhook.go | 33 | ||||
-rw-r--r-- | models/webhook_discord.go | 252 | ||||
-rw-r--r-- | modules/auth/repo_form.go | 13 | ||||
-rw-r--r-- | modules/setting/setting.go | 2 | ||||
-rw-r--r-- | options/locale/locale_en-US.ini | 3 | ||||
-rw-r--r-- | public/img/discord.png | bin | 0 -> 1559 bytes | |||
-rw-r--r-- | routers/repo/webhook.go | 111 | ||||
-rw-r--r-- | routers/routes/routes.go | 2 | ||||
-rw-r--r-- | templates/repo/settings/hook_discord.tmpl | 19 | ||||
-rw-r--r-- | templates/repo/settings/hook_list.tmpl | 3 | ||||
-rw-r--r-- | templates/repo/settings/hook_new.tmpl | 3 |
11 files changed, 427 insertions, 14 deletions
diff --git a/models/webhook.go b/models/webhook.go index b7e687a461..61840a9811 100644 --- a/models/webhook.go +++ b/models/webhook.go @@ -13,15 +13,14 @@ import ( "strings" "time" - "github.com/go-xorm/xorm" - gouuid "github.com/satori/go.uuid" - - api "code.gitea.io/sdk/gitea" - "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sync" + api "code.gitea.io/sdk/gitea" + + "github.com/go-xorm/xorm" + gouuid "github.com/satori/go.uuid" ) // HookQueue is a global queue of web hooks @@ -150,6 +149,15 @@ func (w *Webhook) GetSlackHook() *SlackMeta { return s } +// GetDiscordHook returns discord metadata +func (w *Webhook) GetDiscordHook() *DiscordMeta { + s := &DiscordMeta{} + if err := json.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error(4, "webhook.GetDiscordHook(%d): %v", w.ID, err) + } + return s +} + // History returns history of webhook by given conditions. func (w *Webhook) History(page int) ([]*HookTask, error) { return HookTasks(w.ID, page) @@ -314,12 +322,14 @@ const ( GOGS HookTaskType = iota + 1 SLACK GITEA + DISCORD ) var hookTaskTypes = map[string]HookTaskType{ - "gitea": GITEA, - "gogs": GOGS, - "slack": SLACK, + "gitea": GITEA, + "gogs": GOGS, + "slack": SLACK, + "discord": DISCORD, } // ToHookTaskType returns HookTaskType by given name. @@ -336,6 +346,8 @@ func (t HookTaskType) Name() string { return "gogs" case SLACK: return "slack" + case DISCORD: + return "discord" } return "" } @@ -515,6 +527,11 @@ func PrepareWebhooks(repo *Repository, event HookEventType, p api.Payloader) err if err != nil { return fmt.Errorf("GetSlackPayload: %v", err) } + case DISCORD: + payloader, err = GetDiscordPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetDiscordPayload: %v", err) + } default: p.SetSecret(w.Secret) payloader = p diff --git a/models/webhook_discord.go b/models/webhook_discord.go new file mode 100644 index 0000000000..bdb363af73 --- /dev/null +++ b/models/webhook_discord.go @@ -0,0 +1,252 @@ +package models + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "code.gitea.io/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" +) + +type ( + // DiscordEmbedFooter for Embed Footer Structure. + DiscordEmbedFooter struct { + Text string `json:"text"` + } + + // DiscordEmbedAuthor for Embed Author Structure + DiscordEmbedAuthor struct { + Name string `json:"name"` + URL string `json:"url"` + IconURL string `json:"icon_url"` + } + + // DiscordEmbedField for Embed Field Structure + DiscordEmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + } + + // DiscordEmbed is for Embed Structure + DiscordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Color int `json:"color"` + Footer DiscordEmbedFooter `json:"footer"` + Author DiscordEmbedAuthor `json:"author"` + Fields []DiscordEmbedField `json:"fields"` + } + + // DiscordPayload represents + DiscordPayload struct { + Wait bool `json:"wait"` + Content string `json:"content"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + TTS bool `json:"tts"` + Embeds []DiscordEmbed `json:"embeds"` + } + + // DiscordMeta contains the discord metadata + DiscordMeta struct { + Username string `json:"username"` + IconURL string `json:"icon_url"` + } +) + +func color(clr string) int { + if clr != "" { + clr = strings.TrimLeft(clr, "#") + if s, err := strconv.ParseInt(clr, 16, 32); err == nil { + return int(s) + } + } + + return 0 +} + +var ( + successColor = color("1ac600") + warnColor = color("ffd930") + failedColor = color("ff3232") +) + +// SetSecret sets the discord secret +func (p *DiscordPayload) SetSecret(_ string) {} + +// JSONPayload Marshals the DiscordPayload to json +func (p *DiscordPayload) JSONPayload() ([]byte, error) { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return []byte{}, err + } + return data, nil +} + +func getDiscordCreatePayload(p *api.CreatePayload, meta *DiscordMeta) (*DiscordPayload, error) { + // created tag/branch + refName := git.RefEndName(p.Ref) + title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + URL: p.Repo.HTMLURL + "/src/" + refName, + Color: successColor, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + +func getDiscordPushPayload(p *api.PushPayload, meta *DiscordMeta) (*DiscordPayload, error) { + var ( + branchName = git.RefEndName(p.Ref) + commitDesc string + ) + + var titleLink string + if len(p.Commits) == 1 { + commitDesc = "1 new commit" + titleLink = p.Commits[0].URL + } else { + commitDesc = fmt.Sprintf("%d new commits", len(p.Commits)) + titleLink = p.CompareURL + } + if titleLink == "" { + titleLink = p.Repo.HTMLURL + "/src/" + branchName + } + + title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc) + + var text string + // for each commit, generate attachment text + for i, commit := range p.Commits { + text += fmt.Sprintf("[%s](%s) %s - %s", commit.ID[:7], commit.URL, + strings.TrimRight(commit.Message, "\r\n"), commit.Author.Name) + // add linebreak to each commit but the last + if i < len(p.Commits)-1 { + text += "\n" + } + } + + fmt.Println(text) + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + Description: text, + URL: titleLink, + Color: successColor, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + +func getDiscordPullRequestPayload(p *api.PullRequestPayload, meta *DiscordMeta) (*DiscordPayload, error) { + var text, title string + var color int + switch p.Action { + case api.HookIssueOpened: + title = fmt.Sprintf("[%s] Pull request opened: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueClosed: + if p.PullRequest.HasMerged { + title = fmt.Sprintf("[%s] Pull request merged: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + color = successColor + } else { + title = fmt.Sprintf("[%s] Pull request closed: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + color = failedColor + } + text = p.PullRequest.Body + case api.HookIssueReOpened: + title = fmt.Sprintf("[%s] Pull request re-opened: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueEdited: + title = fmt.Sprintf("[%s] Pull request edited: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueAssigned: + title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d %s", p.Repository.FullName, + p.PullRequest.Assignee.UserName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = successColor + case api.HookIssueUnassigned: + title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueLabelUpdated: + title = fmt.Sprintf("[%s] Pull request labels updated: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueLabelCleared: + title = fmt.Sprintf("[%s] Pull request labels cleared: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + case api.HookIssueSynchronized: + title = fmt.Sprintf("[%s] Pull request synchronized: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title) + text = p.PullRequest.Body + color = warnColor + } + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + Description: text, + URL: p.PullRequest.HTMLURL, + Color: color, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + +// GetDiscordPayload converts a discord webhook into a DiscordPayload +func GetDiscordPayload(p api.Payloader, event HookEventType, meta string) (*DiscordPayload, error) { + s := new(DiscordPayload) + + discord := &DiscordMeta{} + if err := json.Unmarshal([]byte(meta), &discord); err != nil { + return s, errors.New("GetDiscordPayload meta json:" + err.Error()) + } + + switch event { + case HookEventCreate: + return getDiscordCreatePayload(p.(*api.CreatePayload), discord) + case HookEventPush: + return getDiscordPushPayload(p.(*api.PushPayload), discord) + case HookEventPullRequest: + return getDiscordPullRequestPayload(p.(*api.PullRequestPayload), discord) + } + + return s, nil +} diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 681a478d3b..70c9c8b4af 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -183,6 +183,19 @@ func (f *NewSlackHookForm) Validate(ctx *macaron.Context, errs binding.Errors) b return validate(errs, ctx.Data, f, ctx.Locale) } +// NewDiscordHookForm form for creating discord hook +type NewDiscordHookForm struct { + PayloadURL string `binding:"Required;ValidUrl"` + Username string + IconURL string + WebhookForm +} + +// Validate validates the fields +func (f *NewDiscordHookForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + // .___ // | | ______ ________ __ ____ // | |/ ___// ___/ | \_/ __ \ diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c25c2e0c65..a5908bacaa 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -1367,7 +1367,7 @@ func newWebhookService() { Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000) Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5) Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool() - Webhook.Types = []string{"gitea", "gogs", "slack"} + Webhook.Types = []string{"gitea", "gogs", "slack", "discord"} Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4a8a39963f..fbd96b3b00 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -879,6 +879,8 @@ settings.content_type = Content Type settings.secret = Secret settings.slack_username = Username settings.slack_icon_url = Icon URL +settings.discord_username = Username +settings.discord_icon_url = Icon URL settings.slack_color = Color settings.event_desc = When should this webhook be triggered? settings.event_push_only = Just the <code>push</code> event. @@ -902,6 +904,7 @@ settings.add_slack_hook_desc = Add <a href="%s">Slack</a> integration to your re settings.slack_token = Token settings.slack_domain = Domain settings.slack_channel = Channel +settings.add_discord_hook_desc = Add <a href="%s">Discord</a> integration to your repository. settings.deploy_keys = Deploy Keys settings.add_deploy_key = Add Deploy Key settings.deploy_key_desc = Deploy keys have read-only access. They are not the same as personal account SSH keys. diff --git a/public/img/discord.png b/public/img/discord.png Binary files differnew file mode 100644 index 0000000000..db0e70d5d4 --- /dev/null +++ b/public/img/discord.png diff --git a/routers/repo/webhook.go b/routers/repo/webhook.go index 5489d71857..ade40649b6 100644 --- a/routers/repo/webhook.go +++ b/routers/repo/webhook.go @@ -11,16 +11,15 @@ import ( "fmt" "strings" - "github.com/Unknwon/com" - "code.gitea.io/git" - api "code.gitea.io/sdk/gitea" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" + + "github.com/Unknwon/com" ) const ( @@ -96,10 +95,18 @@ func WebhooksNew(ctx *context.Context) { return } - ctx.Data["HookType"] = checkHookType(ctx) + hookType := checkHookType(ctx) + ctx.Data["HookType"] = hookType if ctx.Written() { return } + if hookType == "discord" { + ctx.Data["DiscordHook"] = map[string]interface{}{ + "Username": "Gitea", + "IconURL": setting.AppURL + "img/favicon.png", + "Color": 16724530, + } + } ctx.Data["BaseLink"] = orCtx.Link ctx.HTML(200, orCtx.NewTemplate) @@ -213,6 +220,55 @@ func GogsHooksNewPost(ctx *context.Context, form auth.NewWebhookForm) { ctx.Redirect(orCtx.Link + "/settings/hooks") } +// DiscordHooksNewPost response for creating discord hook +func DiscordHooksNewPost(ctx *context.Context, form auth.NewDiscordHookForm) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksNew"] = true + ctx.Data["Webhook"] = models.Webhook{HookEvent: &models.HookEvent{}} + + orCtx, err := getOrgRepoCtx(ctx) + if err != nil { + ctx.Handle(500, "getOrgRepoCtx", err) + return + } + + if ctx.HasError() { + ctx.HTML(200, orCtx.NewTemplate) + return + } + + meta, err := json.Marshal(&models.DiscordMeta{ + Username: form.Username, + IconURL: form.IconURL, + }) + if err != nil { + ctx.Handle(500, "Marshal", err) + return + } + + w := &models.Webhook{ + RepoID: orCtx.RepoID, + URL: form.PayloadURL, + ContentType: models.ContentTypeJSON, + HookEvent: ParseHookEvent(form.WebhookForm), + IsActive: form.Active, + HookTaskType: models.DISCORD, + Meta: string(meta), + OrgID: orCtx.OrgID, + } + 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(orCtx.Link + "/settings/hooks") +} + // SlackHooksNewPost response for creating slack hook func SlackHooksNewPost(ctx *context.Context, form auth.NewSlackHookForm) { ctx.Data["Title"] = ctx.Tr("repo.settings") @@ -295,6 +351,9 @@ func checkWebhook(ctx *context.Context) (*orgRepoCtx, *models.Webhook) { ctx.Data["HookType"] = "slack" case models.GOGS: ctx.Data["HookType"] = "gogs" + case models.DISCORD: + ctx.Data["DiscordHook"] = w.GetDiscordHook() + ctx.Data["HookType"] = "discord" default: ctx.Data["HookType"] = "gitea" } @@ -443,6 +502,48 @@ func SlackHooksEditPost(ctx *context.Context, form auth.NewSlackHookForm) { ctx.Redirect(fmt.Sprintf("%s/settings/hooks/%d", orCtx.Link, w.ID)) } +// DiscordHooksEditPost response for editing discord hook +func DiscordHooksEditPost(ctx *context.Context, form auth.NewDiscordHookForm) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["PageIsSettingsHooksEdit"] = true + + orCtx, w := checkWebhook(ctx) + if ctx.Written() { + return + } + ctx.Data["Webhook"] = w + + if ctx.HasError() { + ctx.HTML(200, orCtx.NewTemplate) + return + } + + meta, err := json.Marshal(&models.DiscordMeta{ + Username: form.Username, + IconURL: form.IconURL, + }) + if err != nil { + ctx.Handle(500, "Marshal", err) + return + } + + w.URL = form.PayloadURL + w.Meta = string(meta) + w.HookEvent = ParseHookEvent(form.WebhookForm) + 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, "UpdateWebhook", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) + ctx.Redirect(fmt.Sprintf("%s/settings/hooks/%d", orCtx.Link, w.ID)) +} + // TestWebhook test if web hook is work fine func TestWebhook(ctx *context.Context) { // Grab latest commit or fake one if it's empty repository. diff --git a/routers/routes/routes.go b/routers/routes/routes.go index d765c4c03b..c619c8b5af 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -442,11 +442,13 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/gitea/new", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksNewPost) m.Post("/gogs/new", bindIgnErr(auth.NewGogshookForm{}), repo.GogsHooksNewPost) m.Post("/slack/new", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksNewPost) + m.Post("/discord/new", bindIgnErr(auth.NewDiscordHookForm{}), repo.DiscordHooksNewPost) m.Get("/:id", repo.WebHooksEdit) m.Post("/:id/test", repo.TestWebhook) m.Post("/gitea/:id", bindIgnErr(auth.NewWebhookForm{}), repo.WebHooksEditPost) m.Post("/gogs/:id", bindIgnErr(auth.NewGogshookForm{}), repo.GogsHooksNewPost) m.Post("/slack/:id", bindIgnErr(auth.NewSlackHookForm{}), repo.SlackHooksEditPost) + m.Post("/discord/:id", bindIgnErr(auth.NewDiscordHookForm{}), repo.DiscordHooksEditPost) m.Group("/git", func() { m.Get("", repo.GitHooks) diff --git a/templates/repo/settings/hook_discord.tmpl b/templates/repo/settings/hook_discord.tmpl new file mode 100644 index 0000000000..901e7e6311 --- /dev/null +++ b/templates/repo/settings/hook_discord.tmpl @@ -0,0 +1,19 @@ +{{if eq .HookType "discord"}} + <p>{{.i18n.Tr "repo.settings.add_discord_hook_desc" "https://discordapp.com" | Str2html}}</p> + <form class="ui form" action="{{.BaseLink}}/settings/hooks/discord/{{if .PageIsSettingsHooksNew}}new{{else}}{{.Webhook.ID}}{{end}}" method="post"> + {{.CsrfTokenHtml}} + <div class="required field {{if .Err_PayloadURL}}error{{end}}"> + <label for="payload_url">{{.i18n.Tr "repo.settings.payload_url"}}</label> + <input id="payload_url" name="payload_url" type="url" value="{{.Webhook.URL}}" autofocus required> + </div> + <div class="field"> + <label for="username">{{.i18n.Tr "repo.settings.discord_username"}}</label> + <input id="username" name="username" value="{{.DiscordHook.Username}}" placeholder="e.g. Gitea"> + </div> + <div class="field"> + <label for="icon_url">{{.i18n.Tr "repo.settings.discord_icon_url"}}</label> + <input id="icon_url" name="icon_url" value="{{.DiscordHook.IconURL}}" placeholder="e.g. https://example.com/img/favicon.png"> + </div> + {{template "repo/settings/hook_settings" .}} + </form> +{{end}} diff --git a/templates/repo/settings/hook_list.tmpl b/templates/repo/settings/hook_list.tmpl index 45ef70ecad..dce3439096 100644 --- a/templates/repo/settings/hook_list.tmpl +++ b/templates/repo/settings/hook_list.tmpl @@ -14,6 +14,9 @@ <a class="item" href="{{.BaseLink}}/settings/hooks/slack/new"> <img class="img-10" src="{{AppSubUrl}}/img/slack.png">Slack </a> + <a class="item" href="{{.BaseLink}}/settings/hooks/discord/new"> + <img class="img-10" src="{{AppSubUrl}}/img/discord.png">Discord + </a> </div> </div> </div> diff --git a/templates/repo/settings/hook_new.tmpl b/templates/repo/settings/hook_new.tmpl index cbb52680cd..a40eb9e428 100644 --- a/templates/repo/settings/hook_new.tmpl +++ b/templates/repo/settings/hook_new.tmpl @@ -13,6 +13,8 @@ <img class="img-13" src="{{AppSubUrl}}/img/gogs.ico"> {{else if eq .HookType "slack"}} <img class="img-13" src="{{AppSubUrl}}/img/slack.png"> + {{else if eq .HookType "discord"}} + <img class="img-13" src="{{AppSubUrl}}/img/discord.png"> {{end}} </div> </h4> @@ -20,6 +22,7 @@ {{template "repo/settings/hook_gitea" .}} {{template "repo/settings/hook_gogs" .}} {{template "repo/settings/hook_slack" .}} + {{template "repo/settings/hook_discord" .}} </div> {{template "repo/settings/hook_history" .}} |