gitforge/services/webhook/msteams.go
nightfurysl2001 b58d8e20fd 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

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/11704): <!--number 11704 --><!--line 0 --><!--description VXBkYXRlIE1pY3Jvc29mdCBUZWFtcyB3ZWJob29rIHRvIHVzZSBBZGFwdGl2ZUNhcmQ=-->Update Microsoft Teams webhook to use AdaptiveCard<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11704
Reviewed-by: oliverpool <oliverpool@noreply.codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
2026-05-26 16:10:55 +02:00

1092 lines
34 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package webhook
import (
"context"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
webhook_model "forgejo.org/models/webhook"
"forgejo.org/modules/git"
api "forgejo.org/modules/structs"
"forgejo.org/modules/util"
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{}
func (msteamsHandler) Type() webhook_module.HookType { return webhook_module.MSTEAMS }
func (msteamsHandler) Metadata(*webhook_model.Webhook) any { return nil }
func (msteamsHandler) Icon(size int) template.HTML { return shared.ImgIcon("msteams.png", size) }
func (msteamsHandler) UnmarshalForm(bind func(any)) forms.WebhookForm {
var form struct {
forms.WebhookCoreForm
PayloadURL string `binding:"Required;ValidUrl"`
}
bind(&form)
return forms.WebhookForm{
WebhookCoreForm: form.WebhookCoreForm,
URL: form.PayloadURL,
ContentType: webhook_model.ContentTypeJSON,
Secret: "",
HTTPMethod: http.MethodPost,
Metadata: nil,
}
}
type (
// 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 {
Title string `json:"title"`
Value string `json:"value"`
}
// 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"
}
// 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"
}
MSTeamsOptions struct {
Width string `json:"width"` // use "Full" to make card full width
}
// MSTeamsPayload represents the Adaptive Card payload.
// Adaptive Cards use "body" for visual elements and "actions" for interactive buttons.
MSTeamsPayload struct {
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()
actionTitle := fmt.Sprintf("%s created: %s", capitalise(p.RefType), refName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
actionTitle,
nil,
p.Repo.HTMLURL+"/src/"+util.PathEscapeSegments(refName),
goodStyle,
), nil
}
// Delete implements PayloadConvertor Delete method
func (m msteamsConvertor) Delete(p *api.DeletePayload) (MSTeamsPayload, error) {
// deleted tag/branch
refName := git.RefName(p.Ref).ShortName()
actionTitle := fmt.Sprintf("%s deleted: %s", capitalise(p.RefType), refName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
actionTitle,
nil,
p.Repo.HTMLURL,
attentionStyle,
), nil
}
// Fork implements PayloadConvertor Fork method
func (m msteamsConvertor) Fork(p *api.ForkPayload) (MSTeamsPayload, error) {
actionTitle := fmt.Sprintf("%s is forked to %s", markdownLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName), p.Repo.FullName)
return createMSTeamsPayload(
p.Repo,
p.Sender,
actionTitle,
nil,
p.Repo.HTMLURL,
accentStyle,
), nil
}
// Push implements PayloadConvertor Push method
func (m msteamsConvertor) Push(p *api.PushPayload) (MSTeamsPayload, error) {
var (
branchName = git.RefName(p.Ref).ShortName()
actionTitle string
)
var diffLink string
if p.TotalCommits == 1 {
actionTitle = fmt.Sprintf("[%s] 1 new commit", branchName)
diffLink = p.Commits[0].URL
} else {
actionTitle = fmt.Sprintf("[%s] %d new commits", branchName, p.TotalCommits)
diffLink = p.CompareURL
}
if diffLink == "" {
diffLink = p.Repo.HTMLURL + "/src/" + util.PathEscapeSegments(branchName)
}
var shownCommits []any
var hiddenCommits []any
const limit = 5
for i, commit := range p.Commits {
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,
actionTitle,
[]MSTeamsContainer{bodyContainer},
diffLink,
defaultStyle,
), nil
}
// Issue implements PayloadConvertor Issue method
func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) {
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,
actionTitle,
bodySections,
p.Issue.HTMLURL,
defaultStyle,
), nil
}
// IssueComment implements PayloadConvertor IssueComment method
func (m msteamsConvertor) IssueComment(p *api.IssueCommentPayload) (MSTeamsPayload, error) {
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,
actionTitle,
bodySections,
p.Comment.HTMLURL,
defaultStyle,
), nil
}
// PullRequest implements PayloadConvertor PullRequest method
func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) {
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,
actionTitle,
bodySections,
p.PullRequest.HTMLURL,
defaultStyle,
), nil
}
// Review implements PayloadConvertor Review method
func (m msteamsConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MSTeamsPayload, error) {
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
}
actionTitle = fmt.Sprintf("Pull request review %s: #%d %s", action, p.Index, p.PullRequest.Title)
switch event {
case webhook_module.HookEventPullRequestReviewApproved:
bodyStyle = goodStyle
case webhook_module.HookEventPullRequestReviewRejected:
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,
actionTitle,
bodySections,
p.PullRequest.HTMLURL,
defaultStyle,
), nil
}
// Repository implements PayloadConvertor Repository method
func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload, error) {
var actionTitle, url string
style := emphasisStyle
switch p.Action {
case api.HookRepoCreated:
actionTitle = fmt.Sprintf("Repository created: %s", p.Repository.FullName)
url = p.Repository.HTMLURL
style = goodStyle
case api.HookRepoDeleted:
actionTitle = fmt.Sprintf("Repository deleted: %s", p.Repository.FullName)
style = attentionStyle
}
return createMSTeamsPayload(
p.Repository,
p.Sender,
actionTitle,
nil,
url,
style,
), nil
}
// Wiki implements PayloadConvertor Wiki method
func (m msteamsConvertor) Wiki(p *api.WikiPayload) (MSTeamsPayload, error) {
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,
actionTitle,
bodySections,
p.Repository.HTMLURL+"/wiki/"+url.PathEscape(p.Page),
style,
), nil
}
// Release implements PayloadConvertor Release method
func (m msteamsConvertor) Release(p *api.ReleasePayload) (MSTeamsPayload, error) {
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,
actionTitle,
nil,
p.Release.HTMLURL,
style,
), nil
}
func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) {
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,
actionTitle,
nil,
p.Package.HTMLURL,
style,
), nil
}
func (m msteamsConvertor) Action(p *api.ActionPayload) (MSTeamsPayload, error) {
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,
actionTitle,
bodySections,
p.Run.HTMLURL,
defaultStyle,
), nil
}
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 {
updatedRepo = fmt.Sprintf(" | [%s](%s)", r.FullName, r.HTMLURL)
}
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: "AdaptiveCard",
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Version: "1.5",
MsTeams: MSTeamsOptions{
Width: "Full",
},
Body: body,
Actions: []MSTeamsAction{actionButton},
Style: defaultStyle,
}
}
type msteamsConvertor struct{}
var _ shared.PayloadConvertor[MSTeamsPayload] = msteamsConvertor{}
func (msteamsHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
return shared.NewJSONRequest(msteamsConvertor{}, w, t, true)
}