From 81e63d0714f1539fcd688cf3e216b0eab89e88aa Mon Sep 17 00:00:00 2001
From: Cornel <cornelk@users.noreply.github.com>
Date: Sat, 28 Dec 2019 16:55:09 +0800
Subject: [PATCH] Refactor webhooks to reduce code duplication (#9422)

* Start webhook refactoring to reduce code duplication

* More webhook refactoring

* Unify webhook release messages

* Fix webhook release link

* Remove sql import

* More webhook refactoring

* More webhook refactoring

* Webhook tests extended

* Fixed issue opened webhook

Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: techknowlogick <matti@mdranta.net>
---
 modules/webhook/dingtalk.go      | 166 ++++-----------------------
 modules/webhook/dingtalk_test.go |  31 ++++++
 modules/webhook/discord.go       | 165 +++------------------------
 modules/webhook/general.go       | 185 +++++++++++++++++++++++++++++++
 modules/webhook/general_test.go  | 125 +++++++++++++++++++++
 modules/webhook/msteams.go       | 173 +++--------------------------
 modules/webhook/slack.go         | 139 +++--------------------
 modules/webhook/slack_test.go    |  88 +++++++++++++++
 modules/webhook/telegram.go      | 136 ++---------------------
 modules/webhook/telegram_test.go |  24 ++++
 10 files changed, 534 insertions(+), 698 deletions(-)
 create mode 100644 modules/webhook/dingtalk_test.go
 create mode 100644 modules/webhook/general.go
 create mode 100644 modules/webhook/general_test.go
 create mode 100644 modules/webhook/slack_test.go
 create mode 100644 modules/webhook/telegram_test.go

diff --git a/modules/webhook/dingtalk.go b/modules/webhook/dingtalk.go
index 5ec8db9649..4869c1a37c 100644
--- a/modules/webhook/dingtalk.go
+++ b/modules/webhook/dingtalk.go
@@ -132,41 +132,14 @@ func getDingtalkPushPayload(p *api.PushPayload) (*DingtalkPayload, error) {
 }
 
 func getDingtalkIssuesPayload(p *api.IssuePayload) (*DingtalkPayload, error) {
-	var text, title string
-	switch p.Action {
-	case api.HookIssueOpened:
-		title = fmt.Sprintf("[%s] Issue opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-	case api.HookIssueClosed:
-		title = fmt.Sprintf("[%s] Issue closed: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Issue re-opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueEdited:
-		title = fmt.Sprintf("[%s] Issue edited: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-	case api.HookIssueAssigned:
-		title = fmt.Sprintf("[%s] Issue assigned to %s: #%d %s", p.Repository.FullName,
-			p.Issue.Assignee.UserName, p.Index, p.Issue.Title)
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Issue unassigned: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Issue labels updated: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Issue labels cleared: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Issue synchronized: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Issue milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Issue clear milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-	}
+	text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter)
 
 	return &DingtalkPayload{
 		MsgType: "actionCard",
 		ActionCard: dingtalk.ActionCard{
-			Text: title + "\r\n\r\n" + text,
+			Text: text + "\r\n\r\n" + attachmentText,
 			//Markdown:    "# " + title + "\n" + text,
-			Title:       title,
+			Title:       issueTitle,
 			HideAvatar:  "0",
 			SingleTitle: "view issue",
 			SingleURL:   p.Issue.URL,
@@ -175,93 +148,29 @@ func getDingtalkIssuesPayload(p *api.IssuePayload) (*DingtalkPayload, error) {
 }
 
 func getDingtalkIssueCommentPayload(p *api.IssueCommentPayload) (*DingtalkPayload, error) {
-	title := fmt.Sprintf("#%d: %s", p.Issue.Index, p.Issue.Title)
-	url := fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-	var content string
-	switch p.Action {
-	case api.HookIssueCommentCreated:
-		if p.IsPull {
-			title = "New comment on pull request " + title
-		} else {
-			title = "New comment on issue " + title
-		}
-		content = p.Comment.Body
-	case api.HookIssueCommentEdited:
-		if p.IsPull {
-			title = "Comment edited on pull request " + title
-		} else {
-			title = "Comment edited on issue " + title
-		}
-		content = p.Comment.Body
-	case api.HookIssueCommentDeleted:
-		if p.IsPull {
-			title = "Comment deleted on pull request " + title
-		} else {
-			title = "Comment deleted on issue " + title
-		}
-		url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-		content = p.Comment.Body
-	}
-
-	title = fmt.Sprintf("[%s] %s", p.Repository.FullName, title)
+	text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter)
 
 	return &DingtalkPayload{
 		MsgType: "actionCard",
 		ActionCard: dingtalk.ActionCard{
-			Text:        title + "\r\n\r\n" + content,
-			Title:       title,
+			Text:        text + "\r\n\r\n" + p.Comment.Body,
+			Title:       issueTitle,
 			HideAvatar:  "0",
 			SingleTitle: "view issue comment",
-			SingleURL:   url,
+			SingleURL:   p.Comment.HTMLURL,
 		},
 	}, nil
 }
 
 func getDingtalkPullRequestPayload(p *api.PullRequestPayload) (*DingtalkPayload, error) {
-	var text, title string
-	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
-	case api.HookIssueClosed:
-		if p.PullRequest.HasMerged {
-			title = fmt.Sprintf("[%s] Pull request merged: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		} else {
-			title = fmt.Sprintf("[%s] Pull request closed: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		}
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Pull request re-opened: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueEdited:
-		title = fmt.Sprintf("[%s] Pull request edited: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		text = p.PullRequest.Body
-	case api.HookIssueAssigned:
-		list := make([]string, len(p.PullRequest.Assignees))
-		for i, user := range p.PullRequest.Assignees {
-			list[i] = user.UserName
-		}
-		title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d %s", p.Repository.FullName,
-			strings.Join(list, ", "),
-			p.Index, p.PullRequest.Title)
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Pull request labels updated: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Pull request labels cleared: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Pull request synchronized: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Pull request milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Pull request clear milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-	}
+	text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter)
 
 	return &DingtalkPayload{
 		MsgType: "actionCard",
 		ActionCard: dingtalk.ActionCard{
-			Text: title + "\r\n\r\n" + text,
+			Text: text + "\r\n\r\n" + attachmentText,
 			//Markdown:    "# " + title + "\n" + text,
-			Title:       title,
+			Title:       issueTitle,
 			HideAvatar:  "0",
 			SingleTitle: "view pull request",
 			SingleURL:   p.PullRequest.HTMLURL,
@@ -327,51 +236,18 @@ func getDingtalkRepositoryPayload(p *api.RepositoryPayload) (*DingtalkPayload, e
 }
 
 func getDingtalkReleasePayload(p *api.ReleasePayload) (*DingtalkPayload, error) {
-	var title, url string
-	switch p.Action {
-	case api.HookReleasePublished:
-		title = fmt.Sprintf("[%s] Release created", p.Release.TagName)
-		url = p.Release.URL
-		return &DingtalkPayload{
-			MsgType: "actionCard",
-			ActionCard: dingtalk.ActionCard{
-				Text:        title,
-				Title:       title,
-				HideAvatar:  "0",
-				SingleTitle: "view release",
-				SingleURL:   url,
-			},
-		}, nil
-	case api.HookReleaseUpdated:
-		title = fmt.Sprintf("[%s] Release updated", p.Release.TagName)
-		url = p.Release.URL
-		return &DingtalkPayload{
-			MsgType: "actionCard",
-			ActionCard: dingtalk.ActionCard{
-				Text:        title,
-				Title:       title,
-				HideAvatar:  "0",
-				SingleTitle: "view release",
-				SingleURL:   url,
-			},
-		}, nil
+	text, _ := getReleasePayloadInfo(p, noneLinkFormatter)
 
-	case api.HookReleaseDeleted:
-		title = fmt.Sprintf("[%s] Release deleted", p.Release.TagName)
-		url = p.Release.URL
-		return &DingtalkPayload{
-			MsgType: "actionCard",
-			ActionCard: dingtalk.ActionCard{
-				Text:        title,
-				Title:       title,
-				HideAvatar:  "0",
-				SingleTitle: "view release",
-				SingleURL:   url,
-			},
-		}, nil
-	}
-
-	return nil, nil
+	return &DingtalkPayload{
+		MsgType: "actionCard",
+		ActionCard: dingtalk.ActionCard{
+			Text:        text,
+			Title:       text,
+			HideAvatar:  "0",
+			SingleTitle: "view release",
+			SingleURL:   p.Release.URL,
+		},
+	}, nil
 }
 
 // GetDingtalkPayload converts a ding talk webhook into a DingtalkPayload
diff --git a/modules/webhook/dingtalk_test.go b/modules/webhook/dingtalk_test.go
new file mode 100644
index 0000000000..f50cb9a587
--- /dev/null
+++ b/modules/webhook/dingtalk_test.go
@@ -0,0 +1,31 @@
+// Copyright 2019 The Gitea 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 webhook
+
+import (
+	"testing"
+
+	api "code.gitea.io/gitea/modules/structs"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGetDingTalkIssuesPayload(t *testing.T) {
+	p := issueTestPayload()
+
+	p.Action = api.HookIssueOpened
+	pl, err := getDingtalkIssuesPayload(p)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+	assert.Equal(t, "#2 crash", pl.ActionCard.Title)
+	assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\r\n\r\n", pl.ActionCard.Text)
+
+	p.Action = api.HookIssueClosed
+	pl, err = getDingtalkIssuesPayload(p)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+	assert.Equal(t, "#2 crash", pl.ActionCard.Title)
+	assert.Equal(t, "[test/repo] Issue closed: #2 crash by user1\r\n\r\n", pl.ActionCard.Text)
+}
diff --git a/modules/webhook/discord.go b/modules/webhook/discord.go
index 99444851bc..ea69f36fe7 100644
--- a/modules/webhook/discord.go
+++ b/modules/webhook/discord.go
@@ -227,56 +227,16 @@ func getDiscordPushPayload(p *api.PushPayload, meta *DiscordMeta) (*DiscordPaylo
 }
 
 func getDiscordIssuesPayload(p *api.IssuePayload, meta *DiscordMeta) (*DiscordPayload, error) {
-	var text, title string
-	var color int
-	url := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-	switch p.Action {
-	case api.HookIssueOpened:
-		title = fmt.Sprintf("[%s] Issue opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-		color = orangeColor
-	case api.HookIssueClosed:
-		title = fmt.Sprintf("[%s] Issue closed: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = redColor
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Issue re-opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueEdited:
-		title = fmt.Sprintf("[%s] Issue edited: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-		color = yellowColor
-	case api.HookIssueAssigned:
-		title = fmt.Sprintf("[%s] Issue assigned to %s: #%d %s", p.Repository.FullName,
-			p.Issue.Assignee.UserName, p.Index, p.Issue.Title)
-		color = greenColor
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Issue unassigned: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Issue labels updated: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Issue labels cleared: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Issue synchronized: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Issue milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Issue clear milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	}
+	text, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter)
 
 	return &DiscordPayload{
 		Username:  meta.Username,
 		AvatarURL: meta.IconURL,
 		Embeds: []DiscordEmbed{
 			{
-				Title:       title,
-				Description: text,
-				URL:         url,
+				Title:       text,
+				Description: attachmentText,
+				URL:         p.Issue.URL,
 				Color:       color,
 				Author: DiscordEmbedAuthor{
 					Name:    p.Sender.UserName,
@@ -289,49 +249,16 @@ func getDiscordIssuesPayload(p *api.IssuePayload, meta *DiscordMeta) (*DiscordPa
 }
 
 func getDiscordIssueCommentPayload(p *api.IssueCommentPayload, discord *DiscordMeta) (*DiscordPayload, error) {
-	title := fmt.Sprintf("#%d: %s", p.Issue.Index, p.Issue.Title)
-	url := fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-	content := ""
-	var color int
-	switch p.Action {
-	case api.HookIssueCommentCreated:
-		if p.IsPull {
-			title = "New comment on pull request " + title
-			color = greenColorLight
-		} else {
-			title = "New comment on issue " + title
-			color = orangeColorLight
-		}
-		content = p.Comment.Body
-	case api.HookIssueCommentEdited:
-		if p.IsPull {
-			title = "Comment edited on pull request " + title
-		} else {
-			title = "Comment edited on issue " + title
-		}
-		content = p.Comment.Body
-		color = yellowColor
-	case api.HookIssueCommentDeleted:
-		if p.IsPull {
-			title = "Comment deleted on pull request " + title
-		} else {
-			title = "Comment deleted on issue " + title
-		}
-		url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-		content = p.Comment.Body
-		color = redColor
-	}
-
-	title = fmt.Sprintf("[%s] %s", p.Repository.FullName, title)
+	text, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter)
 
 	return &DiscordPayload{
 		Username:  discord.Username,
 		AvatarURL: discord.IconURL,
 		Embeds: []DiscordEmbed{
 			{
-				Title:       title,
-				Description: content,
-				URL:         url,
+				Title:       text,
+				Description: p.Comment.Body,
+				URL:         p.Comment.HTMLURL,
 				Color:       color,
 				Author: DiscordEmbedAuthor{
 					Name:    p.Sender.UserName,
@@ -344,64 +271,15 @@ func getDiscordIssueCommentPayload(p *api.IssueCommentPayload, discord *DiscordM
 }
 
 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 = greenColor
-	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 = purpleColor
-		} else {
-			title = fmt.Sprintf("[%s] Pull request closed: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-			color = redColor
-		}
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Pull request re-opened: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	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 = yellowColor
-	case api.HookIssueAssigned:
-		list := make([]string, len(p.PullRequest.Assignees))
-		for i, user := range p.PullRequest.Assignees {
-			list[i] = user.UserName
-		}
-		title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d by %s", p.Repository.FullName,
-			strings.Join(list, ", "),
-			p.Index, p.PullRequest.Title)
-		color = greenColor
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Pull request labels updated: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Pull request labels cleared: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Pull request synchronized: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Pull request milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Pull request clear milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	}
+	text, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter)
 
 	return &DiscordPayload{
 		Username:  meta.Username,
 		AvatarURL: meta.IconURL,
 		Embeds: []DiscordEmbed{
 			{
-				Title:       title,
-				Description: text,
+				Title:       text,
+				Description: attachmentText,
 				URL:         p.PullRequest.HTMLURL,
 				Color:       color,
 				Author: DiscordEmbedAuthor{
@@ -490,31 +368,16 @@ func getDiscordRepositoryPayload(p *api.RepositoryPayload, meta *DiscordMeta) (*
 }
 
 func getDiscordReleasePayload(p *api.ReleasePayload, meta *DiscordMeta) (*DiscordPayload, error) {
-	var title, url string
-	var color int
-	switch p.Action {
-	case api.HookReleasePublished:
-		title = fmt.Sprintf("[%s] Release created", p.Release.TagName)
-		url = p.Release.URL
-		color = greenColor
-	case api.HookReleaseUpdated:
-		title = fmt.Sprintf("[%s] Release updated", p.Release.TagName)
-		url = p.Release.URL
-		color = yellowColor
-	case api.HookReleaseDeleted:
-		title = fmt.Sprintf("[%s] Release deleted", p.Release.TagName)
-		url = p.Release.URL
-		color = redColor
-	}
+	text, color := getReleasePayloadInfo(p, noneLinkFormatter)
 
 	return &DiscordPayload{
 		Username:  meta.Username,
 		AvatarURL: meta.IconURL,
 		Embeds: []DiscordEmbed{
 			{
-				Title:       title,
+				Title:       text,
 				Description: p.Release.Note,
-				URL:         url,
+				URL:         p.Release.URL,
 				Color:       color,
 				Author: DiscordEmbedAuthor{
 					Name:    p.Sender.UserName,
diff --git a/modules/webhook/general.go b/modules/webhook/general.go
new file mode 100644
index 0000000000..28c3b2730d
--- /dev/null
+++ b/modules/webhook/general.go
@@ -0,0 +1,185 @@
+// Copyright 2019 The Gitea 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 webhook
+
+import (
+	"fmt"
+	"html"
+	"strings"
+
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
+)
+
+type linkFormatter = func(string, string) string
+
+// noneLinkFormatter does not create a link but just returns the text
+func noneLinkFormatter(url string, text string) string {
+	return text
+}
+
+// htmlLinkFormatter creates a HTML link
+func htmlLinkFormatter(url string, text string) string {
+	return fmt.Sprintf(`<a href="%s">%s</a>`, url, html.EscapeString(text))
+}
+
+func getIssuesPayloadInfo(p *api.IssuePayload, linkFormatter linkFormatter) (string, string, string, int) {
+	senderLink := linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+	repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+	issueTitle := fmt.Sprintf("#%d %s", p.Index, p.Issue.Title)
+	titleLink := linkFormatter(fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index), issueTitle)
+	var text string
+	color := yellowColor
+
+	switch p.Action {
+	case api.HookIssueOpened:
+		text = fmt.Sprintf("[%s] Issue opened: %s by %s", repoLink, titleLink, senderLink)
+		color = orangeColor
+	case api.HookIssueClosed:
+		text = fmt.Sprintf("[%s] Issue closed: %s by %s", repoLink, titleLink, senderLink)
+		color = redColor
+	case api.HookIssueReOpened:
+		text = fmt.Sprintf("[%s] Issue re-opened: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueEdited:
+		text = fmt.Sprintf("[%s] Issue edited: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueAssigned:
+		text = fmt.Sprintf("[%s] Issue assigned to %s: %s by %s", repoLink,
+			linkFormatter(setting.AppURL+p.Issue.Assignee.UserName, p.Issue.Assignee.UserName),
+			titleLink, senderLink)
+		color = greenColor
+	case api.HookIssueUnassigned:
+		text = fmt.Sprintf("[%s] Issue unassigned: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueLabelUpdated:
+		text = fmt.Sprintf("[%s] Issue labels updated: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueLabelCleared:
+		text = fmt.Sprintf("[%s] Issue labels cleared: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueSynchronized:
+		text = fmt.Sprintf("[%s] Issue synchronized: %s by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueMilestoned:
+		mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.Issue.Milestone.ID)
+		text = fmt.Sprintf("[%s] Issue milestoned to %s: %s by %s", repoLink,
+			linkFormatter(mileStoneLink, p.Issue.Milestone.Title), titleLink, senderLink)
+	case api.HookIssueDemilestoned:
+		text = fmt.Sprintf("[%s] Issue milestone cleared: %s by %s", repoLink, titleLink, senderLink)
+	}
+
+	var attachmentText string
+	if p.Action == api.HookIssueOpened || p.Action == api.HookIssueEdited {
+		attachmentText = p.Issue.Body
+	}
+
+	return text, issueTitle, attachmentText, color
+}
+
+func getPullRequestPayloadInfo(p *api.PullRequestPayload, linkFormatter linkFormatter) (string, string, string, int) {
+	senderLink := linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+	repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+	issueTitle := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
+	titleLink := linkFormatter(p.PullRequest.URL, issueTitle)
+	var text string
+	color := yellowColor
+
+	switch p.Action {
+	case api.HookIssueOpened:
+		text = fmt.Sprintf("[%s] Pull request %s opened by %s", repoLink, titleLink, senderLink)
+		color = greenColor
+	case api.HookIssueClosed:
+		if p.PullRequest.HasMerged {
+			text = fmt.Sprintf("[%s] Pull request %s merged by %s", repoLink, titleLink, senderLink)
+			color = purpleColor
+		} else {
+			text = fmt.Sprintf("[%s] Pull request %s closed by %s", repoLink, titleLink, senderLink)
+			color = redColor
+		}
+	case api.HookIssueReOpened:
+		text = fmt.Sprintf("[%s] Pull request %s re-opened by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueEdited:
+		text = fmt.Sprintf("[%s] Pull request %s edited by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueAssigned:
+		list := make([]string, len(p.PullRequest.Assignees))
+		for i, user := range p.PullRequest.Assignees {
+			list[i] = linkFormatter(setting.AppURL+user.UserName, user.UserName)
+		}
+		text = fmt.Sprintf("[%s] Pull request %s assigned to %s by %s", repoLink,
+			strings.Join(list, ", "),
+			titleLink, senderLink)
+		color = greenColor
+	case api.HookIssueUnassigned:
+		text = fmt.Sprintf("[%s] Pull request %s unassigned by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueLabelUpdated:
+		text = fmt.Sprintf("[%s] Pull request %s labels updated by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueLabelCleared:
+		text = fmt.Sprintf("[%s] Pull request %s labels cleared by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueSynchronized:
+		text = fmt.Sprintf("[%s] Pull request %s synchronized by %s", repoLink, titleLink, senderLink)
+	case api.HookIssueMilestoned:
+		mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.PullRequest.Milestone.ID)
+		text = fmt.Sprintf("[%s] Pull request %s milestoned to %s by %s", repoLink,
+			linkFormatter(mileStoneLink, p.PullRequest.Milestone.Title), titleLink, senderLink)
+	case api.HookIssueDemilestoned:
+		text = fmt.Sprintf("[%s] Pull request %s milestone cleared by %s", repoLink, titleLink, senderLink)
+	}
+
+	var attachmentText string
+	if p.Action == api.HookIssueOpened || p.Action == api.HookIssueEdited {
+		attachmentText = p.PullRequest.Body
+	}
+
+	return text, issueTitle, attachmentText, color
+}
+
+func getReleasePayloadInfo(p *api.ReleasePayload, linkFormatter linkFormatter) (text string, color int) {
+	senderLink := linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+	repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+	refLink := linkFormatter(p.Repository.HTMLURL+"/src/"+p.Release.TagName, p.Release.TagName)
+
+	switch p.Action {
+	case api.HookReleasePublished:
+		text = fmt.Sprintf("[%s] Release %s created by %s", repoLink, refLink, senderLink)
+		color = greenColor
+	case api.HookReleaseUpdated:
+		text = fmt.Sprintf("[%s] Release %s updated by %s", repoLink, refLink, senderLink)
+		color = yellowColor
+	case api.HookReleaseDeleted:
+		text = fmt.Sprintf("[%s] Release %s deleted by %s", repoLink, refLink, senderLink)
+		color = redColor
+	}
+
+	return text, color
+}
+
+func getIssueCommentPayloadInfo(p *api.IssueCommentPayload, linkFormatter linkFormatter) (string, string, int) {
+	senderLink := linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
+	repoLink := linkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
+	issueTitle := fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title)
+
+	var text, typ, titleLink string
+	color := yellowColor
+
+	if p.IsPull {
+		typ = "pull request"
+		titleLink = linkFormatter(p.Comment.PRURL, issueTitle)
+	} else {
+		typ = "issue"
+		titleLink = linkFormatter(p.Comment.IssueURL, issueTitle)
+	}
+
+	switch p.Action {
+	case api.HookIssueCommentCreated:
+		text = fmt.Sprintf("[%s] New comment on %s %s by %s", repoLink, typ, titleLink, senderLink)
+		if p.IsPull {
+			color = greenColorLight
+		} else {
+			color = orangeColorLight
+		}
+	case api.HookIssueCommentEdited:
+		text = fmt.Sprintf("[%s] Comment on %s %s edited by %s", repoLink, typ, titleLink, senderLink)
+	case api.HookIssueCommentDeleted:
+		text = fmt.Sprintf("[%s] Comment on %s %s deleted by %s", repoLink, typ, titleLink, senderLink)
+		color = redColor
+	}
+
+	return text, issueTitle, color
+}
diff --git a/modules/webhook/general_test.go b/modules/webhook/general_test.go
new file mode 100644
index 0000000000..3033b57880
--- /dev/null
+++ b/modules/webhook/general_test.go
@@ -0,0 +1,125 @@
+// Copyright 2019 The Gitea 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 webhook
+
+import (
+	api "code.gitea.io/gitea/modules/structs"
+)
+
+func issueTestPayload() *api.IssuePayload {
+	return &api.IssuePayload{
+		Index: 2,
+		Sender: &api.User{
+			UserName: "user1",
+		},
+		Repository: &api.Repository{
+			HTMLURL:  "http://localhost:3000/test/repo",
+			Name:     "repo",
+			FullName: "test/repo",
+		},
+		Issue: &api.Issue{
+			ID:    2,
+			Index: 2,
+			URL:   "http://localhost:3000/api/v1/repos/test/repo/issues/2",
+			Title: "crash",
+		},
+	}
+}
+
+func issueCommentTestPayload() *api.IssueCommentPayload {
+	return &api.IssueCommentPayload{
+		Action: api.HookIssueCommentCreated,
+		Sender: &api.User{
+			UserName: "user1",
+		},
+		Repository: &api.Repository{
+			HTMLURL:  "http://localhost:3000/test/repo",
+			Name:     "repo",
+			FullName: "test/repo",
+		},
+		Comment: &api.Comment{
+			HTMLURL:  "http://localhost:3000/test/repo/issues/2#issuecomment-4",
+			IssueURL: "http://localhost:3000/test/repo/issues/2",
+			Body:     "more info needed",
+		},
+		Issue: &api.Issue{
+			ID:    2,
+			Index: 2,
+			URL:   "http://localhost:3000/api/v1/repos/test/repo/issues/2",
+			Title: "crash",
+			Body:  "this happened",
+		},
+	}
+}
+
+func pullRequestCommentTestPayload() *api.IssueCommentPayload {
+	return &api.IssueCommentPayload{
+		Action: api.HookIssueCommentCreated,
+		Sender: &api.User{
+			UserName: "user1",
+		},
+		Repository: &api.Repository{
+			HTMLURL:  "http://localhost:3000/test/repo",
+			Name:     "repo",
+			FullName: "test/repo",
+		},
+		Comment: &api.Comment{
+			HTMLURL: "http://localhost:3000/test/repo/pulls/2#issuecomment-4",
+			PRURL:   "http://localhost:3000/test/repo/pulls/2",
+			Body:    "changes requested",
+		},
+		Issue: &api.Issue{
+			ID:    2,
+			Index: 2,
+			URL:   "http://localhost:3000/api/v1/repos/test/repo/issues/2",
+			Title: "Fix bug",
+			Body:  "fixes bug #2",
+		},
+		IsPull: true,
+	}
+}
+
+func pullReleaseTestPayload() *api.ReleasePayload {
+	return &api.ReleasePayload{
+		Action: api.HookReleasePublished,
+		Sender: &api.User{
+			UserName: "user1",
+		},
+		Repository: &api.Repository{
+			HTMLURL:  "http://localhost:3000/test/repo",
+			Name:     "repo",
+			FullName: "test/repo",
+		},
+		Release: &api.Release{
+			TagName: "v1.0",
+			Target:  "master",
+			Title:   "First stable release",
+			URL:     "http://localhost:3000/api/v1/repos/test/repo/releases/2",
+		},
+	}
+}
+
+func pullRequestTestPayload() *api.PullRequestPayload {
+	return &api.PullRequestPayload{
+		Action: api.HookIssueOpened,
+		Index:  2,
+		Sender: &api.User{
+			UserName: "user1",
+		},
+		Repository: &api.Repository{
+			HTMLURL:  "http://localhost:3000/test/repo",
+			Name:     "repo",
+			FullName: "test/repo",
+		},
+		PullRequest: &api.PullRequest{
+			ID:        2,
+			Index:     2,
+			URL:       "http://localhost:3000/test/repo/pulls/12",
+			Title:     "Fix bug",
+			Body:      "fixes bug #2",
+			Mergeable: true,
+		},
+	}
+}
diff --git a/modules/webhook/msteams.go b/modules/webhook/msteams.go
index 8ff9bb5ba4..4c148421fe 100644
--- a/modules/webhook/msteams.go
+++ b/modules/webhook/msteams.go
@@ -266,60 +266,20 @@ func getMSTeamsPushPayload(p *api.PushPayload) (*MSTeamsPayload, error) {
 }
 
 func getMSTeamsIssuesPayload(p *api.IssuePayload) (*MSTeamsPayload, error) {
-	var text, title string
-	var color int
-	url := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-	switch p.Action {
-	case api.HookIssueOpened:
-		title = fmt.Sprintf("[%s] Issue opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-		color = orangeColor
-	case api.HookIssueClosed:
-		title = fmt.Sprintf("[%s] Issue closed: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = redColor
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Issue re-opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueEdited:
-		title = fmt.Sprintf("[%s] Issue edited: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-		color = yellowColor
-	case api.HookIssueAssigned:
-		title = fmt.Sprintf("[%s] Issue assigned to %s: #%d %s", p.Repository.FullName,
-			p.Issue.Assignee.UserName, p.Index, p.Issue.Title)
-		color = greenColor
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Issue unassigned: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Issue labels updated: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Issue labels cleared: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Issue synchronized: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Issue milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Issue clear milestone: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title)
-		color = yellowColor
-	}
+	text, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter)
 
 	return &MSTeamsPayload{
 		Type:       "MessageCard",
 		Context:    "https://schema.org/extensions",
 		ThemeColor: fmt.Sprintf("%x", color),
-		Title:      title,
-		Summary:    title,
+		Title:      text,
+		Summary:    text,
 		Sections: []MSTeamsSection{
 			{
 				ActivityTitle:    p.Sender.FullName,
 				ActivitySubtitle: p.Sender.UserName,
 				ActivityImage:    p.Sender.AvatarURL,
-				Text:             text,
+				Text:             attachmentText,
 				Facts: []MSTeamsFact{
 					{
 						Name:  "Repository:",
@@ -339,7 +299,7 @@ func getMSTeamsIssuesPayload(p *api.IssuePayload) (*MSTeamsPayload, error) {
 				Targets: []MSTeamsActionTarget{
 					{
 						Os:  "default",
-						URI: url,
+						URI: p.Issue.URL,
 					},
 				},
 			},
@@ -348,53 +308,20 @@ func getMSTeamsIssuesPayload(p *api.IssuePayload) (*MSTeamsPayload, error) {
 }
 
 func getMSTeamsIssueCommentPayload(p *api.IssueCommentPayload) (*MSTeamsPayload, error) {
-	title := fmt.Sprintf("#%d: %s", p.Issue.Index, p.Issue.Title)
-	url := fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-	content := ""
-	var color int
-	switch p.Action {
-	case api.HookIssueCommentCreated:
-		if p.IsPull {
-			title = "New comment on pull request " + title
-			color = greenColorLight
-		} else {
-			title = "New comment on issue " + title
-			color = orangeColorLight
-		}
-		content = p.Comment.Body
-	case api.HookIssueCommentEdited:
-		if p.IsPull {
-			title = "Comment edited on pull request " + title
-		} else {
-			title = "Comment edited on issue " + title
-		}
-		content = p.Comment.Body
-		color = yellowColor
-	case api.HookIssueCommentDeleted:
-		if p.IsPull {
-			title = "Comment deleted on pull request " + title
-		} else {
-			title = "Comment deleted on issue " + title
-		}
-		url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-		content = p.Comment.Body
-		color = redColor
-	}
-
-	title = fmt.Sprintf("[%s] %s", p.Repository.FullName, title)
+	text, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter)
 
 	return &MSTeamsPayload{
 		Type:       "MessageCard",
 		Context:    "https://schema.org/extensions",
 		ThemeColor: fmt.Sprintf("%x", color),
-		Title:      title,
-		Summary:    title,
+		Title:      text,
+		Summary:    text,
 		Sections: []MSTeamsSection{
 			{
 				ActivityTitle:    p.Sender.FullName,
 				ActivitySubtitle: p.Sender.UserName,
 				ActivityImage:    p.Sender.AvatarURL,
-				Text:             content,
+				Text:             p.Comment.Body,
 				Facts: []MSTeamsFact{
 					{
 						Name:  "Repository:",
@@ -414,7 +341,7 @@ func getMSTeamsIssueCommentPayload(p *api.IssueCommentPayload) (*MSTeamsPayload,
 				Targets: []MSTeamsActionTarget{
 					{
 						Os:  "default",
-						URI: url,
+						URI: p.Comment.HTMLURL,
 					},
 				},
 			},
@@ -423,69 +350,20 @@ func getMSTeamsIssueCommentPayload(p *api.IssueCommentPayload) (*MSTeamsPayload,
 }
 
 func getMSTeamsPullRequestPayload(p *api.PullRequestPayload) (*MSTeamsPayload, 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 = greenColor
-	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 = purpleColor
-		} else {
-			title = fmt.Sprintf("[%s] Pull request closed: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-			color = redColor
-		}
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf("[%s] Pull request re-opened: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	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 = yellowColor
-	case api.HookIssueAssigned:
-		list := make([]string, len(p.PullRequest.Assignees))
-		for i, user := range p.PullRequest.Assignees {
-			list[i] = user.UserName
-		}
-		title = fmt.Sprintf("[%s] Pull request assigned to %s: #%d by %s", p.Repository.FullName,
-			strings.Join(list, ", "),
-			p.Index, p.PullRequest.Title)
-		color = greenColor
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf("[%s] Pull request unassigned: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf("[%s] Pull request labels updated: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf("[%s] Pull request labels cleared: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf("[%s] Pull request synchronized: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf("[%s] Pull request milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf("[%s] Pull request clear milestone: #%d %s", p.Repository.FullName, p.Index, p.PullRequest.Title)
-		color = yellowColor
-	}
+	text, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter)
 
 	return &MSTeamsPayload{
 		Type:       "MessageCard",
 		Context:    "https://schema.org/extensions",
 		ThemeColor: fmt.Sprintf("%x", color),
-		Title:      title,
-		Summary:    title,
+		Title:      text,
+		Summary:    text,
 		Sections: []MSTeamsSection{
 			{
 				ActivityTitle:    p.Sender.FullName,
 				ActivitySubtitle: p.Sender.UserName,
 				ActivityImage:    p.Sender.AvatarURL,
-				Text:             text,
+				Text:             attachmentText,
 				Facts: []MSTeamsFact{
 					{
 						Name:  "Repository:",
@@ -625,29 +503,14 @@ func getMSTeamsRepositoryPayload(p *api.RepositoryPayload) (*MSTeamsPayload, err
 }
 
 func getMSTeamsReleasePayload(p *api.ReleasePayload) (*MSTeamsPayload, error) {
-	var title, url string
-	var color int
-	switch p.Action {
-	case api.HookReleasePublished:
-		title = fmt.Sprintf("[%s] Release created", p.Release.TagName)
-		url = p.Release.URL
-		color = greenColor
-	case api.HookReleaseUpdated:
-		title = fmt.Sprintf("[%s] Release updated", p.Release.TagName)
-		url = p.Release.URL
-		color = greenColor
-	case api.HookReleaseDeleted:
-		title = fmt.Sprintf("[%s] Release deleted", p.Release.TagName)
-		url = p.Release.URL
-		color = greenColor
-	}
+	text, color := getReleasePayloadInfo(p, noneLinkFormatter)
 
 	return &MSTeamsPayload{
 		Type:       "MessageCard",
 		Context:    "https://schema.org/extensions",
 		ThemeColor: fmt.Sprintf("%x", color),
-		Title:      title,
-		Summary:    title,
+		Title:      text,
+		Summary:    text,
 		Sections: []MSTeamsSection{
 			{
 				ActivityTitle:    p.Sender.FullName,
@@ -673,7 +536,7 @@ func getMSTeamsReleasePayload(p *api.ReleasePayload) (*MSTeamsPayload, error) {
 				Targets: []MSTeamsActionTarget{
 					{
 						Os:  "default",
-						URI: url,
+						URI: p.Release.URL,
 					},
 				},
 			},
diff --git a/modules/webhook/slack.go b/modules/webhook/slack.go
index 41872f940f..508cb13b8f 100644
--- a/modules/webhook/slack.go
+++ b/modules/webhook/slack.go
@@ -144,42 +144,7 @@ func getSlackForkPayload(p *api.ForkPayload, slack *SlackMeta) (*SlackPayload, e
 }
 
 func getSlackIssuesPayload(p *api.IssuePayload, slack *SlackMeta) (*SlackPayload, error) {
-	senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
-	title := SlackTextFormatter(fmt.Sprintf("#%d %s", p.Index, p.Issue.Title))
-	titleLink := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index)
-	repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
-	var text, attachmentText string
-
-	switch p.Action {
-	case api.HookIssueOpened:
-		text = fmt.Sprintf("[%s] Issue opened by %s", repoLink, senderLink)
-		attachmentText = SlackTextFormatter(p.Issue.Body)
-	case api.HookIssueClosed:
-		text = fmt.Sprintf("[%s] Issue closed: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueReOpened:
-		text = fmt.Sprintf("[%s] Issue re-opened: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueEdited:
-		text = fmt.Sprintf("[%s] Issue edited: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-		attachmentText = SlackTextFormatter(p.Issue.Body)
-	case api.HookIssueAssigned:
-		text = fmt.Sprintf("[%s] Issue assigned to %s: [%s](%s) by %s", repoLink,
-			SlackLinkFormatter(setting.AppURL+p.Issue.Assignee.UserName, p.Issue.Assignee.UserName),
-			title, titleLink, senderLink)
-	case api.HookIssueUnassigned:
-		text = fmt.Sprintf("[%s] Issue unassigned: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueLabelUpdated:
-		text = fmt.Sprintf("[%s] Issue labels updated: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueLabelCleared:
-		text = fmt.Sprintf("[%s] Issue labels cleared: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueSynchronized:
-		text = fmt.Sprintf("[%s] Issue synchronized: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueMilestoned:
-		mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.Issue.Milestone.ID)
-		text = fmt.Sprintf("[%s] Issue milestoned to [%s](%s): [%s](%s) by %s", repoLink,
-			p.Issue.Milestone.Title, mileStoneLink, title, titleLink, senderLink)
-	case api.HookIssueDemilestoned:
-		text = fmt.Sprintf("[%s] Issue milestone cleared: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	}
+	text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter)
 
 	pl := &SlackPayload{
 		Channel:  slack.Channel,
@@ -188,10 +153,12 @@ func getSlackIssuesPayload(p *api.IssuePayload, slack *SlackMeta) (*SlackPayload
 		IconURL:  slack.IconURL,
 	}
 	if attachmentText != "" {
+		attachmentText = SlackTextFormatter(attachmentText)
+		issueTitle = SlackTextFormatter(issueTitle)
 		pl.Attachments = []SlackAttachment{{
-			Color:     slack.Color,
-			Title:     title,
-			TitleLink: titleLink,
+			Color:     fmt.Sprintf("%x", color),
+			Title:     issueTitle,
+			TitleLink: p.Issue.URL,
 			Text:      attachmentText,
 		}}
 	}
@@ -200,25 +167,7 @@ func getSlackIssuesPayload(p *api.IssuePayload, slack *SlackMeta) (*SlackPayload
 }
 
 func getSlackIssueCommentPayload(p *api.IssueCommentPayload, slack *SlackMeta) (*SlackPayload, error) {
-	senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
-	title := SlackTextFormatter(fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title))
-	repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
-	var text, titleLink, attachmentText string
-
-	switch p.Action {
-	case api.HookIssueCommentCreated:
-		text = fmt.Sprintf("[%s] New comment created by %s", repoLink, senderLink)
-		titleLink = fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-		attachmentText = SlackTextFormatter(p.Comment.Body)
-	case api.HookIssueCommentEdited:
-		text = fmt.Sprintf("[%s] Comment edited by %s", repoLink, senderLink)
-		titleLink = fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-		attachmentText = SlackTextFormatter(p.Comment.Body)
-	case api.HookIssueCommentDeleted:
-		text = fmt.Sprintf("[%s] Comment deleted by %s", repoLink, senderLink)
-		titleLink = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index)
-		attachmentText = SlackTextFormatter(p.Comment.Body)
-	}
+	text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter)
 
 	return &SlackPayload{
 		Channel:  slack.Channel,
@@ -226,27 +175,16 @@ func getSlackIssueCommentPayload(p *api.IssueCommentPayload, slack *SlackMeta) (
 		Username: slack.Username,
 		IconURL:  slack.IconURL,
 		Attachments: []SlackAttachment{{
-			Color:     slack.Color,
-			Title:     title,
-			TitleLink: titleLink,
-			Text:      attachmentText,
+			Color:     fmt.Sprintf("%x", color),
+			Title:     issueTitle,
+			TitleLink: p.Comment.HTMLURL,
+			Text:      SlackTextFormatter(p.Comment.Body),
 		}},
 	}, nil
 }
 
 func getSlackReleasePayload(p *api.ReleasePayload, slack *SlackMeta) (*SlackPayload, error) {
-	repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
-	refLink := SlackLinkFormatter(p.Repository.HTMLURL+"/src/"+p.Release.TagName, p.Release.TagName)
-	var text string
-
-	switch p.Action {
-	case api.HookReleasePublished:
-		text = fmt.Sprintf("[%s] new release %s published by %s", repoLink, refLink, p.Sender.UserName)
-	case api.HookReleaseUpdated:
-		text = fmt.Sprintf("[%s] new release %s updated by %s", repoLink, refLink, p.Sender.UserName)
-	case api.HookReleaseDeleted:
-		text = fmt.Sprintf("[%s] new release %s deleted by %s", repoLink, refLink, p.Sender.UserName)
-	}
+	text, _ := getReleasePayloadInfo(p, SlackLinkFormatter)
 
 	return &SlackPayload{
 		Channel:  slack.Channel,
@@ -301,50 +239,7 @@ func getSlackPushPayload(p *api.PushPayload, slack *SlackMeta) (*SlackPayload, e
 }
 
 func getSlackPullRequestPayload(p *api.PullRequestPayload, slack *SlackMeta) (*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, attachmentText string
-
-	switch p.Action {
-	case api.HookIssueOpened:
-		text = fmt.Sprintf("[%s] Pull request opened by %s", repoLink, senderLink)
-		attachmentText = SlackTextFormatter(p.PullRequest.Body)
-	case api.HookIssueClosed:
-		if p.PullRequest.HasMerged {
-			text = fmt.Sprintf("[%s] Pull request merged: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-		} else {
-			text = fmt.Sprintf("[%s] Pull request closed: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-		}
-	case api.HookIssueReOpened:
-		text = fmt.Sprintf("[%s] Pull request re-opened: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueEdited:
-		text = fmt.Sprintf("[%s] Pull request edited: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-		attachmentText = SlackTextFormatter(p.PullRequest.Body)
-	case api.HookIssueAssigned:
-		list := make([]string, len(p.PullRequest.Assignees))
-		for i, user := range p.PullRequest.Assignees {
-			list[i] = SlackLinkFormatter(setting.AppURL+user.UserName, user.UserName)
-		}
-		text = fmt.Sprintf("[%s] Pull request assigned to %s: [%s](%s) by %s", repoLink,
-			strings.Join(list, ", "),
-			title, titleLink, senderLink)
-	case api.HookIssueUnassigned:
-		text = fmt.Sprintf("[%s] Pull request unassigned: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueLabelUpdated:
-		text = fmt.Sprintf("[%s] Pull request labels updated: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueLabelCleared:
-		text = fmt.Sprintf("[%s] Pull request labels cleared: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueSynchronized:
-		text = fmt.Sprintf("[%s] Pull request synchronized: [%s](%s) by %s", repoLink, title, titleLink, senderLink)
-	case api.HookIssueMilestoned:
-		mileStoneLink := fmt.Sprintf("%s/milestone/%d", p.Repository.HTMLURL, p.PullRequest.Milestone.ID)
-		text = fmt.Sprintf("[%s] Pull request milestoned to [%s](%s): [%s](%s) %s", repoLink,
-			p.PullRequest.Milestone.Title, mileStoneLink, title, titleLink, senderLink)
-	case api.HookIssueDemilestoned:
-		text = fmt.Sprintf("[%s] Pull request milestone cleared: [%s](%s) %s", repoLink, title, titleLink, senderLink)
-	}
+	text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter)
 
 	pl := &SlackPayload{
 		Channel:  slack.Channel,
@@ -353,10 +248,12 @@ func getSlackPullRequestPayload(p *api.PullRequestPayload, slack *SlackMeta) (*S
 		IconURL:  slack.IconURL,
 	}
 	if attachmentText != "" {
+		attachmentText = SlackTextFormatter(p.PullRequest.Body)
+		issueTitle = SlackTextFormatter(issueTitle)
 		pl.Attachments = []SlackAttachment{{
-			Color:     slack.Color,
-			Title:     title,
-			TitleLink: titleLink,
+			Color:     fmt.Sprintf("%x", color),
+			Title:     issueTitle,
+			TitleLink: p.PullRequest.URL,
 			Text:      attachmentText,
 		}}
 	}
diff --git a/modules/webhook/slack_test.go b/modules/webhook/slack_test.go
new file mode 100644
index 0000000000..fe4f1384fc
--- /dev/null
+++ b/modules/webhook/slack_test.go
@@ -0,0 +1,88 @@
+// Copyright 2019 The Gitea 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 webhook
+
+import (
+	"testing"
+
+	api "code.gitea.io/gitea/modules/structs"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSlackIssuesPayloadOpened(t *testing.T) {
+	p := issueTestPayload()
+	sl := &SlackMeta{
+		Username: p.Sender.UserName,
+	}
+
+	p.Action = api.HookIssueOpened
+	pl, err := getSlackIssuesPayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Issue opened: <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+
+	p.Action = api.HookIssueClosed
+	pl, err = getSlackIssuesPayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Issue closed: <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+}
+
+func TestSlackIssueCommentPayload(t *testing.T) {
+	p := issueCommentTestPayload()
+
+	sl := &SlackMeta{
+		Username: p.Sender.UserName,
+	}
+
+	pl, err := getSlackIssueCommentPayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] New comment on issue <http://localhost:3000/test/repo/issues/2|#2 crash> by <https://try.gitea.io/user1|user1>", pl.Text)
+}
+
+func TestSlackPullRequestCommentPayload(t *testing.T) {
+	p := pullRequestCommentTestPayload()
+
+	sl := &SlackMeta{
+		Username: p.Sender.UserName,
+	}
+
+	pl, err := getSlackIssueCommentPayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] New comment on pull request <http://localhost:3000/test/repo/pulls/2|#2 Fix bug> by <https://try.gitea.io/user1|user1>", pl.Text)
+}
+
+func TestSlackReleasePayload(t *testing.T) {
+	p := pullReleaseTestPayload()
+
+	sl := &SlackMeta{
+		Username: p.Sender.UserName,
+	}
+
+	pl, err := getSlackReleasePayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Release <http://localhost:3000/test/repo/src/v1.0|v1.0> created by <https://try.gitea.io/user1|user1>", pl.Text)
+}
+
+func TestSlackPullRequestPayload(t *testing.T) {
+	p := pullRequestTestPayload()
+
+	sl := &SlackMeta{
+		Username: p.Sender.UserName,
+	}
+
+	pl, err := getSlackPullRequestPayload(p, sl)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+
+	assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Pull request <http://localhost:3000/test/repo/pulls/12|#2 Fix bug> opened by <https://try.gitea.io/user1|user1>", pl.Text)
+}
diff --git a/modules/webhook/telegram.go b/modules/webhook/telegram.go
index d95ee0f73f..a98d47d55c 100644
--- a/modules/webhook/telegram.go
+++ b/modules/webhook/telegram.go
@@ -7,7 +7,6 @@ package webhook
 import (
 	"encoding/json"
 	"fmt"
-	"html"
 	"strings"
 
 	"code.gitea.io/gitea/models"
@@ -126,122 +125,26 @@ func getTelegramPushPayload(p *api.PushPayload) (*TelegramPayload, error) {
 }
 
 func getTelegramIssuesPayload(p *api.IssuePayload) (*TelegramPayload, error) {
-	var text, title string
-	switch p.Action {
-	case api.HookIssueOpened:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue opened: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-	case api.HookIssueClosed:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue closed: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue re-opened: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueEdited:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue edited: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-		text = p.Issue.Body
-	case api.HookIssueAssigned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue assigned to %s: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.Assignee.UserName, p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue unassigned: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue labels updated: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue labels cleared: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue synchronized: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue milestone: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Issue clear milestone: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.Issue.URL, p.Index, p.Issue.Title)
-	}
+	text, _, attachmentText, _ := getIssuesPayloadInfo(p, htmlLinkFormatter)
 
 	return &TelegramPayload{
-		Message: title + "\n\n" + text,
+		Message: text + "\n\n" + attachmentText,
 	}, nil
 }
 
 func getTelegramIssueCommentPayload(p *api.IssueCommentPayload) (*TelegramPayload, error) {
-	url := fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, models.CommentHashTag(p.Comment.ID))
-	title := fmt.Sprintf(`<a href="%s">#%d %s</a>`, url, p.Issue.Index, html.EscapeString(p.Issue.Title))
-	var text string
-	switch p.Action {
-	case api.HookIssueCommentCreated:
-		text = "New comment: " + title
-		text += p.Comment.Body
-	case api.HookIssueCommentEdited:
-		text = "Comment edited: " + title
-		text += p.Comment.Body
-	case api.HookIssueCommentDeleted:
-		text = "Comment deleted: " + title
-		text += p.Comment.Body
-	}
+	text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter)
 
 	return &TelegramPayload{
-		Message: title + "\n" + text,
+		Message: text + "\n" + p.Comment.Body,
 	}, nil
 }
 
 func getTelegramPullRequestPayload(p *api.PullRequestPayload) (*TelegramPayload, error) {
-	var text, title string
-	switch p.Action {
-	case api.HookIssueOpened:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request opened: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-		text = p.PullRequest.Body
-	case api.HookIssueClosed:
-		if p.PullRequest.HasMerged {
-			title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request merged: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-				p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-		} else {
-			title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request closed: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-				p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-		}
-	case api.HookIssueReOpened:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request re-opened: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueEdited:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request edited: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-		text = p.PullRequest.Body
-	case api.HookIssueAssigned:
-		list, err := models.MakeAssigneeList(&models.Issue{ID: p.PullRequest.ID})
-		if err != nil {
-			return &TelegramPayload{}, err
-		}
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request assigned to %s: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			list, p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueUnassigned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request unassigned: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueLabelUpdated:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request labels updated: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueLabelCleared:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request labels cleared: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueSynchronized:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request synchronized: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueMilestoned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request milestone: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	case api.HookIssueDemilestoned:
-		title = fmt.Sprintf(`[<a href="%s">%s</a>] Pull request clear milestone: <a href="%s">#%d %s</a>`, p.Repository.HTMLURL, p.Repository.FullName,
-			p.PullRequest.HTMLURL, p.Index, p.PullRequest.Title)
-	}
+	text, _, attachmentText, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter)
 
 	return &TelegramPayload{
-		Message: title + "\n" + text,
+		Message: text + "\n" + attachmentText,
 	}, nil
 }
 
@@ -263,30 +166,11 @@ func getTelegramRepositoryPayload(p *api.RepositoryPayload) (*TelegramPayload, e
 }
 
 func getTelegramReleasePayload(p *api.ReleasePayload) (*TelegramPayload, error) {
-	var title, url string
-	switch p.Action {
-	case api.HookReleasePublished:
-		title = fmt.Sprintf("[%s] Release created", p.Release.TagName)
-		url = p.Release.URL
-		return &TelegramPayload{
-			Message: title + "\n" + url,
-		}, nil
-	case api.HookReleaseUpdated:
-		title = fmt.Sprintf("[%s] Release updated", p.Release.TagName)
-		url = p.Release.URL
-		return &TelegramPayload{
-			Message: title + "\n" + url,
-		}, nil
+	text, _ := getReleasePayloadInfo(p, htmlLinkFormatter)
 
-	case api.HookReleaseDeleted:
-		title = fmt.Sprintf("[%s] Release deleted", p.Release.TagName)
-		url = p.Release.URL
-		return &TelegramPayload{
-			Message: title + "\n" + url,
-		}, nil
-	}
-
-	return nil, nil
+	return &TelegramPayload{
+		Message: text + "\n",
+	}, nil
 }
 
 // GetTelegramPayload converts a telegram webhook into a TelegramPayload
diff --git a/modules/webhook/telegram_test.go b/modules/webhook/telegram_test.go
new file mode 100644
index 0000000000..221dc9843c
--- /dev/null
+++ b/modules/webhook/telegram_test.go
@@ -0,0 +1,24 @@
+// Copyright 2019 The Gitea 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 webhook
+
+import (
+	"testing"
+
+	api "code.gitea.io/gitea/modules/structs"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGetTelegramIssuesPayload(t *testing.T) {
+	p := issueTestPayload()
+	p.Action = api.HookIssueClosed
+
+	pl, err := getTelegramIssuesPayload(p)
+	require.Nil(t, err)
+	require.NotNil(t, pl)
+
+	assert.Equal(t, "[<a href=\"http://localhost:3000/test/repo\">test/repo</a>] Issue closed: <a href=\"http://localhost:3000/test/repo/issues/2\">#2 crash</a> by <a href=\"https://try.gitea.io/user1\">user1</a>\n\n", pl.Message)
+}