Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added Microsoft Teams Notifier #2633

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ Available notifiers are:
- **Slack**
- **Webhook**
- **Discord**
- **Microsoft Teams**

## Export data
**GO Feature Flag** allows you to export data about the usage of your flags.
Expand Down
24 changes: 14 additions & 10 deletions cmd/relayproxy/config/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import "fmt"
type NotifierConf struct {
Kind NotifierKind `mapstructure:"kind" koanf:"kind"`
// Deprecated: Use WebhookURL instead
SlackWebhookURL string `mapstructure:"slackWebhookUrl" koanf:"slackwebhookurl"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointurl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
Headers map[string][]string `mapstructure:"headers" koanf:"headers"`
WebhookURL string `mapstructure:"webhookUrl" koanf:"webhookurl"`
SlackWebhookURL string `mapstructure:"slackWebhookUrl" koanf:"slackWebhookUrl"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointUrl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
Headers map[string][]string `mapstructure:"headers" koanf:"headers"`
WebhookURL string `mapstructure:"webhookUrl" koanf:"webhookurl"`
}

func (c *NotifierConf) IsValid() error {
Expand All @@ -20,6 +20,9 @@ func (c *NotifierConf) IsValid() error {
if c.Kind == SlackNotifier && (c.SlackWebhookURL == "" && c.WebhookURL == "") {
return fmt.Errorf("invalid notifier: no \"slackWebhookUrl\" property found for kind \"%s\"", c.Kind)
}
if c.Kind == MicrosoftTeamsNotifier && c.WebhookURL == "" {
return fmt.Errorf("invalid notifier: no \"webhookURL\" property found for kind \"%s\"", c.Kind)
}
if c.Kind == WebhookNotifier && c.EndpointURL == "" {
return fmt.Errorf("invalid notifier: no \"endpointUrl\" property found for kind \"%s\"", c.Kind)
}
Expand All @@ -32,15 +35,16 @@ func (c *NotifierConf) IsValid() error {
type NotifierKind string

const (
SlackNotifier NotifierKind = "slack"
WebhookNotifier NotifierKind = "webhook"
DiscordNotifier NotifierKind = "discord"
SlackNotifier NotifierKind = "slack"
MicrosoftTeamsNotifier NotifierKind = "microsoftteams"
WebhookNotifier NotifierKind = "webhook"
DiscordNotifier NotifierKind = "discord"
)

// IsValid is checking if the value is part of the enum
func (r NotifierKind) IsValid() error {
switch r {
case SlackNotifier, WebhookNotifier, DiscordNotifier:
case SlackNotifier, WebhookNotifier, DiscordNotifier, MicrosoftTeamsNotifier:
return nil
}
return fmt.Errorf("invalid notifier: kind \"%s\" is not supported", r)
Expand Down
9 changes: 8 additions & 1 deletion cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"github.com/thomaspoignant/go-feature-flag/exporter/webhookexporter"
"github.com/thomaspoignant/go-feature-flag/notifier"
"github.com/thomaspoignant/go-feature-flag/notifier/discordnotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/microsoftteamsnotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/slacknotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/webhooknotifier"
"github.com/thomaspoignant/go-feature-flag/retriever"
Expand Down Expand Up @@ -322,7 +323,13 @@
cNotif.WebhookURL = cNotif.SlackWebhookURL // nolint
}
notifiers = append(notifiers, &slacknotifier.Notifier{SlackWebhookURL: cNotif.WebhookURL})

case config.MicrosoftTeamsNotifier:
notifiers = append(
notifiers,
&microsoftteamsnotifier.Notifier{
MicrosoftTeamsWebhookURL: cNotif.MicrosoftTeamsWebhookURL,

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Build

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Coverage

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)) (typecheck)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)) (typecheck)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)) (typecheck)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Test

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)

Check failure on line 330 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Integration Tests

cNotif.MicrosoftTeamsWebhookURL undefined (type "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierConf has no field or method MicrosoftTeamsWebhookURL)
},
)
case config.WebhookNotifier:
notifiers = append(notifiers,
&webhooknotifier.Notifier{
Expand Down
6 changes: 6 additions & 0 deletions cmd/relayproxy/service/gofeatureflag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"github.com/thomaspoignant/go-feature-flag/exporter/sqsexporter"
"github.com/thomaspoignant/go-feature-flag/exporter/webhookexporter"
"github.com/thomaspoignant/go-feature-flag/notifier"
"github.com/thomaspoignant/go-feature-flag/notifier/microsoftteamsnotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/slacknotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/webhooknotifier"
"github.com/thomaspoignant/go-feature-flag/retriever"
Expand Down Expand Up @@ -424,6 +425,10 @@
Kind: config.SlackNotifier,
WebhookURL: "http:xxxx.xxx",
},
{
Kind: config.MicrosoftTeamsNotifier,
MicrosoftTeamsWebhookURL: "http:zzzz.zzz",

Check failure on line 430 in cmd/relayproxy/service/gofeatureflag_test.go

View workflow job for this annotation

GitHub Actions / Lint

unknown field MicrosoftTeamsWebhookURL in struct literal of type struct{Kind "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config".NotifierKind "mapstructure:\"kind\" koanf:\"kind\""; SlackWebhookURL string "mapstructure:\"slackWebhookUrl\" koanf:\"slackWebhookUrl\""; EndpointURL string "mapstructure:\"endpointUrl\" koanf:\"endpointUrl\""; Secret string "mapstructure:\"secret\" koanf:\"secret\""; Meta map[string]string "mapstructure:\"meta\" koanf:\"meta\""; Headers map[string][]string "mapstructure:\"headers\" koanf:\"headers\""; WebhookURL string "mapstructure:\"webhookUrl\" koanf:\"webhookurl\""} (typecheck)
},
{
Kind: config.WebhookNotifier,
EndpointURL: "http:yyyy.yyy",
Expand All @@ -432,6 +437,7 @@
},
want: []notifier.Notifier{
&slacknotifier.Notifier{SlackWebhookURL: "http:xxxx.xxx"},
&microsoftteamsnotifier.Notifier{MicrosoftTeamsWebhookURL: "http:zzzz.zzz"},
&webhooknotifier.Notifier{EndpointURL: "http:yyyy.yyy"},
},
wantErr: assert.NoError,
Expand Down
212 changes: 212 additions & 0 deletions notifier/microsoftteamsnotifier/notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package microsoftteamsnotifier

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"sync"

"github.com/luci/go-render/render"
"github.com/r3labs/diff/v3"
"github.com/thomaspoignant/go-feature-flag/internal"
"github.com/thomaspoignant/go-feature-flag/notifier"
)

const (
colorDeleted = "#FF0000"
colorUpdated = "#FFA500"
colorAdded = "#008000"
goFFLogo = "https://raw.githubusercontent.com/thomaspoignant/go-feature-flag/main/logo_128.png"
microsoftteamsFooter = "go-feature-flag"
longMicrosoftTeamsAttachment = 100
)

type Notifier struct {
MicrosoftTeamsWebhookURL string

httpClient internal.HTTPClient
init sync.Once
}

func (c *Notifier) Notify(diff notifier.DiffCache) error {
if c.MicrosoftTeamsWebhookURL == "" {
return fmt.Errorf("error: (Microsoft Teams Notifier) invalid notifier configuration, no " +
"MicrosoftTeamsWebhookURL provided for the microsoft teams notifier")
}

// init the notifier
c.init.Do(func() {
if c.httpClient == nil {
c.httpClient = internal.DefaultHTTPClient()
}
})

microsoftteamsURL, err := url.Parse(c.MicrosoftTeamsWebhookURL)
if err != nil {
return fmt.Errorf("error: (Microsoft Teams Notifier) invalid MicrosoftTeamsWebhookURL: %v",
c.MicrosoftTeamsWebhookURL)
}

reqBody := convertToMicrosoftTeamsMessage(diff)
payload, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("error: (Microsoft Teams Notifier) impossible to read differences; %v", err)
}
microsoftTeamAccessToken := os.Getenv("MICROSOFT_TEAMS_ACCESS_TOKEN")
request := http.Request{
Method: http.MethodPost,
URL: microsoftteamsURL,
Body: io.NopCloser(bytes.NewReader(payload)),
Header: map[string][]string{
"Content-Type": {"application/json"},
"Authorization": {"Bearer " + microsoftTeamAccessToken},
},
}
response, err := c.httpClient.Do(&request)
if err != nil {
return fmt.Errorf("error: (Microsoft Teams Notifier) error: while calling webhook: %v", err)
}

defer func() { _ = response.Body.Close() }()
if response.StatusCode > 399 {
return fmt.Errorf("error: (Microsoft Teams Notifier) while calling microsoft teams webhook, statusCode = %d",
response.StatusCode)
}

return nil
}

func convertToMicrosoftTeamsMessage(diffCache notifier.DiffCache) AdaptiveCard {
hostname, _ := os.Hostname()
attachments := convertDeletedFlagsToMicrosoftTeamsMessage(diffCache)
attachments = append(attachments, convertUpdatedFlagsToMicrosoftTeamsMessage(diffCache)...)
attachments = append(attachments, convertAddedFlagsToMicrosoftTeamsMessage(diffCache)...)
sections := attachmentsToSections(attachments)
res := AdaptiveCard{
Type: "MessageCard",
Context: "https://schema.org/extensions",
Summary: fmt.Sprintf("Changes detected in your feature flag file on: *%s*", hostname),
Sections: sections,
}
return res
}

func convertDeletedFlagsToMicrosoftTeamsMessage(diffCache notifier.DiffCache) []attachment {
attachments := make([]attachment, 0)
for key := range diffCache.Deleted {
attachment := attachment{
Title: fmt.Sprintf("❌ Flag \"%s\" deleted", key),
Color: colorDeleted,
FooterIcon: goFFLogo,
Footer: microsoftteamsFooter,
}
attachments = append(attachments, attachment)
}
return attachments
}

func convertUpdatedFlagsToMicrosoftTeamsMessage(diffCache notifier.DiffCache) []attachment {
attachments := make([]attachment, 0)
for key, value := range diffCache.Updated {
attachment := attachment{
Title: fmt.Sprintf("✏️ Flag \"%s\" updated", key),
Color: colorUpdated,
FooterIcon: goFFLogo,
Footer: microsoftteamsFooter,
Fields: []Field{},
}

changelog, _ := diff.Diff(value.Before, value.After, diff.AllowTypeMismatch(true))
for _, change := range changelog {
if change.Type == "update" {
value := fmt.Sprintf("%s => %s", render.Render(change.From), render.Render(change.To))
short := len(value) < longMicrosoftTeamsAttachment
attachment.Fields = append(
attachment.Fields,
Field{Title: strings.Join(change.Path, "."), Short: short, Value: value},
)
}
}

sort.Sort(byTitle(attachment.Fields))

attachments = append(attachments, attachment)
}
return attachments
}

func convertAddedFlagsToMicrosoftTeamsMessage(diff notifier.DiffCache) []attachment {
attachments := make([]attachment, 0)
for key := range diff.Added {
attachment := attachment{
Title: fmt.Sprintf("🆕 Flag \"%s\" created", key),
Color: colorAdded,
FooterIcon: goFFLogo,
Footer: microsoftteamsFooter,
}
attachments = append(attachments, attachment)
}
return attachments
}

func attachmentsToSections(attachments []attachment) []Section {
sections := make([]Section, len(attachments))
for i, att := range attachments {
facts := make([]Fact, len(att.Fields))
for j, field := range att.Fields {
facts[j] = Fact(field)
}

sections[i] = Section{
Title: att.Title,
Color: att.Color,
Facts: facts,
}
}
return sections
}

type AdaptiveCard struct {
Type string `json:"@type"`
Context string `json:"@context"`
Summary string `json:"summary"`
Sections []Section `json:"sections"`
}

type Section struct {
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
Color string `json:"color,omitempty"`
Facts []Fact `json:"facts,omitempty"`
}

type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
type attachment struct {
Title string `json:"title"`
Color string `json:"color"`
FooterIcon string `json:"footer_icon"`
Footer string `json:"footer"`
Fields []Field `json:"fields,omitempty"`
}

type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}

type byTitle []Field

func (a byTitle) Len() int { return len(a) }
func (a byTitle) Less(i, j int) bool { return a[i].Title < a[j].Title }
func (a byTitle) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
Loading
Loading