From b58d8e20fd63db7176631e0c0a895b7f76045ead Mon Sep 17 00:00:00 2001 From: nightfurysl2001 Date: Tue, 26 May 2026 16:10:55 +0200 Subject: [PATCH] feat: Update Microsoft Teams webhook to use AdaptiveCard (#11704) ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [ ] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. Closes #4517 ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/11704): Update Microsoft Teams webhook to use AdaptiveCard Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11704 Reviewed-by: oliverpool Reviewed-by: Mathieu Fenniak --- services/webhook/dingtalk.go | 21 +- services/webhook/dingtalk_test.go | 12 +- services/webhook/discord.go | 20 +- services/webhook/discord_test.go | 6 +- services/webhook/feishu.go | 15 +- services/webhook/feishu_test.go | 6 +- services/webhook/general.go | 182 +++--- services/webhook/general_test.go | 100 ++- services/webhook/matrix.go | 19 +- services/webhook/matrix_test.go | 12 +- services/webhook/msteams.go | 1012 ++++++++++++++++++++++++----- services/webhook/msteams_test.go | 530 +++++++-------- services/webhook/slack.go | 21 +- services/webhook/slack_test.go | 6 +- services/webhook/telegram.go | 21 +- services/webhook/telegram_test.go | 6 +- services/webhook/wechatwork.go | 21 +- 17 files changed, 1408 insertions(+), 602 deletions(-) diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 96d4c18c11..077906e192 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -62,6 +62,13 @@ type ( } ) +var dingtalkPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + // Create implements PayloadConvertor Create method func (dc dingtalkConvertor) Create(p *api.CreatePayload) (DingtalkPayload, error) { // created tag/branch @@ -130,14 +137,14 @@ func (dc dingtalkConvertor) Push(p *api.PushPayload) (DingtalkPayload, error) { // Issue implements PayloadConvertor Issue method func (dc dingtalkConvertor) Issue(p *api.IssuePayload) (DingtalkPayload, error) { - text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, _ := dingtalkPayloadFormatter.getIssuesPayloadInfo(p) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view issue", p.Issue.HTMLURL), nil } // Wiki implements PayloadConvertor Wiki method func (dc dingtalkConvertor) Wiki(p *api.WikiPayload) (DingtalkPayload, error) { - text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _, _ := dingtalkPayloadFormatter.getWikiPayloadInfo(p, true) url := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page) return createDingtalkPayload(text, text, "view wiki", url), nil @@ -145,14 +152,14 @@ func (dc dingtalkConvertor) Wiki(p *api.WikiPayload) (DingtalkPayload, error) { // IssueComment implements PayloadConvertor IssueComment method func (dc dingtalkConvertor) IssueComment(p *api.IssueCommentPayload) (DingtalkPayload, error) { - text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, _ := dingtalkPayloadFormatter.getIssueCommentPayloadInfo(p) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+p.Comment.Body, "view issue comment", p.Comment.HTMLURL), nil } // PullRequest implements PayloadConvertor PullRequest method func (dc dingtalkConvertor) PullRequest(p *api.PullRequestPayload) (DingtalkPayload, error) { - text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, _ := dingtalkPayloadFormatter.getPullRequestPayloadInfo(p) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view pull request", p.PullRequest.HTMLURL), nil } @@ -196,19 +203,19 @@ func (dc dingtalkConvertor) Repository(p *api.RepositoryPayload) (DingtalkPayloa // Release implements PayloadConvertor Release method func (dc dingtalkConvertor) Release(p *api.ReleasePayload) (DingtalkPayload, error) { - text, _ := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := dingtalkPayloadFormatter.getReleasePayloadInfo(p) return createDingtalkPayload(text, text, "view release", p.Release.HTMLURL), nil } func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, error) { - text, _ := getPackagePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := dingtalkPayloadFormatter.getPackagePayloadInfo(p) return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil } func (dc dingtalkConvertor) Action(p *api.ActionPayload) (DingtalkPayload, error) { - text, _ := getActionPayloadInfo(p, noneLinkFormatter) + text, _ := dingtalkPayloadFormatter.getActionPayloadInfo(p) return createDingtalkPayload(text, text, "view action", p.Run.HTMLURL), nil } diff --git a/services/webhook/dingtalk_test.go b/services/webhook/dingtalk_test.go index 5d2a240660..3eb2d42c8c 100644 --- a/services/webhook/dingtalk_test.go +++ b/services/webhook/dingtalk_test.go @@ -178,8 +178,8 @@ func TestDingTalkPayload(t *testing.T) { pl, err := dc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Text) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Title) + assert.Equal(t, "[test/repo] New wiki page \"index\" (Wiki change comment) by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] New wiki page \"index\" (Wiki change comment) by user1", pl.ActionCard.Title) assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) @@ -187,8 +187,8 @@ func TestDingTalkPayload(t *testing.T) { pl, err = dc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Text) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Title) + assert.Equal(t, "[test/repo] Wiki page \"index\" edited (Wiki change comment) by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Wiki page \"index\" edited (Wiki change comment) by user1", pl.ActionCard.Title) assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) @@ -196,8 +196,8 @@ func TestDingTalkPayload(t *testing.T) { pl, err = dc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Text) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Title) + assert.Equal(t, "[test/repo] Wiki page \"index\" deleted by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Wiki page \"index\" deleted by user1", pl.ActionCard.Title) assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) }) diff --git a/services/webhook/discord.go b/services/webhook/discord.go index cb7584e157..279756a81e 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -45,6 +45,12 @@ type discordForm struct { var _ binding.Validator = &discordForm{} +var discordPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withRepoName: true, +} + // Validate implements binding.Validator. func (d *discordForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { ctx := app_context.GetWebContext(req) @@ -235,21 +241,21 @@ func (d discordConvertor) Push(p *api.PushPayload) (DiscordPayload, error) { // Issue implements PayloadConvertor Issue method func (d discordConvertor) Issue(p *api.IssuePayload) (DiscordPayload, error) { - title, _, text, color := getIssuesPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + title, _, text, color := discordPayloadFormatter.getIssuesPayloadInfo(p) return d.createPayload(p.Sender, title, text, p.Issue.HTMLURL, color), nil } // IssueComment implements PayloadConvertor IssueComment method func (d discordConvertor) IssueComment(p *api.IssueCommentPayload) (DiscordPayload, error) { - title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + title, _, color := discordPayloadFormatter.getIssueCommentPayloadInfo(p) return d.createPayload(p.Sender, title, p.Comment.Body, p.Comment.HTMLURL, color), nil } // PullRequest implements PayloadConvertor PullRequest method func (d discordConvertor) PullRequest(p *api.PullRequestPayload) (DiscordPayload, error) { - title, _, text, color := getPullRequestPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + title, _, text, color := discordPayloadFormatter.getPullRequestPayloadInfo(p) return d.createPayload(p.Sender, title, text, p.PullRequest.HTMLURL, color), nil } @@ -301,7 +307,7 @@ func (d discordConvertor) Repository(p *api.RepositoryPayload) (DiscordPayload, // Wiki implements PayloadConvertor Wiki method func (d discordConvertor) Wiki(p *api.WikiPayload) (DiscordPayload, error) { - text, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + text, color, _ := discordPayloadFormatter.getWikiPayloadInfo(p, true) htmlLink := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page) var description string @@ -314,19 +320,19 @@ func (d discordConvertor) Wiki(p *api.WikiPayload) (DiscordPayload, error) { // Release implements PayloadConvertor Release method func (d discordConvertor) Release(p *api.ReleasePayload) (DiscordPayload, error) { - text, color := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + text, color := discordPayloadFormatter.getReleasePayloadInfo(p) return d.createPayload(p.Sender, text, p.Release.Note, p.Release.HTMLURL, color), nil } func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error) { - text, color := getPackagePayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + text, color := discordPayloadFormatter.getPackagePayloadInfo(p) return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil } func (d discordConvertor) Action(p *api.ActionPayload) (DiscordPayload, error) { - text, color := getActionPayloadInfo(p, noneLinkFormatter) + text, color := discordPayloadFormatter.getActionPayloadInfo(p) return d.createPayload(p.Run.TriggerUser, text, "", p.Run.HTMLURL, color), nil } diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go index 23745b421a..6a5d1da334 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -279,7 +279,7 @@ func TestDiscordPayload(t *testing.T) { require.NoError(t, err) assert.Len(t, pl.Embeds, 1) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Embeds[0].Title) + assert.Equal(t, "[test/repo] New wiki page \"index\" (Wiki change comment)", pl.Embeds[0].Title) assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) @@ -291,7 +291,7 @@ func TestDiscordPayload(t *testing.T) { require.NoError(t, err) assert.Len(t, pl.Embeds, 1) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Embeds[0].Title) + assert.Equal(t, "[test/repo] Wiki page \"index\" edited (Wiki change comment)", pl.Embeds[0].Title) assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) @@ -303,7 +303,7 @@ func TestDiscordPayload(t *testing.T) { require.NoError(t, err) assert.Len(t, pl.Embeds, 1) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Embeds[0].Title) + assert.Equal(t, "[test/repo] Wiki page \"index\" deleted", pl.Embeds[0].Title) assert.Empty(t, pl.Embeds[0].Description) assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index ffbd4eb469..a2e4632361 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -63,6 +63,13 @@ func newFeishuTextPayload(text string) FeishuPayload { } } +var feishuPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + // Create implements PayloadConvertor Create method func (fc feishuConvertor) Create(p *api.CreatePayload) (FeishuPayload, error) { // created tag/branch @@ -174,26 +181,26 @@ func (fc feishuConvertor) Repository(p *api.RepositoryPayload) (FeishuPayload, e // Wiki implements PayloadConvertor Wiki method func (fc feishuConvertor) Wiki(p *api.WikiPayload) (FeishuPayload, error) { - text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _, _ := feishuPayloadFormatter.getWikiPayloadInfo(p, true) return newFeishuTextPayload(text), nil } // Release implements PayloadConvertor Release method func (fc feishuConvertor) Release(p *api.ReleasePayload) (FeishuPayload, error) { - text, _ := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := feishuPayloadFormatter.getReleasePayloadInfo(p) return newFeishuTextPayload(text), nil } func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error) { - text, _ := getPackagePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := feishuPayloadFormatter.getPackagePayloadInfo(p) return newFeishuTextPayload(text), nil } func (fc feishuConvertor) Action(p *api.ActionPayload) (FeishuPayload, error) { - text, _ := getActionPayloadInfo(p, noneLinkFormatter) + text, _ := feishuPayloadFormatter.getActionPayloadInfo(p) return newFeishuTextPayload(text), nil } diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go index 7cf24b84ed..5258edea7f 100644 --- a/services/webhook/feishu_test.go +++ b/services/webhook/feishu_test.go @@ -131,19 +131,19 @@ func TestFeishuPayload(t *testing.T) { pl, err := fc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.Content.Text) + assert.Equal(t, "[test/repo] New wiki page \"index\" (Wiki change comment) by user1", pl.Content.Text) p.Action = api.HookWikiEdited pl, err = fc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.Content.Text) + assert.Equal(t, "[test/repo] Wiki page \"index\" edited (Wiki change comment) by user1", pl.Content.Text) p.Action = api.HookWikiDeleted pl, err = fc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.Content.Text) + assert.Equal(t, "[test/repo] Wiki page \"index\" deleted by user1", pl.Content.Text) }) t.Run("Release", func(t *testing.T) { diff --git a/services/webhook/general.go b/services/webhook/general.go index 0f1b3da70a..19307cd451 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -17,8 +17,8 @@ import ( ) type ( - linkFormatter = func(string, string) string - nameFormatter = func(string) string + linkFormatter = func(url, text string) string + nameFormatter = func(name string) string ) // noneLinkFormatter does not create a link but just returns the text @@ -101,48 +101,56 @@ func getIssuesCommentInfo(p *api.IssueCommentPayload, nameFormatter nameFormatte return title, link, by, operator } -func getIssuesPayloadInfo(p *api.IssuePayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (string, string, string, int) { - 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 +type webhookPayloadFormatter struct { + linkFormatter linkFormatter + nameFormatter nameFormatter + withSender, withRepoName bool +} + +func (wpf webhookPayloadFormatter) getIssuesPayloadInfo(p *api.IssuePayload) (text, issueTitle, attachmentText string, color int) { + issueTitle = fmt.Sprintf("#%d %s", p.Index, p.Issue.Title) + titleLink := wpf.linkFormatter(fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index), issueTitle) + color = yellowColor switch p.Action { case api.HookIssueOpened: - text = fmt.Sprintf("[%s] Issue opened: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue opened: %s", titleLink) color = orangeColor case api.HookIssueClosed: - text = fmt.Sprintf("[%s] Issue closed: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue closed: %s", titleLink) color = redColor case api.HookIssueReOpened: - text = fmt.Sprintf("[%s] Issue re-opened: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue re-opened: %s", titleLink) case api.HookIssueEdited: - text = fmt.Sprintf("[%s] Issue edited: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue edited: %s", titleLink) case api.HookIssueAssigned: list := make([]string, len(p.Issue.Assignees)) for i, user := range p.Issue.Assignees { - list[i] = linkFormatter(setting.AppURL+url.PathEscape(user.UserName), user.UserName) + list[i] = wpf.linkFormatter(setting.AppURL+url.PathEscape(user.UserName), user.UserName) } - text = fmt.Sprintf("[%s] Issue assigned to %s: %s", p.Repository.FullName, strings.Join(list, ", "), titleLink) + text = fmt.Sprintf("Issue assigned to %s: %s", strings.Join(list, ", "), titleLink) color = greenColor case api.HookIssueUnassigned: - text = fmt.Sprintf("[%s] Issue unassigned: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue unassigned: %s", titleLink) case api.HookIssueLabelUpdated: - text = fmt.Sprintf("[%s] Issue labels updated: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue labels updated: %s", titleLink) case api.HookIssueLabelCleared: - text = fmt.Sprintf("[%s] Issue labels cleared: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue labels cleared: %s", titleLink) case api.HookIssueSynchronized: - text = fmt.Sprintf("[%s] Issue synchronized: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Issue synchronized: %s", titleLink) case api.HookIssueMilestoned: - text = fmt.Sprintf("[%s] Issue milestoned to %s: %s", p.Repository.FullName, p.Issue.Milestone.Title, titleLink) + text = fmt.Sprintf("Issue milestoned to %s: %s", p.Issue.Milestone.Title, titleLink) case api.HookIssueDemilestoned: - text = fmt.Sprintf("[%s] Issue milestone cleared: %s", p.Repository.FullName, titleLink) - } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + text = fmt.Sprintf("Issue milestone cleared: %s", titleLink) + } + + if wpf.withRepoName { + text = fmt.Sprintf("[%s] %s", p.Repository.FullName, text) + } + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } - var attachmentText string if p.Action == api.HookIssueOpened || p.Action == api.HookIssueEdited { attachmentText = p.Issue.Body } @@ -150,152 +158,160 @@ func getIssuesPayloadInfo(p *api.IssuePayload, linkFormatter linkFormatter, name return text, issueTitle, attachmentText, color } -func getPullRequestPayloadInfo(p *api.PullRequestPayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (string, string, string, int) { - issueTitle := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title) - titleLink := linkFormatter(p.PullRequest.URL, issueTitle) - var text string - var attachmentText string - color := yellowColor +func (wpf webhookPayloadFormatter) getPullRequestPayloadInfo(p *api.PullRequestPayload) (text, issueTitle, attachmentText string, color int) { + issueTitle = fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title) + titleLink := wpf.linkFormatter(p.PullRequest.URL, issueTitle) + color = yellowColor switch p.Action { case api.HookIssueOpened: - text = fmt.Sprintf("[%s] Pull request opened: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request opened: %s", titleLink) attachmentText = p.PullRequest.Body color = greenColor case api.HookIssueClosed: if p.PullRequest.HasMerged { - text = fmt.Sprintf("[%s] Pull request merged: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request merged: %s", titleLink) color = purpleColor } else { - text = fmt.Sprintf("[%s] Pull request closed: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request closed: %s", titleLink) color = redColor } case api.HookIssueReOpened: - text = fmt.Sprintf("[%s] Pull request re-opened: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request re-opened: %s", titleLink) case api.HookIssueEdited: - text = fmt.Sprintf("[%s] Pull request edited: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request edited: %s", titleLink) attachmentText = p.PullRequest.Body 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) + list[i] = wpf.linkFormatter(setting.AppURL+user.UserName, user.UserName) } - text = fmt.Sprintf("[%s] Pull request assigned to %s: %s", p.Repository.FullName, - strings.Join(list, ", "), titleLink) + text = fmt.Sprintf("Pull request assigned to %s: %s", strings.Join(list, ", "), titleLink) color = greenColor case api.HookIssueUnassigned: - text = fmt.Sprintf("[%s] Pull request unassigned: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request unassigned: %s", titleLink) case api.HookIssueLabelUpdated: - text = fmt.Sprintf("[%s] Pull request labels updated: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request labels updated: %s", titleLink) case api.HookIssueLabelCleared: - text = fmt.Sprintf("[%s] Pull request labels cleared: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request labels cleared: %s", titleLink) case api.HookIssueSynchronized: - text = fmt.Sprintf("[%s] Pull request synchronized: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request synchronized: %s", titleLink) case api.HookIssueMilestoned: - text = fmt.Sprintf("[%s] Pull request milestoned to %s: %s", p.Repository.FullName, p.PullRequest.Milestone.Title, titleLink) + text = fmt.Sprintf("Pull request milestoned to %s: %s", p.PullRequest.Milestone.Title, titleLink) case api.HookIssueDemilestoned: - text = fmt.Sprintf("[%s] Pull request milestone cleared: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request milestone cleared: %s", titleLink) case api.HookIssueReviewed: - text = fmt.Sprintf("[%s] Pull request reviewed: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request reviewed: %s", titleLink) attachmentText = p.Review.Content case api.HookIssueReviewRequested: - text = fmt.Sprintf("[%s] Pull request review requested: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request review requested: %s", titleLink) case api.HookIssueReviewRequestRemoved: - text = fmt.Sprintf("[%s] Pull request review request removed: %s", p.Repository.FullName, titleLink) + text = fmt.Sprintf("Pull request review request removed: %s", titleLink) } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + if wpf.withRepoName { + text = fmt.Sprintf("[%s] %s", p.Repository.FullName, text) + } + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } return text, issueTitle, attachmentText, color } -func getReleasePayloadInfo(p *api.ReleasePayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (text string, color int) { - refLink := linkFormatter(p.Repository.HTMLURL+"/releases/tag/"+util.PathEscapeSegments(p.Release.TagName), p.Release.TagName) +func (wpf webhookPayloadFormatter) getReleasePayloadInfo(p *api.ReleasePayload) (text string, color int) { + refLink := wpf.linkFormatter(p.Repository.HTMLURL+"/releases/tag/"+util.PathEscapeSegments(p.Release.TagName), p.Release.TagName) switch p.Action { case api.HookReleasePublished: - text = fmt.Sprintf("[%s] Release created: %s", p.Repository.FullName, refLink) + text = fmt.Sprintf("Release created: %s", refLink) color = greenColor case api.HookReleaseUpdated: - text = fmt.Sprintf("[%s] Release updated: %s", p.Repository.FullName, refLink) + text = fmt.Sprintf("Release updated: %s", refLink) color = yellowColor case api.HookReleaseDeleted: - text = fmt.Sprintf("[%s] Release deleted: %s", p.Repository.FullName, refLink) + text = fmt.Sprintf("Release deleted: %s", refLink) color = redColor } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + if wpf.withRepoName { + text = fmt.Sprintf("[%s] %s", p.Repository.FullName, text) + } + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } return text, color } -func getWikiPayloadInfo(p *api.WikiPayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (string, int, string) { - pageLink := linkFormatter(p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page), p.Page) +func (wpf webhookPayloadFormatter) getWikiPayloadInfo(p *api.WikiPayload, withCommitMessage bool) (text string, color int, pageLink string) { + pageLink = wpf.linkFormatter(p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page), p.Page) - var text string - color := greenColor + color = greenColor switch p.Action { case api.HookWikiCreated: - text = fmt.Sprintf("[%s] New wiki page '%s'", p.Repository.FullName, pageLink) + text = fmt.Sprintf("New wiki page \"%s\"", pageLink) case api.HookWikiEdited: - text = fmt.Sprintf("[%s] Wiki page '%s' edited", p.Repository.FullName, pageLink) + text = fmt.Sprintf("Wiki page \"%s\" edited", pageLink) color = yellowColor case api.HookWikiDeleted: - text = fmt.Sprintf("[%s] Wiki page '%s' deleted", p.Repository.FullName, pageLink) + text = fmt.Sprintf("Wiki page \"%s\" deleted", pageLink) color = redColor } - if p.Action != api.HookWikiDeleted && p.Comment != "" { + if p.Action != api.HookWikiDeleted && p.Comment != "" && withCommitMessage { text += fmt.Sprintf(" (%s)", p.Comment) } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + if wpf.withRepoName { + text = fmt.Sprintf("[%s] %s", p.Repository.FullName, text) + } + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } return text, color, pageLink } -func getIssueCommentPayloadInfo(p *api.IssueCommentPayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (string, string, int) { - issueTitle := fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title) +func (wpf webhookPayloadFormatter) getIssueCommentPayloadInfo(p *api.IssueCommentPayload) (text, issueTitle string, color int) { + issueTitle = fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title) - var text, typ, titleLink string - color := yellowColor + var typ, titleLink string + color = yellowColor if p.IsPull { typ = "pull request" - titleLink = linkFormatter(p.Comment.PRURL, issueTitle) + titleLink = wpf.linkFormatter(p.Comment.PRURL, issueTitle) } else { typ = "issue" - titleLink = linkFormatter(p.Comment.IssueURL, issueTitle) + titleLink = wpf.linkFormatter(p.Comment.IssueURL, issueTitle) } switch p.Action { case api.HookIssueCommentCreated: - text = fmt.Sprintf("[%s] New comment on %s %s", p.Repository.FullName, typ, titleLink) + text = fmt.Sprintf("New comment on %s %s", typ, titleLink) if p.IsPull { color = greenColorLight } else { color = orangeColorLight } case api.HookIssueCommentEdited: - text = fmt.Sprintf("[%s] Comment edited on %s %s", p.Repository.FullName, typ, titleLink) + text = fmt.Sprintf("Comment edited on %s %s", typ, titleLink) case api.HookIssueCommentDeleted: - text = fmt.Sprintf("[%s] Comment deleted on %s %s", p.Repository.FullName, typ, titleLink) + text = fmt.Sprintf("Comment deleted on %s %s", typ, titleLink) color = redColor } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + if wpf.withRepoName { + text = fmt.Sprintf("[%s] %s", p.Repository.FullName, text) + } + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } return text, issueTitle, color } -func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, nameFormatter nameFormatter, withSender bool) (text string, color int) { - refLink := linkFormatter(p.Package.HTMLURL, p.Package.Name+":"+p.Package.Version) +func (wpf webhookPayloadFormatter) getPackagePayloadInfo(p *api.PackagePayload) (text string, color int) { + refLink := wpf.linkFormatter(p.Package.HTMLURL, p.Package.Name+":"+p.Package.Version) switch p.Action { case api.HookPackageCreated: @@ -305,16 +321,16 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, n text = fmt.Sprintf("Package deleted: %s", refLink) color = redColor } - if withSender { - text += fmt.Sprintf(" by %s", nameFormatter(p.Sender.UserName)) + if wpf.withSender { + text += fmt.Sprintf(" by %s", wpf.nameFormatter(p.Sender.UserName)) } return text, color } -func getActionPayloadInfo(p *api.ActionPayload, linkFormatter linkFormatter) (text string, color int) { - runLink := linkFormatter(p.Run.HTMLURL, p.Run.Title) - repoLink := linkFormatter(p.Run.Repo.HTMLURL, p.Run.Repo.FullName) +func (wpf webhookPayloadFormatter) getActionPayloadInfo(p *api.ActionPayload) (text string, color int) { + runLink := wpf.linkFormatter(p.Run.HTMLURL, p.Run.Title) + repoLink := wpf.linkFormatter(p.Run.Repo.HTMLURL, p.Run.Repo.FullName) switch p.Action { case api.HookActionFailure: diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index 478fc003a1..d5b5bb3d62 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -4,6 +4,7 @@ package webhook import ( + "fmt" "strings" "testing" @@ -12,6 +13,13 @@ import ( "github.com/stretchr/testify/assert" ) +var generalTestPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + func createTestPayload() *api.CreatePayload { return &api.CreatePayload{ Sha: "2020558fe2e34debb818a514715839cabd25e777", @@ -476,7 +484,7 @@ func TestGetIssuesPayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, color := generalTestPayloadFormatter.getIssuesPayloadInfo(p) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.issueTitle, issueTitle, "case %d", i) assert.Equal(t, c.attachmentText, attachmentText, "case %d", i) @@ -575,7 +583,7 @@ func TestGetPullRequestPayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, color := generalTestPayloadFormatter.getPullRequestPayloadInfo(p) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.issueTitle, issueTitle, "case %d", i) assert.Equal(t, c.attachmentText, attachmentText, "case %d", i) @@ -594,19 +602,19 @@ func TestGetWikiPayloadInfo(t *testing.T) { }{ { api.HookWikiCreated, - "[test/repo] New wiki page 'index' (Wiki change comment) by user1", + "[test/repo] New wiki page \"index\" (Wiki change comment) by user1", greenColor, "index", }, { api.HookWikiEdited, - "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", + "[test/repo] Wiki page \"index\" edited (Wiki change comment) by user1", yellowColor, "index", }, { api.HookWikiDeleted, - "[test/repo] Wiki page 'index' deleted by user1", + "[test/repo] Wiki page \"index\" deleted by user1", redColor, "index", }, @@ -614,7 +622,7 @@ func TestGetWikiPayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, color, link := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, color, link := generalTestPayloadFormatter.getWikiPayloadInfo(p, true) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.color, color, "case %d", i) assert.Equal(t, c.link, link, "case %d", i) @@ -648,7 +656,7 @@ func TestGetReleasePayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, color := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, color := generalTestPayloadFormatter.getReleasePayloadInfo(p) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.color, color, "case %d", i) } @@ -685,7 +693,7 @@ func TestGetIssueCommentPayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, issueTitle, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, color := generalTestPayloadFormatter.getIssueCommentPayloadInfo(p) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.issueTitle, issueTitle, "case %d", i) assert.Equal(t, c.color, color, "case %d", i) @@ -719,8 +727,82 @@ func TestGetActionPayloadInfo(t *testing.T) { for i, c := range cases { p.Action = c.action - text, color := getActionPayloadInfo(p, noneLinkFormatter) + text, color := generalTestPayloadFormatter.getActionPayloadInfo(p) assert.Equal(t, c.text, text, "case %d", i) assert.Equal(t, c.color, color, "case %d", i) } } + +func TestWebhookPayloadTextFormatter(t *testing.T) { + p := issueTestPayload() + p.Action = api.HookIssueOpened + + formatterCases := []struct { + formatter webhookPayloadFormatter + expectedText string + }{ + { + formatter: webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: false, + withRepoName: false, + }, + expectedText: "Issue opened: #2 crash", + }, + { + formatter: webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: false, + }, + expectedText: "Issue opened: #2 crash by user1", + }, + { + formatter: webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: false, + withRepoName: true, + }, + expectedText: "[test/repo] Issue opened: #2 crash", + }, + { + formatter: webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, + }, + expectedText: "[test/repo] Issue opened: #2 crash by user1", + }, + { + formatter: webhookPayloadFormatter{ + linkFormatter: func(url, text string) string { + return fmt.Sprintf("%s @ %s", text, url) + }, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, + }, + expectedText: "[test/repo] Issue opened: #2 crash @ http://localhost:3000/test/repo/issues/2 by user1", + }, + { + formatter: webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: func(name string) string { + return fmt.Sprintf("!!! @%s !!!", name) + }, + withSender: true, + withRepoName: false, + }, + expectedText: "Issue opened: #2 crash by !!! @user1 !!!", + }, + } + + for i, c := range formatterCases { + text, _, _, _ := c.formatter.getIssuesPayloadInfo(p) + assert.Equal(t, c.expectedText, text, "case %d", i) + } +} diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index a4bd774f50..a32a2c4825 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -127,6 +127,13 @@ type MatrixPayload struct { var _ shared.PayloadConvertor[MatrixPayload] = matrixConvertor{} +var matrixPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: htmlLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + type matrixConvertor struct { MsgType string } @@ -169,28 +176,28 @@ func (m matrixConvertor) Fork(p *api.ForkPayload) (MatrixPayload, error) { // Issue implements payloadConvertor Issue method func (m matrixConvertor) Issue(p *api.IssuePayload) (MatrixPayload, error) { - text, _, _, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _, _ := matrixPayloadFormatter.getIssuesPayloadInfo(p) return m.newPayload(text) } // IssueComment implements payloadConvertor IssueComment method func (m matrixConvertor) IssueComment(p *api.IssueCommentPayload) (MatrixPayload, error) { - text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _ := matrixPayloadFormatter.getIssueCommentPayloadInfo(p) return m.newPayload(text) } // Wiki implements payloadConvertor Wiki method func (m matrixConvertor) Wiki(p *api.WikiPayload) (MatrixPayload, error) { - text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _ := matrixPayloadFormatter.getWikiPayloadInfo(p, true) return m.newPayload(text) } // Release implements payloadConvertor Release method func (m matrixConvertor) Release(p *api.ReleasePayload) (MatrixPayload, error) { - text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _ := matrixPayloadFormatter.getReleasePayloadInfo(p) return m.newPayload(text) } @@ -223,7 +230,7 @@ func (m matrixConvertor) Push(p *api.PushPayload) (MatrixPayload, error) { // PullRequest implements payloadConvertor PullRequest method func (m matrixConvertor) PullRequest(p *api.PullRequestPayload) (MatrixPayload, error) { - text, _, _, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _, _ := matrixPayloadFormatter.getPullRequestPayloadInfo(p) return m.newPayload(text) } @@ -275,7 +282,7 @@ func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) { } func (m matrixConvertor) Action(p *api.ActionPayload) (MatrixPayload, error) { - text, _ := getActionPayloadInfo(p, htmlLinkFormatter) + text, _ := matrixPayloadFormatter.getActionPayloadInfo(p) return m.newPayload(text) } diff --git a/services/webhook/matrix_test.go b/services/webhook/matrix_test.go index 1644def0e1..a160dac718 100644 --- a/services/webhook/matrix_test.go +++ b/services/webhook/matrix_test.go @@ -159,24 +159,24 @@ func TestMatrixPayload(t *testing.T) { require.NoError(t, err) require.NotNil(t, pl) - assert.Equal(t, "[test/repo] New wiki page '[index](http://localhost:3000/test/repo/wiki/index)' (Wiki change comment) by user1", pl.Body) - assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.FormattedBody) + assert.Equal(t, "[test/repo] New wiki page \"[index](http://localhost:3000/test/repo/wiki/index)\" (Wiki change comment) by user1", pl.Body) + assert.Equal(t, `[test/repo] New wiki page "index" (Wiki change comment) by user1`, pl.FormattedBody) p.Action = api.HookWikiEdited pl, err = mc.Wiki(p) require.NoError(t, err) require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' edited (Wiki change comment) by user1", pl.Body) - assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.FormattedBody) + assert.Equal(t, "[test/repo] Wiki page \"[index](http://localhost:3000/test/repo/wiki/index)\" edited (Wiki change comment) by user1", pl.Body) + assert.Equal(t, `[test/repo] Wiki page "index" edited (Wiki change comment) by user1`, pl.FormattedBody) p.Action = api.HookWikiDeleted pl, err = mc.Wiki(p) require.NoError(t, err) require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' deleted by user1", pl.Body) - assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.FormattedBody) + assert.Equal(t, "[test/repo] Wiki page \"[index](http://localhost:3000/test/repo/wiki/index)\" deleted by user1", pl.Body) + assert.Equal(t, `[test/repo] Wiki page "index" deleted by user1`, pl.FormattedBody) }) t.Run("Release", func(t *testing.T) { diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 43ab588230..578062b846 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -18,6 +18,9 @@ import ( webhook_module "forgejo.org/modules/webhook" "forgejo.org/services/forms" "forgejo.org/services/webhook/shared" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type msteamsHandler struct{} @@ -44,60 +47,274 @@ func (msteamsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm { } type ( - // MSTeamsFact for Fact Structure + // MSTeamsAction defines an action for an Adaptive Card, for example, opening a URL. + MSTeamsAction struct { + Type string `json:"type"` // e.g. "Action.OpenUrl" or "Action.ToggleVisibility" + Title string `json:"title,omitempty"` // The button title + URL string `json:"url,omitempty"` // The URL to open + TargetElements []string `json:"targetElements,omitempty"` // For Action.ToggleVisibility + } + + // MSTeamsTextBlock defines the Adaptive Card TextBlock element. + MSTeamsTextBlock struct { + Type string `json:"type"` // Must be "TextBlock" + Text string `json:"text"` // The text content + Size string `json:"size,omitempty"` // e.g., "Small", "Default", "Medium", "Large", "ExtraLarge" + Weight string `json:"weight,omitempty"` // e.g., "Lighter", "Default", "Bolder" + Color string `json:"color,omitempty"` // text color, e.g., "Default", "Dark", "Light", "Accent", "Good", "Warning", "Attention" + IsSubtle *bool `json:"isSubtle,omitempty"` // Optional: makes text less prominent (nil->omitted) + Wrap *bool `json:"wrap,omitempty"` // Optional: enables text wrapping (nil->omitted) + MaxLines int `json:"maxLines,omitempty"` // Optional: maximum number of lines to display + HorizAlign string `json:"horizontalAlignment,omitempty"` // e.g., "Left", "Center", "Right" + Spacing string `json:"spacing,omitempty"` // e.g., "None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding" + FontType string `json:"fontType,omitempty"` // e.g., "Default", "Monospace" + Style string `json:"style,omitempty"` // e.g., "default","columnHeader","heading" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when the TextBlock is clicked + IsVisible *bool `json:"isVisible,omitempty"` // Optional: is visible or not, default is true (nil->omitted) + } + + MSTeamsBadge struct { + Type string `json:"type"` // Must be "Badge" + Text string `json:"text,omitempty"` // The text content. Either this or Icon should be present + Icon string `json:"icon,omitempty"` // Icon to display. Either this or Text should be present + Appearance string `json:"appearance,omitempty"` // e.g., "Filled", "Tint" + IconPosition string `json:"iconPosition,omitempty"` // e.g., "Before", "After" + Shape string `json:"shape,omitempty"` // e.g., "Square", "Rounded", "Circular" + Size string `json:"size,omitempty"` // e.g., "Medium", "Large", "ExtraLarge" + Style string `json:"style,omitempty"` // e.g., "Default", "Subtle", "Informative", "Accent", "Good", "Attention", "Warning" + Spacing string `json:"spacing,omitempty"` // e.g., "None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when the IconRun is clicked + ID string `json:"id,omitempty"` + } + + // MSTeamsRickTextBlock defines the Adaptive Card RichTextBlock element, along with its contents (TextRun, IconRun, ImageRun) + MSTeamsRichTextBlock struct { + Type string `json:"type"` // Must be "RichTextBlock" + Inlines []any `json:"inlines"` // Content, should be array of either TextRun, IconRun, ImageRun + LabelFor string `json:"labelFor,omitempty"` // Optional: label for the RichTextBlock + HorizAlign string `json:"horizontalAlignment,omitempty"` // e.g., "Left", "Center", "Right" + Spacing string `json:"spacing,omitempty"` // e.g., "None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding" + ID string `json:"id,omitempty"` // ID + IsVisible *bool `json:"isVisible,omitempty"` // Optional: is visible or not, default is true (nil->omitted) + } + + MSTeamsRichTextTextRun struct { + Type string `json:"type"` // Must be "TextRun" + Text string `json:"text"` // The text content + Highlight *bool `json:"highlight,omitempty"` + Italic *bool `json:"italic,omitempty"` + Strikethrough *bool `json:"strikethrough,omitempty"` + Underline *bool `json:"underline,omitempty"` + Color string `json:"color,omitempty"` // text color, e.g., "Default", "Dark", "Light", "Accent", "Good", "Warning", "Attention" + FontType string `json:"fontType,omitempty"` // e.g., "Default", "Monospace" + IsSubtle *bool `json:"isSubtle,omitempty"` // Optional: makes text less prominent (nil->omitted) + Size string `json:"size,omitempty"` // e.g., "Small", "Default", "Medium", "Large", "ExtraLarge" + Weight string `json:"weight,omitempty"` // e.g., "Lighter", "Default", "Bolder" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when the TextRun is clicked + ID string `json:"id,omitempty"` + } + + MSTeamsIcon struct { + Type string `json:"type"` // Must be "Icon" + Name string `json:"name"` // The icon name to display + Color string `json:"color,omitempty"` // text color, e.g., "Default", "Dark", "Light", "Accent", "Good", "Warning", "Attention" + Size string `json:"size,omitempty"` // e.g., "xxSmall", "xSmall", "Small", "Standard", "Medium", "Large", "xLarge", "xxLarge" + Style string `json:"style,omitempty"` // e.g., "Regular", "Filled" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when the IconRun is clicked + ID string `json:"id,omitempty"` + } + + // MSTeamsImage defines the Adaptive Card Image element. + MSTeamsImage struct { + Type string `json:"type"` // Must be "Image" + URL string `json:"url"` // URL of the image + Alt string `json:"altText"` // Alt text for the image + Size string `json:"size,omitempty"` // e.g., "Auto", "Stretch", "Small", "Medium", "Large" + Style string `json:"style,omitempty"` // e.g., "Default", "Person", "RoundedCorners" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when the columnset is clicked + } + + // MSTeamsColumn defines a column in an Adaptive Card ColumnSet. + MSTeamsColumn struct { + Type string `json:"type"` // Must be "Column" + Items []any `json:"items,omitempty"` // Array of card elements (TextBlock, Image, etc.) + Width any `json:"width,omitempty"` // "auto", "stretch", or number/string for fixed width + Style string `json:"style,omitempty"` // e.g., "default", "emphasis" + VerticalAlign string `json:"verticalContentAlignment,omitempty"` // "Top", "Center", "Bottom" + Bleed *bool `json:"bleed,omitempty"` // Optional: allow content to bleed outside padding (nil->omitted) + Separator *bool `json:"separator,omitempty"` // Optional: draw a separating line (nil->omitted) + Spacing string `json:"spacing,omitempty"` // e.g., "none", "small", "default", "medium", "large", "extraLarge", "padding" + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when column is clicked + IsVisible *bool `json:"isVisible,omitempty"` // Optional: is visible or not, default is true (nil->omitted) + } + + // MSTeamsColumnSet defines a row of columns in an Adaptive Card. + MSTeamsColumnSet struct { + Type string `json:"type"` // Must be "ColumnSet" + Columns []MSTeamsColumn `json:"columns"` // Array of columns + Spacing string `json:"spacing,omitempty"` // e.g., "none", "small", "default", "medium", "large", "extraLarge", "padding" + Separator *bool `json:"separator,omitempty"` // Optional: draw a separating line (nil->omitted) + Bleed *bool `json:"bleed,omitempty"` // Optional: allow content to bleed outside padding (nil->omitted) + SelectAction *MSTeamsAction `json:"selectAction,omitempty"` // Optional: action when columnset is clicked + IsVisible *bool `json:"isVisible,omitempty"` // Optional: is visible or not, default is true (nil->omitted) + } + + // MSTeamsFact represents a fact in an Adaptive Card FactSet. MSTeamsFact struct { - Name string `json:"name"` + Title string `json:"title"` Value string `json:"value"` } - // MSTeamsSection is a MessageCard section - MSTeamsSection struct { - ActivityTitle string `json:"activityTitle"` - ActivitySubtitle string `json:"activitySubtitle"` - ActivityImage string `json:"activityImage"` - Facts []MSTeamsFact `json:"facts"` - Text string `json:"text"` + // MSTeamsFactSet represents the Adaptive Card FactSet element. + MSTeamsFactSet struct { + Type string `json:"type"` // Must be "FactSet" + Facts []MSTeamsFact `json:"facts"` // List of facts + Separator *bool `json:"separator,omitempty"` // Optional: draw a separating line (nil->omitted) + Spacing string `json:"spacing,omitempty"` // e.g., "None", "ExtraSmall", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding" } - // MSTeamsAction is an action (creates buttons, links etc) - MSTeamsAction struct { - Type string `json:"@type"` - Name string `json:"name"` - Targets []MSTeamsActionTarget `json:"targets,omitempty"` + // MSTeamsContainer corresponds to an Adaptive Card container. + MSTeamsContainer struct { + Type string `json:"type"` // Must be "Container" + Items []any `json:"items,omitempty"` // Array of card elements (TextBlock, Image, FactSet, etc.) + ShowBorder *bool `json:"showBorder,omitempty"` // Optional: draw a border around the container (nil->omitted) + RoundedCorners *bool `json:"roundedCorners,omitempty"` // Optional: round the corners of the container (nil->omitted) + Spacing string `json:"spacing,omitempty"` // e.g., "None", "ExtraSmall", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding" + Bleed *bool `json:"bleed,omitempty"` // Optional: allow content to bleed outside padding (nil->omitted) + Style string `json:"style,omitempty"` // color theme of the container, e.g. "default", "emphasis", "accent", "good", "attention", "warning" + ID string `json:"id,omitempty"` // ID + IsVisible *bool `json:"isVisible,omitempty"` // Optional: is visible or not, default is true (nil->omitted) + VerticalAlign string `json:"verticalContentAlignment,omitempty"` // "Top", "Center", "Bottom" } - // MSTeamsActionTarget is the actual link to follow, etc - MSTeamsActionTarget struct { - Os string `json:"os"` - URI string `json:"uri"` + MSTeamsOptions struct { + Width string `json:"width"` // use "Full" to make card full width } - // MSTeamsPayload is the parent object + // MSTeamsPayload represents the Adaptive Card payload. + // Adaptive Cards use "body" for visual elements and "actions" for interactive buttons. MSTeamsPayload struct { - Type string `json:"@type"` - Context string `json:"@context"` - ThemeColor string `json:"themeColor"` - Title string `json:"title"` - Summary string `json:"summary"` - Sections []MSTeamsSection `json:"sections"` - PotentialAction []MSTeamsAction `json:"potentialAction"` + Type string `json:"type"` // Must be "AdaptiveCard" + Schema string `json:"$schema"` // e.g., "http://adaptivecards.io/schemas/adaptive-card.json" + Version string `json:"version"` // e.g., "1.5" + MsTeams MSTeamsOptions `json:"msteams"` // Optional: settings for Microsoft Teams + Body []MSTeamsContainer `json:"body"` // Array of containers (sections) + Actions []MSTeamsAction `json:"actions,omitempty"` // Optional array of actions + Style string `json:"style,omitempty"` // color theme of the card, e.g. "default", "emphasis", "accent", "good", "attention", "warning" } ) +var ( + defaultStyle = "default" // default colour + emphasisStyle = "emphasis" // darker + accentStyle = "accent" // blue-purple + goodStyle = "good" // green + attentionStyle = "attention" // red + warningStyle = "warning" // yellow + // informativeStyle = "informative" // gray with white text + // subtleStyle = "subtle" // gray with dark text +) + +func capitalise(s string) string { + caser := cases.Title(language.English) + return caser.String(strings.ToLower(s)) +} + +func markdownLinkFormatter(url, text string) string { + if url == "" { + return text + } + return fmt.Sprintf(`[%s](%s)`, text, url) +} + +var msTeamsPayloadFormatter = webhookPayloadFormatter{ + nameFormatter: noneNameFormatter, + linkFormatter: noneLinkFormatter, + withSender: false, + withRepoName: false, +} + +func makeBadgeRow(badgeText, badgeIcon, badgeStyle string, refInfo ...string) MSTeamsContainer { + extraRefInfoText := []any{} + for _, info := range refInfo { + extraRefInfoText = append(extraRefInfoText, MSTeamsTextBlock{ + Type: "TextBlock", + Text: info, + Size: "Small", + IsSubtle: new(true), + Spacing: "None", + }) + } + + return MSTeamsContainer{ + Type: "Container", + VerticalAlign: "Center", + Items: []any{ + MSTeamsColumnSet{ + Type: "ColumnSet", + Columns: []MSTeamsColumn{ + { + Type: "Column", + Width: "auto", + Items: []any{ + MSTeamsBadge{ + Type: "Badge", + Size: "Large", + Icon: badgeIcon, + Text: badgeText, + Style: badgeStyle, + }, + }, + }, + { + Type: "Column", + Width: "stretch", + Items: extraRefInfoText, + }, + }, + }, + }, + } +} + +func pullRequestBranchInfo(p *api.PullRequestPayload) string { + if p == nil || p.PullRequest == nil { + return "" + } + + headRef, baseRef := "", "" + if p.PullRequest.Head != nil { + headRef = p.PullRequest.Head.Ref + } + if p.PullRequest.Base != nil { + baseRef = p.PullRequest.Base.Ref + } + + switch { + case headRef == "" && baseRef == "": + return "" + case headRef == "": + return baseRef + case baseRef == "": + return headRef + default: + return fmt.Sprintf("%s → %s", headRef, baseRef) + } +} + // Create implements PayloadConvertor Create method func (m msteamsConvertor) Create(p *api.CreatePayload) (MSTeamsPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() - title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) + actionTitle := fmt.Sprintf("%s created: %s", capitalise(p.RefType), refName) return createMSTeamsPayload( p.Repo, p.Sender, - title, - "", + actionTitle, + nil, p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName), - greenColor, - &MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName}, + goodStyle, ), nil } @@ -105,283 +322,764 @@ func (m msteamsConvertor) Create(p *api.CreatePayload) (MSTeamsPayload, error) { func (m msteamsConvertor) Delete(p *api.DeletePayload) (MSTeamsPayload, error) { // deleted tag/branch refName := git.RefName(p.Ref).ShortName() - title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) + actionTitle := fmt.Sprintf("%s deleted: %s", capitalise(p.RefType), refName) return createMSTeamsPayload( p.Repo, p.Sender, - title, - "", - p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName), - yellowColor, - &MSTeamsFact{fmt.Sprintf("%s:", p.RefType), refName}, + actionTitle, + nil, + p.Repo.HTMLURL, + attentionStyle, ), nil } // Fork implements PayloadConvertor Fork method func (m msteamsConvertor) Fork(p *api.ForkPayload) (MSTeamsPayload, error) { - title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) + actionTitle := fmt.Sprintf("%s is forked to %s", markdownLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName), p.Repo.FullName) return createMSTeamsPayload( p.Repo, p.Sender, - title, - "", + actionTitle, + nil, p.Repo.HTMLURL, - greenColor, - &MSTeamsFact{"Forkee:", p.Forkee.FullName}, + accentStyle, ), nil } // Push implements PayloadConvertor Push method func (m msteamsConvertor) Push(p *api.PushPayload) (MSTeamsPayload, error) { var ( - branchName = git.RefName(p.Ref).ShortName() - commitDesc string + branchName = git.RefName(p.Ref).ShortName() + actionTitle string ) - var titleLink string + var diffLink string if p.TotalCommits == 1 { - commitDesc = "1 new commit" - titleLink = p.Commits[0].URL + actionTitle = fmt.Sprintf("[%s] 1 new commit", branchName) + diffLink = p.Commits[0].URL } else { - commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits) - titleLink = p.CompareURL + actionTitle = fmt.Sprintf("[%s] %d new commits", branchName, p.TotalCommits) + diffLink = p.CompareURL } - if titleLink == "" { - titleLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName) + if diffLink == "" { + diffLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName) } - title := fmt.Sprintf("[%s:%s] %s", p.Repo.FullName, branchName, commitDesc) + var shownCommits []any + var hiddenCommits []any + const limit = 5 - var text strings.Builder - // for each commit, generate attachment text for i, commit := range p.Commits { - fmt.Fprintf(&text, "[%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.WriteString("\n\n") + currentCommitTextBlock := MSTeamsColumnSet{ + Type: "ColumnSet", + Columns: []MSTeamsColumn{ + { + Type: "Column", + Width: "auto", + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: markdownLinkFormatter(commit.URL, commit.ID[:8]), + Size: "Small", + FontType: "Monospace", + }, + }, + }, + { + Type: "Column", + Width: "stretch", + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: strings.TrimRight(commit.Message, "\r\n"), + Size: "Small", + MaxLines: 3, + }, + }, + }, + }, } + if i < limit { + shownCommits = append(shownCommits, currentCommitTextBlock) + } else { + hiddenCommits = append(hiddenCommits, currentCommitTextBlock) + } + } + + bodyContainer := MSTeamsContainer{ + Type: "Container", + Style: emphasisStyle, + ShowBorder: new(true), + Items: shownCommits, + } + + if len(hiddenCommits) > 0 { + var extraCommitText string + remaining := len(hiddenCommits) + if remaining == 1 { + extraCommitText = "*and 1 more commit*" + } else { + extraCommitText = fmt.Sprintf("*and %d more commits*", remaining) + } + + // Expand control + showMore := MSTeamsContainer{ + Type: "Container", + ID: "showMore", + Items: []any{ + MSTeamsColumnSet{ + Type: "ColumnSet", + SelectAction: &MSTeamsAction{ + Type: "Action.ToggleVisibility", + TargetElements: []string{"showMore", "showLess", "hiddenCommits"}, + }, + Columns: []MSTeamsColumn{ + { + Type: "Column", + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: extraCommitText, + Size: "Small", + }, + }, + Width: "auto", + }, + { + Type: "Column", + Items: []any{ + MSTeamsIcon{ + Type: "Icon", + Name: "ChevronDown", + Size: "xxSmall", + }, + }, + Width: "auto", + }, + }, + }, + }, + } + + // Hidden commit messages + hiddenBlock := MSTeamsContainer{ + Type: "Container", + ID: "hiddenCommits", + Items: hiddenCommits, + IsVisible: new(false), + } + + // Collapse control + showLess := MSTeamsContainer{ + Type: "Container", + ID: "showLess", + IsVisible: new(false), + Items: []any{ + MSTeamsColumnSet{ + Type: "ColumnSet", + SelectAction: &MSTeamsAction{ + Type: "Action.ToggleVisibility", + TargetElements: []string{"showMore", "showLess", "hiddenCommits"}, + }, + Columns: []MSTeamsColumn{ + { + Type: "Column", + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: "Collapse", + Size: "Small", + }, + }, + Width: "auto", + }, + { + Type: "Column", + Items: []any{ + MSTeamsIcon{ + Type: "Icon", + Name: "ChevronUp", + Size: "xxSmall", + }, + }, + Width: "auto", + }, + }, + }, + }, + } + + bodyContainer.Items = append(bodyContainer.Items, showMore, hiddenBlock, showLess) } return createMSTeamsPayload( p.Repo, p.Sender, - title, - text.String(), - titleLink, - greenColor, - &MSTeamsFact{"Commit count:", fmt.Sprintf("%d", p.TotalCommits)}, + actionTitle, + []MSTeamsContainer{bodyContainer}, + diffLink, + defaultStyle, ), nil } // Issue implements PayloadConvertor Issue method func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) { - title, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, _, attachmentText, _ := msTeamsPayloadFormatter.getIssuesPayloadInfo(p) + + badgeStyle := attentionStyle + if p.Action == api.HookIssueReOpened { + badgeStyle = warningStyle + } else if p.Issue.State == api.StateOpen { + badgeStyle = goodStyle + } + + bodySections := []MSTeamsContainer{ + makeBadgeRow(capitalise(string(p.Issue.State)), "Target", badgeStyle, fmt.Sprintf("%s#%d", p.Repository.FullName, p.Issue.Index)), + } + + if attachmentText != "" { + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Style: emphasisStyle, + ShowBorder: new(true), + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: attachmentText, + Wrap: new(true), + }, + }, + }) + } + + factSet := MSTeamsFactSet{ + Type: "FactSet", + Facts: []MSTeamsFact{}, + Separator: new(true), + } + + if p.Issue.Ref != "" && (p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Ref", + Value: p.Issue.Ref, + }) + } + + if len(p.Issue.Labels) > 0 && + (p.Action == api.HookIssueLabelUpdated || p.Action == api.HookIssueLabelCleared || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + list := make([]string, len(p.Issue.Labels)) + for i, label := range p.Issue.Labels { + list[i] = label.Name + } + if len(list) > 0 { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Labels", + Value: strings.Join(list, ", "), + }) + } + } + + if len(p.Issue.Assignees) > 0 && + (p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + list := make([]string, len(p.Issue.Assignees)) + for i, user := range p.Issue.Assignees { + list[i] = "@" + user.UserName + } + if len(list) > 0 { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Assignee", + Value: strings.Join(list, ", "), + }) + } + } + + if p.Issue.Milestone != nil && + (p.Action == api.HookIssueMilestoned || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Milestone", + Value: p.Issue.Milestone.Title, + }) + } + + if len(factSet.Facts) > 0 { + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Items: []any{ + factSet, + }, + }) + } return createMSTeamsPayload( p.Repository, p.Sender, - title, - attachmentText, + actionTitle, + bodySections, p.Issue.HTMLURL, - color, - &MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)}, + defaultStyle, ), nil } // IssueComment implements PayloadConvertor IssueComment method func (m msteamsConvertor) IssueComment(p *api.IssueCommentPayload) (MSTeamsPayload, error) { - title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, _, _ := msTeamsPayloadFormatter.getIssueCommentPayloadInfo(p) + + var style string + switch p.Action { + case api.HookIssueCommentCreated: + style = goodStyle + case api.HookIssueCommentDeleted: + style = attentionStyle + default: + style = emphasisStyle + } + + bodySections := []MSTeamsContainer{ + { + Type: "Container", + Style: style, + ShowBorder: new(true), + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: "Comment", + Size: "Small", + Weight: "Bolder", + }, + MSTeamsTextBlock{ + Type: "TextBlock", + Text: p.Comment.Body, + Wrap: new(true), + }, + }, + }, + } return createMSTeamsPayload( p.Repository, p.Sender, - title, - p.Comment.Body, + actionTitle, + bodySections, p.Comment.HTMLURL, - color, - &MSTeamsFact{"Issue #:", fmt.Sprintf("%d", p.Issue.ID)}, + defaultStyle, ), nil } // PullRequest implements PayloadConvertor PullRequest method func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) { - title, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, _, attachmentText, _ := msTeamsPayloadFormatter.getPullRequestPayloadInfo(p) + + badgeStyle := attentionStyle + badgeState := "Closed" + badgeIcon := "BranchRequest" + if p.Action == api.HookIssueReOpened { + badgeStyle = warningStyle + badgeState = "Open" + } else if p.PullRequest.State == api.StateOpen { + badgeStyle = goodStyle + badgeState = "Open" + } else if p.PullRequest.HasMerged { + badgeStyle = accentStyle + badgeState = "Merged" + badgeIcon = "Branch" + } + + bodySections := []MSTeamsContainer{ + makeBadgeRow( + badgeState, + badgeIcon, + badgeStyle, + pullRequestBranchInfo(p), + fmt.Sprintf("%s#%d", p.Repository.FullName, p.PullRequest.Index), + ), + } + + if attachmentText != "" { + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Style: emphasisStyle, + ShowBorder: new(true), + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: attachmentText, + Wrap: new(true), + }, + }, + }) + } + + factSet := MSTeamsFactSet{ + Type: "FactSet", + Facts: []MSTeamsFact{}, + Separator: new(true), + } + + if len(p.PullRequest.Labels) > 0 && + (p.Action == api.HookIssueLabelUpdated || p.Action == api.HookIssueLabelCleared || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + list := make([]string, len(p.PullRequest.Labels)) + for i, label := range p.PullRequest.Labels { + list[i] = label.Name + } + if len(list) > 0 { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Labels", + Value: strings.Join(list, ", "), + }) + } + } + + if len(p.PullRequest.Assignees) > 0 && + (p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + list := make([]string, len(p.PullRequest.Assignees)) + for i, user := range p.PullRequest.Assignees { + list[i] = "@" + user.UserName + } + if len(list) > 0 { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Assignee", + Value: strings.Join(list, ", "), + }) + } + } + + if p.PullRequest.Milestone != nil && + (p.Action == api.HookIssueMilestoned || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Milestone", + Value: p.PullRequest.Milestone.Title, + }) + } + + if (p.PullRequest.RequestedReviewers != nil || p.PullRequest.RequestedReviewersTeams != nil) && + (p.Action == api.HookIssueReviewRequested || p.Action == api.HookIssueReviewRequestRemoved || p.Action == api.HookIssueOpened || p.Action == api.HookIssueReOpened) { + list := make([]string, 0, + len(p.PullRequest.RequestedReviewers)+ + len(p.PullRequest.RequestedReviewersTeams), + ) + for _, u := range p.PullRequest.RequestedReviewers { + list = append(list, u.UserName) + } + for _, t := range p.PullRequest.RequestedReviewersTeams { + list = append(list, t.Name) + } + if len(list) > 0 { + factSet.Facts = append(factSet.Facts, MSTeamsFact{ + Title: "Reviewers", + Value: strings.Join(list, ", "), + }) + } + } + + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Items: []any{factSet}, + }) return createMSTeamsPayload( p.Repository, p.Sender, - title, - attachmentText, + actionTitle, + bodySections, p.PullRequest.HTMLURL, - color, - &MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)}, + defaultStyle, ), nil } // Review implements PayloadConvertor Review method func (m msteamsConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MSTeamsPayload, error) { - var text, title string - var color int + badgeStyle := attentionStyle + badgeState := "Closed" + badgeIcon := "BranchRequest" + if p.Action == api.HookIssueReOpened { + badgeStyle = warningStyle + badgeState = "Re-opened" + } else if p.PullRequest.State == api.StateOpen { + badgeStyle = goodStyle + badgeState = "Open" + } else if p.PullRequest.HasMerged { + badgeStyle = accentStyle + badgeState = "Merged" + badgeIcon = "Branch" + } + + bodySections := []MSTeamsContainer{ + makeBadgeRow( + badgeState, + badgeIcon, + badgeStyle, + pullRequestBranchInfo(p), + fmt.Sprintf("%s#%d", p.Repository.FullName, p.PullRequest.Index), + ), + } + + var actionTitle, bodyStyle string if p.Action == api.HookIssueReviewed { action, err := parseHookPullRequestEventType(event) if err != nil { return MSTeamsPayload{}, err } - title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) - text = p.Review.Content + actionTitle = fmt.Sprintf("Pull request review %s: #%d %s", action, p.Index, p.PullRequest.Title) switch event { case webhook_module.HookEventPullRequestReviewApproved: - color = greenColor + bodyStyle = goodStyle case webhook_module.HookEventPullRequestReviewRejected: - color = redColor - case webhook_module.HookEventPullRequestReviewComment: - color = greyColor - default: - color = yellowColor + bodyStyle = attentionStyle } + + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Style: bodyStyle, + ShowBorder: new(true), + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: p.Review.Content, + Wrap: new(true), + }, + }, + }) } return createMSTeamsPayload( p.Repository, p.Sender, - title, - text, + actionTitle, + bodySections, p.PullRequest.HTMLURL, - color, - &MSTeamsFact{"Pull request #:", fmt.Sprintf("%d", p.PullRequest.ID)}, + defaultStyle, ), nil } // Repository implements PayloadConvertor Repository method func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload, error) { - var title, url string - var color int + var actionTitle, url string + style := emphasisStyle switch p.Action { case api.HookRepoCreated: - title = fmt.Sprintf("[%s] Repository created", p.Repository.FullName) + actionTitle = fmt.Sprintf("Repository created: %s", p.Repository.FullName) url = p.Repository.HTMLURL - color = greenColor + style = goodStyle case api.HookRepoDeleted: - title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName) - color = yellowColor + actionTitle = fmt.Sprintf("Repository deleted: %s", p.Repository.FullName) + style = attentionStyle } return createMSTeamsPayload( p.Repository, p.Sender, - title, - "", - url, - color, + actionTitle, nil, + url, + style, ), nil } // Wiki implements PayloadConvertor Wiki method func (m msteamsConvertor) Wiki(p *api.WikiPayload) (MSTeamsPayload, error) { - title, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, _, _ := msTeamsPayloadFormatter.getWikiPayloadInfo(p, false) + + var style string + + switch p.Action { + case api.HookWikiCreated: + style = goodStyle + case api.HookWikiEdited: + style = warningStyle + case api.HookWikiDeleted: + style = attentionStyle + } + + var bodySections []MSTeamsContainer + if p.Action != api.HookWikiDeleted { + bodySections = append(bodySections, MSTeamsContainer{ + Type: "Container", + Style: defaultStyle, + ShowBorder: new(true), + Items: []any{ + MSTeamsFactSet{ + Type: "FactSet", + Facts: []MSTeamsFact{ + { + Title: "Comment", + Value: p.Comment, + }, + }, + }, + }, + }) + } return createMSTeamsPayload( p.Repository, p.Sender, - title, - "", + actionTitle, + bodySections, p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page), - color, - &MSTeamsFact{"Repository:", p.Repository.FullName}, + style, ), nil } // Release implements PayloadConvertor Release method func (m msteamsConvertor) Release(p *api.ReleasePayload) (MSTeamsPayload, error) { - title, color := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, color := msTeamsPayloadFormatter.getReleasePayloadInfo(p) + var style string + + switch color { + case greenColor: + style = goodStyle + case yellowColor: + style = warningStyle + case redColor: + style = attentionStyle + } return createMSTeamsPayload( p.Repository, p.Sender, - title, - "", + actionTitle, + nil, p.Release.HTMLURL, - color, - &MSTeamsFact{"Tag:", p.Release.TagName}, + style, ), nil } func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) { - title, color := getPackagePayloadInfo(p, noneLinkFormatter, noneNameFormatter, false) + actionTitle, color := msTeamsPayloadFormatter.getPackagePayloadInfo(p) + var style string + + switch color { + case greenColor: + style = goodStyle + case yellowColor: + style = warningStyle + case redColor: + style = attentionStyle + } return createMSTeamsPayload( p.Repository, p.Sender, - title, - "", + actionTitle, + nil, p.Package.HTMLURL, - color, - &MSTeamsFact{"Package:", p.Package.Name}, + style, ), nil } func (m msteamsConvertor) Action(p *api.ActionPayload) (MSTeamsPayload, error) { - title, color := getActionPayloadInfo(p, noneLinkFormatter) + var actionTitle string + + var badgeStyle, badgeState string + + switch p.Action { + case api.HookActionFailure: + actionTitle = fmt.Sprintf("Action run #%d failed: %s", p.Run.ID, p.Run.Title) + badgeStyle = attentionStyle + badgeState = "Failed" + case api.HookActionRecover: + actionTitle = fmt.Sprintf("Action run #%d recovered: %s", p.Run.ID, p.Run.Title) + badgeStyle = goodStyle + badgeState = "Success" + case api.HookActionSuccess: + actionTitle = fmt.Sprintf("Action run #%d succeeded: %s", p.Run.ID, p.Run.Title) + badgeStyle = goodStyle + badgeState = "Success" + } + + bodySections := []MSTeamsContainer{ + makeBadgeRow( + badgeState, + "PlayCircle", + badgeStyle, + fmt.Sprintf("On %s", p.Run.PrettyRef), + fmt.Sprintf("%s Run #%d", p.Run.Repo.FullName, p.Run.ID), + ), + } // TODO: is TriggerUser correct here? // if you'd like to test these proprietary services, see the discussion on: https://codeberg.org/forgejo/forgejo/pulls/7508 return createMSTeamsPayload( p.Run.Repo, p.Run.TriggerUser, - title, - "", + actionTitle, + bodySections, p.Run.HTMLURL, - color, - // TODO: does this make any sense? - &MSTeamsFact{"Action:", p.Run.Title}, + defaultStyle, ), nil } -func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload { - facts := make([]MSTeamsFact, 0, 2) +func createMSTeamsPayload(r *api.Repository, s *api.User, actionTitle string, bodySections []MSTeamsContainer, actionTarget, style string) MSTeamsPayload { + // Update header adding the repository name and link + var updatedRepo string if r != nil { - facts = append(facts, MSTeamsFact{ - Name: "Repository:", - Value: r.FullName, - }) + updatedRepo = fmt.Sprintf(" | [%s](%s)", r.FullName, r.HTMLURL) } - if fact != nil { - facts = append(facts, *fact) + if style == "" { + style = defaultStyle + } + + repoHeaderSection := MSTeamsContainer{ + Type: "Container", + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Text: "💬 Update" + updatedRepo, + Weight: "Bolder", + Size: "Small", + IsSubtle: new(true), + }, + }, + } + + if s != nil { + // get display name + username := "@" + s.UserName + if s.FullName != "" { + username = s.FullName + " (@" + s.UserName + ")" + } + + actionTitle = fmt.Sprintf("%s by %s", actionTitle, markdownLinkFormatter(s.HTMLURL, username)) + } + + // Sender info section + actionTitleSection := MSTeamsContainer{ + Type: "Container", + Style: style, + Items: []any{ + MSTeamsTextBlock{ + Type: "TextBlock", + Style: "heading", + Text: actionTitle, + }, + }, + } + + // Combine sections in order + body := []MSTeamsContainer{repoHeaderSection, actionTitleSection} + body = append(body, bodySections...) + + // Create action button for navigation + actionButton := MSTeamsAction{ + Type: "Action.OpenUrl", + Title: "View in Forgejo", + URL: actionTarget, } return MSTeamsPayload{ - Type: "MessageCard", - Context: "https://schema.org/extensions", - ThemeColor: fmt.Sprintf("%x", color), - Title: title, - Summary: title, - Sections: []MSTeamsSection{ - { - ActivityTitle: s.FullName, - ActivitySubtitle: s.UserName, - ActivityImage: s.AvatarURL, - Text: text, - Facts: facts, - }, - }, - PotentialAction: []MSTeamsAction{ - { - Type: "OpenUri", - Name: "View in Forgejo", - Targets: []MSTeamsActionTarget{ - { - Os: "default", - URI: actionTarget, - }, - }, - }, + Type: "AdaptiveCard", + Schema: "http://adaptivecards.io/schemas/adaptive-card.json", + Version: "1.5", + MsTeams: MSTeamsOptions{ + Width: "Full", }, + Body: body, + Actions: []MSTeamsAction{actionButton}, + Style: defaultStyle, } } diff --git a/services/webhook/msteams_test.go b/services/webhook/msteams_test.go index da6439f198..c35ac2dd77 100644 --- a/services/webhook/msteams_test.go +++ b/services/webhook/msteams_test.go @@ -4,6 +4,9 @@ package webhook import ( + "bytes" + "context" + "strings" "testing" webhook_model "forgejo.org/models/webhook" @@ -15,32 +18,74 @@ import ( "github.com/stretchr/testify/require" ) +// findTextInContainer recursively searches for text within an MSTeamsContainer +func findTextInContainer(c MSTeamsContainer, substr string) bool { + for _, it := range c.Items { + switch v := it.(type) { + case MSTeamsTextBlock: + if strings.Contains(v.Text, substr) { + return true + } + case MSTeamsColumnSet: + for _, col := range v.Columns { + for _, it2 := range col.Items { + if tb, ok := it2.(MSTeamsTextBlock); ok && strings.Contains(tb.Text, substr) { + return true + } + } + } + case MSTeamsContainer: + if findTextInContainer(v, substr) { + return true + } + case MSTeamsFactSet: + for _, fact := range v.Facts { + if strings.Contains(fact.Value, substr) || strings.Contains(fact.Title, substr) { + return true + } + } + } + } + return false +} + func TestMSTeamsPayload(t *testing.T) { mc := msteamsConvertor{} + + // helper to find text within the adaptive card body + findTextInBody := func(pl MSTeamsPayload, substr string) bool { + for _, container := range pl.Body { + if findTextInContainer(container, substr) { + return true + } + } + return false + } + t.Run("Create", func(t *testing.T) { p := createTestPayload() pl, err := mc.Create(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] branch test created", pl.Title) - assert.Equal(t, "[test/repo] branch test created", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repo.FullName, fact.Value) - } else if fact.Name == "branch:" { - assert.Equal(t, "test", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) + // Check payload structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.Equal(t, "1.5", pl.Version) + + // Check body structure: header + title + badge sections + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Header should contain repo info + assert.True(t, findTextInBody(pl, "test/repo")) + + // Title should contain action by user + assert.True(t, findTextInBody(pl, "Branch created: test")) + + // action button should point to branch + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Actions[0].URL) }) t.Run("Delete", func(t *testing.T) { @@ -48,25 +93,20 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Delete(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] branch test deleted", pl.Title) - assert.Equal(t, "[test/repo] branch test deleted", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repo.FullName, fact.Value) - } else if fact.Name == "branch:" { - assert.Equal(t, "test", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "test/repo")) + assert.True(t, findTextInBody(pl, "Branch deleted: test")) + + // action button should point to branch + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo", pl.Actions[0].URL) }) t.Run("Fork", func(t *testing.T) { @@ -74,25 +114,19 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Fork(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.Title) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repo.FullName, fact.Value) - } else if fact.Name == "Forkee:" { - assert.Equal(t, p.Forkee.FullName, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "[test/repo2](http://localhost:3000/test/repo2) is forked to test/repo")) + + // action button should point to repo + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo", pl.Actions[0].URL) }) t.Run("Push", func(t *testing.T) { @@ -100,25 +134,23 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Push(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo:test] 2 new commits", pl.Title) - assert.Equal(t, "[test/repo:test] 2 new commits", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repo.FullName, fact.Value) - } else if fact.Name == "Commit count:" { - assert.Equal(t, "2", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify repo and basic content + assert.True(t, findTextInBody(pl, "[test] 2 new commits")) + + // commit details should be present in body + assert.True(t, findTextInBody(pl, "2020558")) + assert.True(t, findTextInBody(pl, "commit message")) + + // action button should point to compare + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Actions[0].URL) }) t.Run("Issue", func(t *testing.T) { @@ -127,48 +159,28 @@ func TestMSTeamsPayload(t *testing.T) { p.Action = api.HookIssueOpened pl, err := mc.Issue(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Title) - assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "issue body", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Issue #:" { - assert.Equal(t, "2", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "test/repo")) + assert.True(t, findTextInBody(pl, "Issue opened: #2 crash")) + assert.True(t, findTextInBody(pl, "issue body")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Actions[0].URL) p.Action = api.HookIssueClosed pl, err = mc.Issue(p) require.NoError(t, err) - - assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Title) - assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Issue #:" { - assert.Equal(t, "2", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI) + require.NotNil(t, pl) + assert.True(t, findTextInBody(pl, "Issue closed: #2 crash")) + require.Len(t, pl.Actions, 1) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Actions[0].URL) }) t.Run("IssueComment", func(t *testing.T) { @@ -176,51 +188,53 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.IssueComment(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Title) - assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "more info needed", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Issue #:" { - assert.Equal(t, "2", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "test/repo")) + assert.True(t, findTextInBody(pl, "New comment on issue #2 crash")) + assert.True(t, findTextInBody(pl, "more info needed")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.Actions[0].URL) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() + p.PullRequest.Head = &api.PRBranchInfo{ + Name: "feature/test", + Ref: "feature/test", + Sha: "b1eb92dc659513b7b4eb57d7ee7f9c6f92e714b5", + RepoID: 1, + Repository: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + } pl, err := mc.PullRequest(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Title) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "fixes bug #2", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Pull request #:" { - assert.Equal(t, "12", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "test/repo")) + assert.True(t, findTextInBody(pl, "Pull request opened: #12 Fix bug")) + assert.True(t, findTextInBody(pl, "fixes bug #2")) + assert.True(t, findTextInBody(pl, "feature/test → refs/pull/2/head")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Actions[0].URL) }) t.Run("PullRequestComment", func(t *testing.T) { @@ -228,25 +242,20 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.IssueComment(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Title) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "changes requested", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Issue #:" { - assert.Equal(t, "12", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "test/repo")) + assert.True(t, findTextInBody(pl, "New comment on pull request #12 Fix bug")) + assert.True(t, findTextInBody(pl, "changes requested")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.Actions[0].URL) }) t.Run("Review", func(t *testing.T) { @@ -255,25 +264,19 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Title) - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Equal(t, "good job", pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Pull request #:" { - assert.Equal(t, "12", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // review content should be present + assert.True(t, findTextInBody(pl, "Pull request review approved: #12 Fix bug")) + assert.True(t, findTextInBody(pl, "good job")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Actions[0].URL) }) t.Run("Repository", func(t *testing.T) { @@ -281,23 +284,18 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Repository(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Repository created", pl.Title) - assert.Equal(t, "[test/repo] Repository created", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 1) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "Repository created: test/repo")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo", pl.Actions[0].URL) }) t.Run("Package", func(t *testing.T) { @@ -305,23 +303,20 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Package(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "Package created: GiteaContainer:latest", pl.Title) - assert.Equal(t, "Package created: GiteaContainer:latest", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 1) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Package:" { - assert.Equal(t, p.Package.Name, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // no repo is associated + assert.False(t, findTextInBody(pl, "test/repo")) + // Verify content + assert.True(t, findTextInBody(pl, "Package created: GiteaContainer:latest")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.Actions[0].URL) }) t.Run("Wiki", func(t *testing.T) { @@ -330,65 +325,35 @@ func TestMSTeamsPayload(t *testing.T) { p.Action = api.HookWikiCreated pl, err := mc.Wiki(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Title) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content for create + assert.True(t, findTextInBody(pl, "New wiki page \"index\"")) + assert.True(t, findTextInBody(pl, "Wiki change comment")) + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Actions[0].URL) p.Action = api.HookWikiEdited pl, err = mc.Wiki(p) require.NoError(t, err) - - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Title) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) + require.NotNil(t, pl) + assert.True(t, findTextInBody(pl, "Wiki page \"index\" edited")) + assert.True(t, findTextInBody(pl, "Wiki change comment")) + require.Len(t, pl.Actions, 1) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Actions[0].URL) p.Action = api.HookWikiDeleted pl, err = mc.Wiki(p) require.NoError(t, err) - - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Title) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) + require.NotNil(t, pl) + assert.True(t, findTextInBody(pl, "Wiki page \"index\" deleted")) + require.Len(t, pl.Actions, 1) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Actions[0].URL) }) t.Run("Release", func(t *testing.T) { @@ -396,25 +361,18 @@ func TestMSTeamsPayload(t *testing.T) { pl, err := mc.Release(p) require.NoError(t, err) + require.NotNil(t, pl) - assert.Equal(t, "[test/repo] Release created: v1.0", pl.Title) - assert.Equal(t, "[test/repo] Release created: v1.0", pl.Summary) - assert.Len(t, pl.Sections, 1) - assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) - assert.Empty(t, pl.Sections[0].Text) - assert.Len(t, pl.Sections[0].Facts, 2) - for _, fact := range pl.Sections[0].Facts { - if fact.Name == "Repository:" { - assert.Equal(t, p.Repository.FullName, fact.Value) - } else if fact.Name == "Tag:" { - assert.Equal(t, "v1.0", fact.Value) - } else { - t.Fail() - } - } - assert.Len(t, pl.PotentialAction, 1) - assert.Len(t, pl.PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.PotentialAction[0].Targets[0].URI) + // Check basic structure + require.Equal(t, "AdaptiveCard", pl.Type) + require.GreaterOrEqual(t, len(pl.Body), 2) + + // Verify content + assert.True(t, findTextInBody(pl, "Release created: v1.0")) + + require.Len(t, pl.Actions, 1) + assert.Equal(t, "View in Forgejo", pl.Actions[0].Title) + assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.Actions[0].URL) }) } @@ -422,6 +380,7 @@ func TestMSTeamsJSONPayload(t *testing.T) { p := pushTestPayload() data, err := p.JSONPayload() require.NoError(t, err) + require.NotNil(t, data) hook := &webhook_model.Webhook{ RepoID: 3, @@ -438,7 +397,7 @@ func TestMSTeamsJSONPayload(t *testing.T) { PayloadVersion: 2, } - req, reqBody, err := msteamsHandler{}.NewRequest(t.Context(), hook, task) + req, reqBody, err := msteamsHandler{}.NewRequest(context.Background(), hook, task) require.NotNil(t, req) require.NotNil(t, reqBody) require.NoError(t, err) @@ -448,7 +407,10 @@ func TestMSTeamsJSONPayload(t *testing.T) { assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) var body MSTeamsPayload - err = json.NewDecoder(req.Body).Decode(&body) + err = json.NewDecoder(bytes.NewReader(reqBody)).Decode(&body) require.NoError(t, err) - assert.Equal(t, "[test/repo:test] 2 new commits", body.Summary) + + // Verify payload structure + assert.Equal(t, "AdaptiveCard", body.Type) + assert.Equal(t, "1.5", body.Version) } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index fe1bfc8aa4..24c28b58f3 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -119,6 +119,13 @@ func SlackTextFormatter(s string) string { return s } +var slackPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: SlackLinkFormatter, + nameFormatter: SlackNameFormatter, + withSender: true, + withRepoName: true, +} + // SlackNameFormatter puts the name into an inline code block. // This way names do not trigger unwanted message notifications, as users usually don't want to get notified about their own actions. func SlackNameFormatter(name string) string { @@ -174,7 +181,7 @@ func (s slackConvertor) Fork(p *api.ForkPayload) (SlackPayload, error) { // Issue implements payloadConvertor Issue method func (s slackConvertor) Issue(p *api.IssuePayload) (SlackPayload, error) { - text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter, SlackNameFormatter, true) + text, issueTitle, attachmentText, color := slackPayloadFormatter.getIssuesPayloadInfo(p) var attachments []SlackAttachment if attachmentText != "" { @@ -193,7 +200,7 @@ func (s slackConvertor) Issue(p *api.IssuePayload) (SlackPayload, error) { // IssueComment implements payloadConvertor IssueComment method func (s slackConvertor) IssueComment(p *api.IssueCommentPayload) (SlackPayload, error) { - text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter, SlackNameFormatter, true) + text, issueTitle, color := slackPayloadFormatter.getIssueCommentPayloadInfo(p) return s.createPayload(text, []SlackAttachment{{ Color: fmt.Sprintf("%x", color), @@ -205,20 +212,20 @@ func (s slackConvertor) IssueComment(p *api.IssueCommentPayload) (SlackPayload, // Wiki implements payloadConvertor Wiki method func (s slackConvertor) Wiki(p *api.WikiPayload) (SlackPayload, error) { - text, _, _ := getWikiPayloadInfo(p, SlackLinkFormatter, SlackNameFormatter, true) + text, _, _ := slackPayloadFormatter.getWikiPayloadInfo(p, 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, SlackNameFormatter, true) + text, _ := slackPayloadFormatter.getReleasePayloadInfo(p) return s.createPayload(text, nil), nil } func (s slackConvertor) Package(p *api.PackagePayload) (SlackPayload, error) { - text, _ := getPackagePayloadInfo(p, SlackLinkFormatter, SlackNameFormatter, true) + text, _ := slackPayloadFormatter.getPackagePayloadInfo(p) return s.createPayload(text, nil), nil } @@ -265,7 +272,7 @@ func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) { // PullRequest implements payloadConvertor PullRequest method func (s slackConvertor) PullRequest(p *api.PullRequestPayload) (SlackPayload, error) { - text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter, SlackNameFormatter, true) + text, issueTitle, attachmentText, color := slackPayloadFormatter.getPullRequestPayloadInfo(p) var attachments []SlackAttachment if attachmentText != "" { @@ -316,7 +323,7 @@ func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, erro } func (s slackConvertor) Action(p *api.ActionPayload) (SlackPayload, error) { - text, _ := getActionPayloadInfo(p, SlackLinkFormatter) + text, _ := slackPayloadFormatter.getActionPayloadInfo(p) return s.createPayload(text, nil), nil } diff --git a/services/webhook/slack_test.go b/services/webhook/slack_test.go index 9a60bf01ac..8d3a8a2e04 100644 --- a/services/webhook/slack_test.go +++ b/services/webhook/slack_test.go @@ -132,19 +132,19 @@ func TestSlackPayload(t *testing.T) { pl, err := sc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] New wiki page '' (Wiki change comment) by `user1`", pl.Text) + assert.Equal(t, "[test/repo] New wiki page \"\" (Wiki change comment) by `user1`", pl.Text) p.Action = api.HookWikiEdited pl, err = sc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page '' edited (Wiki change comment) by `user1`", pl.Text) + assert.Equal(t, "[test/repo] Wiki page \"\" edited (Wiki change comment) by `user1`", pl.Text) p.Action = api.HookWikiDeleted pl, err = sc.Wiki(p) require.NoError(t, err) - assert.Equal(t, "[test/repo] Wiki page '' deleted by `user1`", pl.Text) + assert.Equal(t, "[test/repo] Wiki page \"\" deleted by `user1`", pl.Text) }) t.Run("Release", func(t *testing.T) { diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index 47a7514968..dfd3014698 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -75,6 +75,13 @@ func (telegramHandler) Metadata(w *webhook_model.Webhook) any { return s } +var telegramPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: htmlLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + // Create implements PayloadConvertor Create method func (t telegramConvertor) Create(p *api.CreatePayload) (TelegramPayload, error) { // created tag/branch @@ -136,21 +143,21 @@ func (t telegramConvertor) Push(p *api.PushPayload) (TelegramPayload, error) { // Issue implements PayloadConvertor Issue method func (t telegramConvertor) Issue(p *api.IssuePayload) (TelegramPayload, error) { - text, _, attachmentText, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, attachmentText, _ := telegramPayloadFormatter.getIssuesPayloadInfo(p) return createTelegramPayload(text + "\n\n" + attachmentText), nil } // IssueComment implements PayloadConvertor IssueComment method func (t telegramConvertor) IssueComment(p *api.IssueCommentPayload) (TelegramPayload, error) { - text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _ := telegramPayloadFormatter.getIssueCommentPayloadInfo(p) return createTelegramPayload(text + "\n" + p.Comment.Body), nil } // PullRequest implements PayloadConvertor PullRequest method func (t telegramConvertor) PullRequest(p *api.PullRequestPayload) (TelegramPayload, error) { - text, _, attachmentText, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, attachmentText, _ := telegramPayloadFormatter.getPullRequestPayloadInfo(p) return createTelegramPayload(text + "\n" + attachmentText), nil } @@ -187,26 +194,26 @@ func (t telegramConvertor) Repository(p *api.RepositoryPayload) (TelegramPayload // Wiki implements PayloadConvertor Wiki method func (t telegramConvertor) Wiki(p *api.WikiPayload) (TelegramPayload, error) { - text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _, _ := telegramPayloadFormatter.getWikiPayloadInfo(p, true) return createTelegramPayload(text), nil } // Release implements PayloadConvertor Release method func (t telegramConvertor) Release(p *api.ReleasePayload) (TelegramPayload, error) { - text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _ := telegramPayloadFormatter.getReleasePayloadInfo(p) return createTelegramPayload(text), nil } func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, error) { - text, _ := getPackagePayloadInfo(p, htmlLinkFormatter, noneNameFormatter, true) + text, _ := telegramPayloadFormatter.getPackagePayloadInfo(p) return createTelegramPayload(text), nil } func (telegramConvertor) Action(p *api.ActionPayload) (TelegramPayload, error) { - text, _ := getActionPayloadInfo(p, htmlLinkFormatter) + text, _ := telegramPayloadFormatter.getActionPayloadInfo(p) return createTelegramPayload(text), nil } diff --git a/services/webhook/telegram_test.go b/services/webhook/telegram_test.go index 5066e55b8c..7da29c343c 100644 --- a/services/webhook/telegram_test.go +++ b/services/webhook/telegram_test.go @@ -148,19 +148,19 @@ good job`, pl.Message) pl, err := tc.Wiki(p) require.NoError(t, err) - assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.Message) + assert.Equal(t, `[test/repo] New wiki page "index" (Wiki change comment) by user1`, pl.Message) p.Action = api.HookWikiEdited pl, err = tc.Wiki(p) require.NoError(t, err) - assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.Message) + assert.Equal(t, `[test/repo] Wiki page "index" edited (Wiki change comment) by user1`, pl.Message) p.Action = api.HookWikiDeleted pl, err = tc.Wiki(p) require.NoError(t, err) - assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.Message) + assert.Equal(t, `[test/repo] Wiki page "index" deleted by user1`, pl.Message) }) t.Run("Release", func(t *testing.T) { diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index b22935be81..bb08f692f4 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -70,6 +70,13 @@ func newWechatworkMarkdownPayload(title string) WechatworkPayload { } } +var wechatworkPayloadFormatter = webhookPayloadFormatter{ + linkFormatter: noneLinkFormatter, + nameFormatter: noneNameFormatter, + withSender: true, + withRepoName: true, +} + // Create implements PayloadConvertor Create method func (wc wechatworkConvertor) Create(p *api.CreatePayload) (WechatworkPayload, error) { // created tag/branch @@ -126,7 +133,7 @@ func (wc wechatworkConvertor) Push(p *api.PushPayload) (WechatworkPayload, error // Issue implements PayloadConvertor Issue method func (wc wechatworkConvertor) Issue(p *api.IssuePayload) (WechatworkPayload, error) { - text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, _ := wechatworkPayloadFormatter.getIssuesPayloadInfo(p) var content string content += fmt.Sprintf(" >%s\n >%s \n > %s \n [%s](%s)", text, attachmentText, issueTitle, p.Issue.HTMLURL, p.Issue.HTMLURL) @@ -135,7 +142,7 @@ func (wc wechatworkConvertor) Issue(p *api.IssuePayload) (WechatworkPayload, err // IssueComment implements PayloadConvertor IssueComment method func (wc wechatworkConvertor) IssueComment(p *api.IssueCommentPayload) (WechatworkPayload, error) { - text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, _ := wechatworkPayloadFormatter.getIssueCommentPayloadInfo(p) var content string content += fmt.Sprintf(" >%s\n >%s \n >%s \n [%s](%s)", text, p.Comment.Body, issueTitle, p.Comment.HTMLURL, p.Comment.HTMLURL) @@ -144,7 +151,7 @@ func (wc wechatworkConvertor) IssueComment(p *api.IssueCommentPayload) (Wechatwo // PullRequest implements PayloadConvertor PullRequest method func (wc wechatworkConvertor) PullRequest(p *api.PullRequestPayload) (WechatworkPayload, error) { - text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, issueTitle, attachmentText, _ := wechatworkPayloadFormatter.getPullRequestPayloadInfo(p) pr := fmt.Sprintf("> %s \r\n > %s \r\n > %s \r\n", text, issueTitle, attachmentText) @@ -183,26 +190,26 @@ func (wc wechatworkConvertor) Repository(p *api.RepositoryPayload) (WechatworkPa // Wiki implements PayloadConvertor Wiki method func (wc wechatworkConvertor) Wiki(p *api.WikiPayload) (WechatworkPayload, error) { - text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _, _ := wechatworkPayloadFormatter.getWikiPayloadInfo(p, true) return newWechatworkMarkdownPayload(text), nil } // Release implements PayloadConvertor Release method func (wc wechatworkConvertor) Release(p *api.ReleasePayload) (WechatworkPayload, error) { - text, _ := getReleasePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := wechatworkPayloadFormatter.getReleasePayloadInfo(p) return newWechatworkMarkdownPayload(text), nil } func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload, error) { - text, _ := getPackagePayloadInfo(p, noneLinkFormatter, noneNameFormatter, true) + text, _ := wechatworkPayloadFormatter.getPackagePayloadInfo(p) return newWechatworkMarkdownPayload(text), nil } func (wc wechatworkConvertor) Action(p *api.ActionPayload) (WechatworkPayload, error) { - text, _ := getActionPayloadInfo(p, noneLinkFormatter) + text, _ := wechatworkPayloadFormatter.getActionPayloadInfo(p) return newWechatworkMarkdownPayload(text), nil }