summaryrefslogtreecommitdiffstats
path: root/services/webhook/slack.go
diff options
context:
space:
mode:
Diffstat (limited to 'services/webhook/slack.go')
-rw-r--r--services/webhook/slack.go361
1 files changed, 361 insertions, 0 deletions
diff --git a/services/webhook/slack.go b/services/webhook/slack.go
new file mode 100644
index 0000000..af93976
--- /dev/null
+++ b/services/webhook/slack.go
@@ -0,0 +1,361 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webhook
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "regexp"
+ "strings"
+
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/webhook/shared"
+
+ "gitea.com/go-chi/binding"
+)
+
+type slackHandler struct{}
+
+func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK }
+func (slackHandler) Icon(size int) template.HTML { return shared.ImgIcon("slack.png", size) }
+
+type slackForm struct {
+ forms.WebhookCoreForm
+ PayloadURL string `binding:"Required;ValidUrl"`
+ Channel string `binding:"Required"`
+ Username string
+ IconURL string
+ Color string
+}
+
+var _ binding.Validator = &slackForm{}
+
+// Validate implements binding.Validator.
+func (s *slackForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := gitea_context.GetWebContext(req)
+ if !IsValidSlackChannel(strings.TrimSpace(s.Channel)) {
+ errs = append(errs, binding.Error{
+ FieldNames: []string{"Channel"},
+ Classification: "",
+ Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_channel_name"),
+ })
+ }
+ return errs
+}
+
+func (slackHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
+ var form slackForm
+ bind(&form)
+
+ return forms.WebhookForm{
+ WebhookCoreForm: form.WebhookCoreForm,
+ URL: form.PayloadURL,
+ ContentType: webhook_model.ContentTypeJSON,
+ Secret: "",
+ HTTPMethod: http.MethodPost,
+ Metadata: &SlackMeta{
+ Channel: strings.TrimSpace(form.Channel),
+ Username: form.Username,
+ IconURL: form.IconURL,
+ Color: form.Color,
+ },
+ }
+}
+
+// SlackMeta contains the slack metadata
+type SlackMeta struct {
+ Channel string `json:"channel"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ Color string `json:"color"`
+}
+
+// Metadata returns slack metadata
+func (slackHandler) Metadata(w *webhook_model.Webhook) any {
+ s := &SlackMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
+ log.Error("slackHandler.Metadata(%d): %v", w.ID, err)
+ }
+ return s
+}
+
+// SlackPayload contains the information about the slack 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"`
+}
+
+// SlackAttachment contains the slack message
+type SlackAttachment struct {
+ Fallback string `json:"fallback"`
+ Color string `json:"color"`
+ Title string `json:"title"`
+ TitleLink string `json:"title_link"`
+ Text string `json:"text"`
+}
+
+// SlackTextFormatter replaces &, <, > with HTML characters
+// see: https://api.slack.com/docs/formatting
+func SlackTextFormatter(s string) string {
+ // replace & < >
+ s = strings.ReplaceAll(s, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ return s
+}
+
+// SlackShortTextFormatter replaces &, <, > with HTML characters
+func SlackShortTextFormatter(s string) string {
+ s = strings.Split(s, "\n")[0]
+ // replace & < >
+ s = strings.ReplaceAll(s, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ return s
+}
+
+// SlackLinkFormatter creates a link compatible with slack
+func SlackLinkFormatter(url, text string) string {
+ return fmt.Sprintf("<%s|%s>", url, SlackTextFormatter(text))
+}
+
+// SlackLinkToRef slack-formatter link to a repo ref
+func SlackLinkToRef(repoURL, ref string) string {
+ // FIXME: SHA1 hardcoded here
+ url := git.RefURL(repoURL, ref)
+ refName := git.RefName(ref).ShortName()
+ return SlackLinkFormatter(url, refName)
+}
+
+// Create implements payloadConvertor Create method
+func (s slackConvertor) Create(p *api.CreatePayload) (SlackPayload, error) {
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Delete composes Slack payload for delete a branch or tag.
+func (s slackConvertor) Delete(p *api.DeletePayload) (SlackPayload, error) {
+ refName := git.RefName(p.Ref).ShortName()
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Fork composes Slack payload for forked by a repository.
+func (s slackConvertor) Fork(p *api.ForkPayload) (SlackPayload, error) {
+ baseLink := SlackLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName)
+ forkLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Issue implements payloadConvertor Issue method
+func (s slackConvertor) Issue(p *api.IssuePayload) (SlackPayload, error) {
+ text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter, true)
+
+ var attachments []SlackAttachment
+ if attachmentText != "" {
+ attachmentText = SlackTextFormatter(attachmentText)
+ issueTitle = SlackTextFormatter(issueTitle)
+ attachments = append(attachments, SlackAttachment{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.Issue.HTMLURL,
+ Text: attachmentText,
+ })
+ }
+
+ return s.createPayload(text, attachments), nil
+}
+
+// IssueComment implements payloadConvertor IssueComment method
+func (s slackConvertor) IssueComment(p *api.IssueCommentPayload) (SlackPayload, error) {
+ text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, []SlackAttachment{{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.Comment.HTMLURL,
+ Text: SlackTextFormatter(p.Comment.Body),
+ }}), nil
+}
+
+// Wiki implements payloadConvertor Wiki method
+func (s slackConvertor) Wiki(p *api.WikiPayload) (SlackPayload, error) {
+ text, _, _ := getWikiPayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Release implements payloadConvertor Release method
+func (s slackConvertor) Release(p *api.ReleasePayload) (SlackPayload, error) {
+ text, _ := getReleasePayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+func (s slackConvertor) Package(p *api.PackagePayload) (SlackPayload, error) {
+ text, _ := getPackagePayloadInfo(p, SlackLinkFormatter, true)
+
+ return s.createPayload(text, nil), nil
+}
+
+// Push implements payloadConvertor Push method
+func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) {
+ // n new commits
+ var (
+ commitDesc string
+ commitString string
+ )
+
+ if p.TotalCommits == 1 {
+ commitDesc = "1 new commit"
+ } else {
+ commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
+ }
+ if len(p.CompareURL) > 0 {
+ commitString = SlackLinkFormatter(p.CompareURL, commitDesc)
+ } else {
+ commitString = commitDesc
+ }
+
+ repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
+ branchLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
+ text := fmt.Sprintf("[%s:%s] %s pushed by %s", repoLink, branchLink, commitString, p.Pusher.UserName)
+
+ var attachmentText string
+ // for each commit, generate attachment text
+ for i, commit := range p.Commits {
+ attachmentText += fmt.Sprintf("%s: %s - %s", SlackLinkFormatter(commit.URL, commit.ID[:7]), SlackShortTextFormatter(commit.Message), SlackTextFormatter(commit.Author.Name))
+ // add linebreak to each commit but the last
+ if i < len(p.Commits)-1 {
+ attachmentText += "\n"
+ }
+ }
+
+ return s.createPayload(text, []SlackAttachment{{
+ Color: s.Color,
+ Title: p.Repo.HTMLURL,
+ TitleLink: p.Repo.HTMLURL,
+ Text: attachmentText,
+ }}), nil
+}
+
+// PullRequest implements payloadConvertor PullRequest method
+func (s slackConvertor) PullRequest(p *api.PullRequestPayload) (SlackPayload, error) {
+ text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter, true)
+
+ var attachments []SlackAttachment
+ if attachmentText != "" {
+ attachmentText = SlackTextFormatter(p.PullRequest.Body)
+ issueTitle = SlackTextFormatter(issueTitle)
+ attachments = append(attachments, SlackAttachment{
+ Color: fmt.Sprintf("%x", color),
+ Title: issueTitle,
+ TitleLink: p.PullRequest.HTMLURL,
+ Text: attachmentText,
+ })
+ }
+
+ return s.createPayload(text, attachments), nil
+}
+
+// Review implements payloadConvertor Review method
+func (s slackConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (SlackPayload, error) {
+ senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
+ titleLink := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index)
+ repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ if p.Action == api.HookIssueReviewed {
+ action, err := parseHookPullRequestEventType(event)
+ if err != nil {
+ return SlackPayload{}, err
+ }
+
+ text = fmt.Sprintf("[%s] Pull request review %s: [%s](%s) by %s", repoLink, action, title, titleLink, senderLink)
+ }
+
+ return s.createPayload(text, nil), nil
+}
+
+// Repository implements payloadConvertor Repository method
+func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, error) {
+ senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+ repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+ var text string
+
+ switch p.Action {
+ case api.HookRepoCreated:
+ text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
+ case api.HookRepoDeleted:
+ text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
+ }
+
+ return s.createPayload(text, nil), nil
+}
+
+func (s slackConvertor) createPayload(text string, attachments []SlackAttachment) SlackPayload {
+ return SlackPayload{
+ Channel: s.Channel,
+ Text: text,
+ Username: s.Username,
+ IconURL: s.IconURL,
+ Attachments: attachments,
+ }
+}
+
+type slackConvertor struct {
+ Channel string
+ Username string
+ IconURL string
+ Color string
+}
+
+var _ shared.PayloadConvertor[SlackPayload] = slackConvertor{}
+
+func (slackHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
+ meta := &SlackMeta{}
+ if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
+ return nil, nil, fmt.Errorf("slackHandler.NewRequest meta json: %w", err)
+ }
+ sc := slackConvertor{
+ Channel: meta.Channel,
+ Username: meta.Username,
+ IconURL: meta.IconURL,
+ Color: meta.Color,
+ }
+ return shared.NewJSONRequest(sc, w, t, true)
+}
+
+var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
+
+// IsValidSlackChannel validates a channel name conforms to what slack expects:
+// https://api.slack.com/methods/conversations.rename#naming
+// Conversation names can only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less.
+// Forgejo accepts if it starts with a #.
+func IsValidSlackChannel(name string) bool {
+ return slackChannel.MatchString(name)
+}