From 3901141dc9c27e056a0ac0ee4bd218af618746ce Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Thu, 15 Aug 2024 11:12:38 +0800 Subject: [PATCH 01/13] Create MessageFilter --- pkg/slack/filter.go | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 pkg/slack/filter.go diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go new file mode 100644 index 0000000..2e3171d --- /dev/null +++ b/pkg/slack/filter.go @@ -0,0 +1,81 @@ +package slack + +import ( + "fmt" + "strings" + + "github.com/oursky/github-actions-manager/pkg/github/jobs" + "k8s.io/utils/strings/slices" +) + +type messageFilterRule struct { + conclusions []string + // branches []string + workflows []string +} +type MessageFilter struct { + whitelists []messageFilterRule +} + +func (rule messageFilterRule) Pass(run *jobs.WorkflowRun) bool { + if len(rule.conclusions) > 0 && !slices.Contains(rule.conclusions, run.Conclusion) { + return false + } + if len(rule.workflows) > 0 && !slices.Contains(rule.workflows, run.Name) { + return false + } + return true +} + +func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { + for _, rule := range mf.whitelists { + if rule.Pass(run) { + return true + } + } + return false +} + +func (rule *messageFilterRule) setConclusions(conclusions []string) error { + conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} + // var supportedConclusions, unsupportedConclusions []string + var unsupportedConclusions []string + for _, c := range conclusions { + // if slices.Contains(conclusionsEnum, c) { + // supportedConclusions = append(supportedConclusions, c) + // } else { + if !slices.Contains(conclusionsEnum, c) { + unsupportedConclusions = append(unsupportedConclusions, c) + } + } + + if len(unsupportedConclusions) > 0 { + if slices.Contains(unsupportedConclusions, " ") { + return fmt.Errorf("Do not space-separate conclusions. Use format conclusion1,conclusion2") + } + return fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) + } + + rule.conclusions = conclusions + return nil +} + +func NewFilter(filterLayers []string) (*MessageFilter, error) { + filter := MessageFilter{ + whitelists: []messageFilterRule{}, + } + // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters + for _, layer := range filterLayers { + definition := strings.Split(layer, ":") + + definition = definition + // switch definition[0] + // case "" + } + + return &filter, nil +} + +// func (mf MessageFilter) String() string { +// output = "" +// } From 302a4418ab9289a62e4382fa67c6057191836918 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Thu, 15 Aug 2024 10:48:18 +0800 Subject: [PATCH 02/13] Switch channelInfo encoding from string to JSON --- pkg/slack/app.go | 81 +++++++++++++++---------------------------- pkg/slack/filter.go | 26 +++++++------- pkg/slack/notifier.go | 6 ++-- 3 files changed, 45 insertions(+), 68 deletions(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 1308c25..34c61d4 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "errors" "fmt" "regexp" @@ -13,7 +14,6 @@ import ( "github.com/slack-go/slack/socketmode" "go.uber.org/zap" "golang.org/x/sync/errgroup" - "k8s.io/utils/strings/slices" ) var repoRegex = regexp.MustCompile("[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)?") @@ -27,8 +27,8 @@ type App struct { } type ChannelInfo struct { - channelID string - conclusions []string + ChannelID string `json:"channelID"` + Conclusions []string `json:"conclusions"` } func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { @@ -50,15 +50,6 @@ func (a *App) Disabled() bool { return a.disabled } -// Format of channel info string: ":," -func toChannelInfoString(channelInfo ChannelInfo) string { - if len(channelInfo.conclusions) == 0 { - return channelInfo.channelID - } - conclusionsString := strings.Join(channelInfo.conclusions, ",") - return channelInfo.channelID + ":" + conclusionsString -} - func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, error) { data, err := a.store.Get(ctx, kvNamespace, repo) if err != nil { @@ -66,23 +57,12 @@ func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, erro } else if data == "" { return nil, nil } - channelInfoStrings := strings.Split(data, ";") - var channelInfos []ChannelInfo - for _, channelString := range channelInfoStrings { - channelID, conclusionsString, _ := strings.Cut(channelString, ":") - var conclusions []string - for _, conclusion := range strings.Split(conclusionsString, ",") { - if len(conclusion) > 0 { - conclusions = append(conclusions, conclusion) - } - } - channelInfos = append(channelInfos, ChannelInfo{ - channelID: channelID, - conclusions: conclusions, - }) + var channelInfos []ChannelInfo + err = json.Unmarshal([]byte(data), &channelInfos) + if err != nil { + return nil, err } - return channelInfos, nil } @@ -92,31 +72,22 @@ func (a *App) AddChannel(ctx context.Context, repo string, channelInfo ChannelIn return err } - // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters - supportedConclusions := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} - var unsupportedConclusions []string - for _, c := range channelInfo.conclusions { - if !slices.Contains(supportedConclusions, c) { - unsupportedConclusions = append(unsupportedConclusions, c) - } - } - - if len(unsupportedConclusions) > 0 { - return fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) - } - - var newChannelInfoStrings []string + var newChannelInfos []ChannelInfo for _, c := range channelInfos { - if c.channelID == channelInfo.channelID { + if c.ChannelID == channelInfo.ChannelID { // Skip the old subscription and will replace with the new conclusion filter options continue } - newChannelInfoStrings = append(newChannelInfoStrings, toChannelInfoString(c)) + newChannelInfos = append(newChannelInfos, c) } - newChannelInfoStrings = append(newChannelInfoStrings, toChannelInfoString(channelInfo)) - data := strings.Join(newChannelInfoStrings, ";") + newChannelInfos = append(newChannelInfos, channelInfo) - return a.store.Set(ctx, kvNamespace, repo, data) + data, err := json.Marshal(newChannelInfos) + if err != nil { + return err + } + + return a.store.Set(ctx, kvNamespace, repo, string(data)) } func (a *App) DelChannel(ctx context.Context, repo string, channelID string) error { @@ -125,21 +96,25 @@ func (a *App) DelChannel(ctx context.Context, repo string, channelID string) err return err } - var newChannelInfoStrings []string + var newChannelInfos []ChannelInfo found := false for _, c := range channelInfos { - if c.channelID == channelID { + if c.ChannelID == channelID { found = true continue } - newChannelInfoStrings = append(newChannelInfoStrings, toChannelInfoString(c)) + newChannelInfos = append(newChannelInfos, c) } if !found { return fmt.Errorf("not subscribed to repo") } - data := strings.Join(newChannelInfoStrings, ";") - return a.store.Set(ctx, kvNamespace, repo, data) + data, err := json.Marshal(newChannelInfos) + if err != nil { + return err + } + + return a.store.Set(ctx, kvNamespace, repo, string(data)) } func (a *App) SendMessage(ctx context.Context, channel string, options ...slack.MsgOption) error { @@ -222,8 +197,8 @@ func (a *App) messageLoop(ctx context.Context, client *socketmode.Client) { switch subcommand { case "subscribe": channelInfo := ChannelInfo{ - channelID: data.ChannelID, - conclusions: conclusions, + ChannelID: data.ChannelID, + Conclusions: conclusions, } err := a.AddChannel(ctx, repo, channelInfo) if err != nil { diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go index 2e3171d..c77cd19 100644 --- a/pkg/slack/filter.go +++ b/pkg/slack/filter.go @@ -8,27 +8,28 @@ import ( "k8s.io/utils/strings/slices" ) -type messageFilterRule struct { - conclusions []string +type MessageFilterRule struct { + Conclusions []string `json:"conclusions"` // branches []string - workflows []string + Workflows []string `json:"workflows"` } + type MessageFilter struct { - whitelists []messageFilterRule + Whitelists []MessageFilterRule `json:"whitelists"` + // can be extended to include blacklists []messageFilterRule } -func (rule messageFilterRule) Pass(run *jobs.WorkflowRun) bool { - if len(rule.conclusions) > 0 && !slices.Contains(rule.conclusions, run.Conclusion) { +func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { + if len(rule.Conclusions) > 0 && !slices.Contains(rule.Conclusions, run.Conclusion) { return false } - if len(rule.workflows) > 0 && !slices.Contains(rule.workflows, run.Name) { + if len(rule.Workflows) > 0 && !slices.Contains(rule.Workflows, run.Name) { return false } return true } - func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { - for _, rule := range mf.whitelists { + for _, rule := range mf.Whitelists { if rule.Pass(run) { return true } @@ -36,7 +37,8 @@ func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { return false } -func (rule *messageFilterRule) setConclusions(conclusions []string) error { +func (rule *MessageFilterRule) setConclusions(conclusions []string) error { + // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} // var supportedConclusions, unsupportedConclusions []string var unsupportedConclusions []string @@ -56,13 +58,13 @@ func (rule *messageFilterRule) setConclusions(conclusions []string) error { return fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) } - rule.conclusions = conclusions + rule.Conclusions = conclusions return nil } func NewFilter(filterLayers []string) (*MessageFilter, error) { filter := MessageFilter{ - whitelists: []messageFilterRule{}, + Whitelists: []MessageFilterRule{}, } // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters for _, layer := range filterLayers { diff --git a/pkg/slack/notifier.go b/pkg/slack/notifier.go index 7889429..d7c0c33 100644 --- a/pkg/slack/notifier.go +++ b/pkg/slack/notifier.go @@ -159,14 +159,14 @@ func (n *Notifier) notify(ctx context.Context, run *jobs.WorkflowRun) { } for _, channel := range channels { - if len(channel.conclusions) > 0 && !slices.Contains(channel.conclusions, run.Conclusion) { + if len(channel.Conclusions) > 0 && !slices.Contains(channel.Conclusions, run.Conclusion) { return } - err := n.app.SendMessage(ctx, channel.channelID, slack.MsgOptionAttachments(slackMsg)) + err := n.app.SendMessage(ctx, channel.ChannelID, slack.MsgOptionAttachments(slackMsg)) if err != nil { n.logger.Warn("failed to send message", zap.Error(err), - zap.String("channelID", channel.channelID), + zap.String("channelID", channel.ChannelID), ) } } From a00391812ee55777f25377962d23f1715bb10674 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Thu, 15 Aug 2024 10:56:00 +0800 Subject: [PATCH 03/13] Add argument parsing and filter construction to MessageFilter Fix location of used.keys a bit of a rename Update argument description for subscription filter --- pkg/github/jobs/state.go | 4 +++ pkg/slack/filter.go | 72 +++++++++++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/pkg/github/jobs/state.go b/pkg/github/jobs/state.go index 544d417..0e4e4ab 100644 --- a/pkg/github/jobs/state.go +++ b/pkg/github/jobs/state.go @@ -33,6 +33,8 @@ type WorkflowRun struct { CommitMessageTitle string CommitURL string + Branch string + Jobs []*WorkflowJob } @@ -94,6 +96,8 @@ func newState(runs map[Key]cell[github.WorkflowRun], jobs map[Key]cell[github.Wo StartedAt: run.GetRunStartedAt().Time, CommitMessageTitle: commitMsgTitle, CommitURL: commitURL, + + Branch: run.GetHeadBranch(), } } for key, c := range jobs { diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go index c77cd19..3a8bb4c 100644 --- a/pkg/slack/filter.go +++ b/pkg/slack/filter.go @@ -10,12 +10,12 @@ import ( type MessageFilterRule struct { Conclusions []string `json:"conclusions"` - // branches []string - Workflows []string `json:"workflows"` + Branches []string `json:"branches"` + Workflows []string `json:"workflows"` } type MessageFilter struct { - Whitelists []MessageFilterRule `json:"whitelists"` + Whitelists []MessageFilterRule `json:"filters"` // can be extended to include blacklists []messageFilterRule } @@ -23,6 +23,9 @@ func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { if len(rule.Conclusions) > 0 && !slices.Contains(rule.Conclusions, run.Conclusion) { return false } + if len(rule.Branches) > 0 && !slices.Contains(rule.Branches, run.Branch) { + return false + } if len(rule.Workflows) > 0 && !slices.Contains(rule.Workflows, run.Name) { return false } @@ -37,24 +40,17 @@ func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { return false } -func (rule *MessageFilterRule) setConclusions(conclusions []string) error { +func (rule *MessageFilterRule) SetConclusions(conclusions []string) error { // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} - // var supportedConclusions, unsupportedConclusions []string var unsupportedConclusions []string for _, c := range conclusions { - // if slices.Contains(conclusionsEnum, c) { - // supportedConclusions = append(supportedConclusions, c) - // } else { if !slices.Contains(conclusionsEnum, c) { unsupportedConclusions = append(unsupportedConclusions, c) } } if len(unsupportedConclusions) > 0 { - if slices.Contains(unsupportedConclusions, " ") { - return fmt.Errorf("Do not space-separate conclusions. Use format conclusion1,conclusion2") - } return fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) } @@ -66,18 +62,56 @@ func NewFilter(filterLayers []string) (*MessageFilter, error) { filter := MessageFilter{ Whitelists: []MessageFilterRule{}, } - // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters + + used := []string{} for _, layer := range filterLayers { definition := strings.Split(layer, ":") - definition = definition - // switch definition[0] - // case "" + switch len(definition) { + case 1: // Assumed format "conclusion1,conclusion2,..." + rule := MessageFilterRule{} + if slices.Contains(used, "none") { + return nil, fmt.Errorf("duplicated conclusion strings; use commas to separate conclusions") + } + conclusions := strings.Split(definition[0], ",") + + err := rule.SetConclusions(conclusions) + if err != nil { + return nil, err + } + + used = append(used, "none") + filter.Whitelists = append(filter.Whitelists, rule) + case 2, 3: // Assumed format "filterKey:filterValue1,filterValue2,..." + rule := MessageFilterRule{} + filterType := definition[0] + if slices.Contains(used, filterType) { + return nil, fmt.Errorf("duplicated filter type: %s", filterType) + } + switch filterType { + case "branches": + branches := strings.Split(definition[1], ",") + rule.Branches = branches + case "workflows": + workflows := strings.Split(definition[1], ",") + rule.Workflows = workflows + default: + return nil, fmt.Errorf("unsupported filter type: %s", filterType) + } + + if len(definition) == 3 { + conclusions := strings.Split(definition[2], ",") + + err := rule.SetConclusions(conclusions) + if err != nil { + return nil, err + } + } + + used = append(used, filterType) + filter.Whitelists = append(filter.Whitelists, rule) + } } return &filter, nil } - -// func (mf MessageFilter) String() string { -// output = "" -// } From 3dd8a94dceb7558e8761869323073ef7d8af7292 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Mon, 19 Aug 2024 14:08:46 +0800 Subject: [PATCH 04/13] Add String methods for human-readable printing --- pkg/slack/app.go | 7 +++++++ pkg/slack/filter.go | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 34c61d4..babaab0 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -31,6 +31,13 @@ type ChannelInfo struct { Conclusions []string `json:"conclusions"` } +func (f ChannelInfo) String() string { + if len(f.Conclusions) == 0 { + return f.ChannelID + } + return fmt.Sprintf("%s with %s", f.ChannelID, f.Conclusions) +} + func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { logger = logger.Named("slack-app") return &App{ diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go index 3a8bb4c..0ea3e19 100644 --- a/pkg/slack/filter.go +++ b/pkg/slack/filter.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/oursky/github-actions-manager/pkg/github/jobs" + "github.com/samber/lo" "k8s.io/utils/strings/slices" ) @@ -19,6 +20,28 @@ type MessageFilter struct { // can be extended to include blacklists []messageFilterRule } +func (rule MessageFilterRule) String() string { + output := "" + if len(rule.Conclusions) > 0 { + output += fmt.Sprintf("conclusions: %s", rule.Conclusions) + } + if len(rule.Branches) > 0 { + output += fmt.Sprintf("branches: %s", rule.Branches) + } + if len(rule.Workflows) > 0 { + output += fmt.Sprintf("workflows: %s", rule.Workflows) + } + return output +} + +func (mf MessageFilter) String() string { + return fmt.Sprintf("whitelists: %s", fmt.Sprintf("[%s]", strings.Join(lo.Map(mf.Whitelists, func(x MessageFilterRule, _ int) string { return x.String() }), ", "))) +} + +func (mf MessageFilter) Length() int { + return len(mf.Whitelists) +} + func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { if len(rule.Conclusions) > 0 && !slices.Contains(rule.Conclusions, run.Conclusion) { return false @@ -31,6 +54,7 @@ func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { } return true } + func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { for _, rule := range mf.Whitelists { if rule.Pass(run) { From c98b13140a1ff7b72d168e8fa541f6b8fd5a5faa Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Mon, 19 Aug 2024 14:10:26 +0800 Subject: [PATCH 05/13] Install MessageFilter in app and notifier temp squash Install --- pkg/slack/app.go | 28 +++++++++++++++++++++------- pkg/slack/notifier.go | 3 +-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index babaab0..8756834 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/oursky/github-actions-manager/pkg/github/jobs" "github.com/oursky/github-actions-manager/pkg/kv" "github.com/oursky/github-actions-manager/pkg/utils/array" "github.com/slack-go/slack" @@ -27,15 +28,19 @@ type App struct { } type ChannelInfo struct { - ChannelID string `json:"channelID"` - Conclusions []string `json:"conclusions"` + ChannelID string `json:"channelID"` + Filter *MessageFilter `json:"filter"` } func (f ChannelInfo) String() string { - if len(f.Conclusions) == 0 { + if f.Filter.Length() == 0 { return f.ChannelID } - return fmt.Sprintf("%s with %s", f.ChannelID, f.Conclusions) + return fmt.Sprintf("%s with %s", f.ChannelID, f.Filter) +} + +func (f ChannelInfo) ShouldSend(run *jobs.WorkflowRun) bool { + return f.Filter.Length() == 0 || f.Filter.Any(run) } func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { @@ -203,11 +208,20 @@ func (a *App) messageLoop(ctx context.Context, client *socketmode.Client) { switch subcommand { case "subscribe": + filterLayers := array.Unique(args[2:]) + filter, err := NewFilter(filterLayers) + if err != nil { + a.logger.Warn("failed to subscribe", zap.Error(err)) + client.Ack(*e.Request, map[string]interface{}{ + "text": fmt.Sprintf("Failed to subscribe '%s': %s\n", repo, err), + }) + } + channelInfo := ChannelInfo{ - ChannelID: data.ChannelID, - Conclusions: conclusions, + ChannelID: data.ChannelID, + Filter: filter, } - err := a.AddChannel(ctx, repo, channelInfo) + err = a.AddChannel(ctx, repo, channelInfo) if err != nil { a.logger.Warn("failed to subscribe", zap.Error(err)) client.Ack(*e.Request, map[string]interface{}{ diff --git a/pkg/slack/notifier.go b/pkg/slack/notifier.go index d7c0c33..fc9d2fa 100644 --- a/pkg/slack/notifier.go +++ b/pkg/slack/notifier.go @@ -12,7 +12,6 @@ import ( "github.com/slack-go/slack/slackutilsx" "go.uber.org/zap" "golang.org/x/sync/errgroup" - "k8s.io/utils/strings/slices" ) type JobsState interface { @@ -159,7 +158,7 @@ func (n *Notifier) notify(ctx context.Context, run *jobs.WorkflowRun) { } for _, channel := range channels { - if len(channel.Conclusions) > 0 && !slices.Contains(channel.Conclusions, run.Conclusion) { + if channel.ShouldSend(run) { return } err := n.app.SendMessage(ctx, channel.ChannelID, slack.MsgOptionAttachments(slackMsg)) From 4f759cbd9e70e03e741d3d9ac1ab4f416fa4a834 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Fri, 16 Aug 2024 12:11:27 +0800 Subject: [PATCH 06/13] Create modular and extensible CLI structure Move all command logic into command.go command reword Readability changes to command.go using String methods Uses util array --- go.mod | 6 +- go.sum | 8 +- pkg/slack/app.go | 83 +-------------- pkg/slack/command.go | 234 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 83 deletions(-) create mode 100644 pkg/slack/command.go diff --git a/go.mod b/go.mod index 3554815..9425d6b 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,11 @@ require ( github.com/go-playground/validator/v10 v10.11.0 github.com/google/go-github/v45 v45.1.0 github.com/gorilla/mux v1.8.0 + github.com/samber/lo v1.47.0 github.com/slack-go/slack v0.11.0 go.uber.org/zap v1.21.0 golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/sync v0.7.0 golang.org/x/time v0.0.0-20220609170525-579cf78fd858 k8s.io/api v0.24.2 k8s.io/apimachinery v0.24.2 @@ -31,6 +32,7 @@ require ( github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/procfs v0.7.3 // indirect + github.com/smarty/assertions v1.15.0 // indirect github.com/spf13/afero v1.8.2 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.3.0 // indirect @@ -83,7 +85,7 @@ require ( golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 // indirect golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/text v0.16.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index b6311cd..c872250 100644 --- a/go.sum +++ b/go.sum @@ -358,6 +358,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -529,8 +531,9 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -603,8 +606,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 8756834..07130ff 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -6,11 +6,9 @@ import ( "errors" "fmt" "regexp" - "strings" "github.com/oursky/github-actions-manager/pkg/github/jobs" "github.com/oursky/github-actions-manager/pkg/kv" - "github.com/oursky/github-actions-manager/pkg/utils/array" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" "go.uber.org/zap" @@ -25,6 +23,7 @@ type App struct { api *slack.Client store kv.Store commandName string + commands *[]Command } type ChannelInfo struct { @@ -55,6 +54,7 @@ func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { ), store: store, commandName: config.GetCommandName(), + // cli: NewCLI(logger), } } @@ -183,83 +183,8 @@ func (a *App) messageLoop(ctx context.Context, client *socketmode.Client) { zap.String("text", data.Text), ) - if data.Command != "/"+a.commandName { - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Unknown command '%s'\n", data.Command)}) - continue - } - - args := strings.Split(data.Text, " ") - if len(args) < 2 { - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Please specify subcommand and repo")}) - continue - } - - repo := args[1] - subcommand := args[0] - conclusions := array.Unique(args[2:]) - if !repoRegex.MatchString(repo) { - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Invalid repo '%s'\n", repo), - }) - continue - } - - switch subcommand { - case "subscribe": - filterLayers := array.Unique(args[2:]) - filter, err := NewFilter(filterLayers) - if err != nil { - a.logger.Warn("failed to subscribe", zap.Error(err)) - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Failed to subscribe '%s': %s\n", repo, err), - }) - } - - channelInfo := ChannelInfo{ - ChannelID: data.ChannelID, - Filter: filter, - } - err = a.AddChannel(ctx, repo, channelInfo) - if err != nil { - a.logger.Warn("failed to subscribe", zap.Error(err)) - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Failed to subscribe '%s': %s\n", repo, err), - }) - } else { - if len(conclusions) > 0 { - client.Ack(*e.Request, map[string]interface{}{ - "response_type": "in_channel", - "text": fmt.Sprintf("Subscribed to '%s' with conclusions: %s\n", repo, strings.Join(conclusions, ", ")), - }) - } else { - client.Ack(*e.Request, map[string]interface{}{ - "response_type": "in_channel", - "text": fmt.Sprintf("Subscribed to '%s'\n", repo), - }) - } - } - - case "unsubscribe": - err := a.DelChannel(ctx, repo, data.ChannelID) - if err != nil { - a.logger.Warn("failed to unsubscribe", zap.Error(err)) - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Failed to unsubscribe '%s': %s\n", repo, err), - }) - } else { - client.Ack(*e.Request, map[string]interface{}{ - "response_type": "in_channel", - "text": fmt.Sprintf("Unsubscribed from '%s'\n", repo), - }) - } - - default: - client.Ack(*e.Request, map[string]interface{}{ - "text": fmt.Sprintf("Unknown subcommand '%s'\n", subcommand), - }) - } + response := a.Handle(ctx, data) + client.Ack(*e.Request, response) default: if e.Type == socketmode.EventTypeHello { diff --git a/pkg/slack/command.go b/pkg/slack/command.go new file mode 100644 index 0000000..3251e82 --- /dev/null +++ b/pkg/slack/command.go @@ -0,0 +1,234 @@ +package slack + +import ( + "context" + "fmt" + "strings" + + "github.com/oursky/github-actions-manager/pkg/utils/array" + "github.com/samber/lo" + "github.com/slack-go/slack" + "go.uber.org/zap" +) + +type Argument struct { + name string + required bool + acceptsMany bool + description string +} + +func NewArgument(name string, required bool, acceptsMany bool, description string) Argument { + return Argument{name: name, required: required, acceptsMany: acceptsMany, description: description} +} + +type CommandContext struct { + ctx context.Context + a *App + channelID string + args []string +} + +type Command struct { + trigger string + arguments []Argument + description string + execute func(CommandContext) CommandResult +} + +type CommandResult struct { + printToChannel bool + message string +} + +func NewCLIResult(printToChannel bool, message string) CommandResult { + return CommandResult{printToChannel: printToChannel, message: message} +} + +func (arg Argument) String() string { + argname := arg.name + if arg.acceptsMany { + argname += "..." + } + if arg.required { + return argname + } else { + return " [" + argname + "]" + } +} + +func (c Command) String() string { + output := fmt.Sprintf("`%s`: %s", c.trigger, c.description) + output += fmt.Sprintf("\nUsage of `%s`:", c.trigger) + output += fmt.Sprintf("`%s %s`", c.trigger, lo.Reduce(c.arguments, + func(o string, x Argument, _ int) string { + return fmt.Sprintf("%s %s", o, x.String()) + }, "")) + for _, arg := range c.arguments { + output += fmt.Sprintf("\n\t`%s`: %s", arg.name, arg.description) + } + + return output +} + +func (a *App) Execute(ctx context.Context, channelID string, subcommand string, args []string) CommandResult { + for _, command := range *a.commands { + if subcommand != command.trigger { + continue + } + return command.execute(CommandContext{ctx: ctx, a: a, channelID: channelID, args: args}) + } + return NewCLIResult(false, fmt.Sprintf("Unknown command: %s", subcommand)) +} + +func (a *App) Handle(ctx context.Context, data slack.SlashCommand) map[string]interface{} { + if data.Command != "/"+a.commandName { + return map[string]interface{}{"text": fmt.Sprintf("Unknown command '%s'\n", data.Command)} + } + + args := strings.Split(data.Text, " ") + if len(args) < 1 { + return map[string]interface{}{"text": fmt.Sprintf("Please specify subcommand")} + } + + result := a.Execute(ctx, data.ChannelID, args[0], args[1:]) + response := map[string]interface{}{"text": result.message} + if result.printToChannel { + response["response_type"] = "in_channel" + } + return response +} + +func GetCommands() *[]Command { + commands := &[]Command{} + commands = &[]Command{ + { + trigger: "help", + arguments: []Argument{ + NewArgument("subcommand", false, false, "The subcommand to get help about."), + }, + description: "Get help about a command.", + execute: func(env CommandContext) CommandResult { + output := "" + commands := (*commands) + if len(env.args) == 0 { + output = "The known commands are:" + for _, command := range commands { + output += fmt.Sprintf(" `%s`", command.trigger) + } + return NewCLIResult(false, output) + } + subcommand := env.args[0] + for _, command := range commands { + if command.trigger != subcommand { + continue + } + return NewCLIResult(false, command.String()) + } + return NewCLIResult(false, fmt.Sprintf("No such command: %s", subcommand)) + }, + }, + { + trigger: "list", + arguments: []Argument{ + NewArgument("repo", true, false, "The repo to get the subscription data for."), + }, + description: "List the channels subscribed to a given repo.", + execute: func(env CommandContext) CommandResult { + if len(env.args) < 1 { + return NewCLIResult(false, "Please specify repo") + } + + repo := env.args[0] + if !repoRegex.MatchString(repo) { + return NewCLIResult(false, fmt.Sprintf("Invalid repo *%s*\n", repo)) + } + + channels, err := env.a.GetChannels(env.ctx, repo) + if err != nil { + env.a.logger.Warn("failed to list channels", zap.Error(err)) + return NewCLIResult(false, fmt.Sprintf("Failed to get list of subscribed channels: '%s'", err)) + } else { + if len(channels) == 0 { + return NewCLIResult(true, fmt.Sprintf("*%s* is sending updates to no channels", repo)) + } + channelStrings := lo.Map(channels, func(x ChannelInfo, _ int) string { return x.String() }) + return NewCLIResult(true, fmt.Sprintf("*%s* is sending updates to: %s\n", repo, strings.Join(channelStrings, "; "))) + } + }, + }, + { + trigger: "subscribe", + arguments: []Argument{ + NewArgument("repo", true, false, "The repo to subscribe to."), + NewArgument("filters", false, true, "In the format of filter_key:value1,value2,...:conclusion1,conclusion2,..., one of the supported filter keys (workflows, branches)"), + }, + description: "Subscribe this channel to a given repo.", + execute: func(env CommandContext) CommandResult { + if len(env.args) < 1 { + return NewCLIResult(false, fmt.Sprintf("Please specify repo")) + } + + repo := env.args[0] + if !repoRegex.MatchString(repo) { + return NewCLIResult(false, fmt.Sprintf("Invalid repo *%s*\n", repo)) + } + + filterLayers := array.Unique(env.args[1:]) + filter, err := NewFilter(filterLayers) + if err != nil { + env.a.logger.Warn("failed to subscribe", zap.Error(err)) + return NewCLIResult(false, fmt.Sprintf("Failed to subscribe to *%s*: '%s'\n", repo, err)) + } + + channelInfo := ChannelInfo{ + ChannelID: env.channelID, + Filter: filter, + } + err = env.a.AddChannel(env.ctx, repo, channelInfo) + if err != nil { + env.a.logger.Warn("failed to subscribe", zap.Error(err)) + return NewCLIResult(false, fmt.Sprintf("Failed to subscribe to *%s*: '%s'\n", repo, err)) + } + if len(filterLayers) > 0 { + return NewCLIResult(true, fmt.Sprintf("Subscribed to *%s* with filter layers %s", repo, filter.Whitelists)) + } else { + return NewCLIResult(true, fmt.Sprintf("Subscribed to *%s*\n", repo)) + } + }, + }, + { + trigger: "unsubscribe", + arguments: []Argument{ + NewArgument("repo", true, false, "The repo to unsubscribe from."), + }, + description: "Unsubscribe this channel from a given repo.", + execute: func(env CommandContext) CommandResult { + if len(env.args) < 1 { + return NewCLIResult(false, fmt.Sprintf("Please specify repo")) + } + + repo := env.args[0] + if !repoRegex.MatchString(repo) { + return NewCLIResult(false, fmt.Sprintf("Invalid repo *%s*\n", repo)) + } + + err := env.a.DelChannel(env.ctx, repo, env.channelID) + if err != nil { + env.a.logger.Warn("failed to unsubscribe", zap.Error(err)) + return NewCLIResult(false, fmt.Sprintf("Failed to unsubscribe from *%s*: '%s'\n", repo, err)) + } else { + return NewCLIResult(true, fmt.Sprintf("Unsubscribed from *%s*\n", repo)) + } + }, + }, + { + trigger: "meow", + description: "Meow.", + execute: func(env CommandContext) CommandResult { + return NewCLIResult(false, "meow") + }, + }, + } + return commands +} From 8c95a3853c83cc7b8ecfe04c89f4f5b4c51956b5 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Thu, 15 Aug 2024 17:43:33 +0800 Subject: [PATCH 07/13] Add tests for command interpreter Update go.mod and go.sum to add goconvey --- go.mod | 7 +- go.sum | 12 ++- pkg/slack/command_test.go | 160 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 pkg/slack/command_test.go diff --git a/go.mod b/go.mod index 9425d6b..5afd6e1 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/samber/lo v1.47.0 github.com/slack-go/slack v0.11.0 + github.com/smartystreets/goconvey v1.8.1 go.uber.org/zap v1.21.0 golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 golang.org/x/sync v0.7.0 @@ -23,7 +24,9 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -83,7 +86,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/sys v0.6.0 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.16.0 // indirect google.golang.org/appengine v1.6.7 // indirect @@ -93,7 +96,7 @@ require ( gopkg.in/yaml.v3 v3.0.0 // indirect k8s.io/klog/v2 v2.60.1 // indirect k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 // indirect - k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect + k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect sigs.k8s.io/yaml v1.2.0 // indirect diff --git a/go.sum b/go.sum index c872250..a413a79 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= @@ -259,6 +261,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -367,6 +371,10 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/slack-go/slack v0.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k= github.com/slack-go/slack v0.11.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= @@ -593,8 +601,8 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/pkg/slack/command_test.go b/pkg/slack/command_test.go new file mode 100644 index 0000000..4be28f7 --- /dev/null +++ b/pkg/slack/command_test.go @@ -0,0 +1,160 @@ +package slack + +import ( + "context" + "testing" + + "github.com/oursky/github-actions-manager/pkg/kv" + "github.com/slack-go/slack" + . "github.com/smartystreets/goconvey/convey" + + "go.uber.org/zap" +) + +func TestSpec(t *testing.T) { + testCommand := "test-gha" + + NewTestSlackChannel := func(channelID string) (string, func(string) slack.SlashCommand) { + return channelID, func(command string) slack.SlashCommand { + return slack.SlashCommand{ + ChannelID: channelID, + Command: "/" + testCommand, + Text: command, + } + } + } + channelID1, commandFromChannel1 := NewTestSlackChannel("TestChannelID1") + channelID2, commandFromChannel2 := NewTestSlackChannel("TestChannelID2") + + Convey("When receiving commands, the bot", t, func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + testApp := &App{ + logger: zap.NewNop(), + store: kv.NewInMemoryStore(), + commandName: testCommand, + commands: GetCommands(), + } + + Convey("responds", func() { + response := testApp.Handle(ctx, commandFromChannel1("meow")) + So(response["text"], ShouldEqual, "meow") + }) + Convey("rejects unrecognised commmands", func() { + response := testApp.Handle(ctx, commandFromChannel1("fhqwhgads")) + So(response["response_type"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "fhqwhgads") + }) + Convey("When asked to subscribe", func() { + Convey("rejects an insufficient number of arguments", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "repo") + }) + Convey("rejects an unrecognised conclusion", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo foo")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "conclusion") + }) + Convey("rejects a malformed filter", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo foo:bar")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "filter") + + response = testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo foo:bar:success")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "filter") + }) + Convey("rejects a duplicated filter", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo success failure")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "duplicated") + + response = testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo workflows:bar:success workflows:bar:failure")) + So(response["printToChannel"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "duplicated") + }) + Convey("accepts a well-formed filter", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo workflows:workflow1:success failure")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, "Subscribed") + So(response["text"], ShouldContainSubstring, "workflow1") + }) + Convey("overrides an existing subscription", func() { + response := testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo workflows:workflow2:success failure")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, "Subscribed") + So(response["text"], ShouldContainSubstring, "workflow2") + + // Anachronistic usage of list command, this needs to be fixed + response = testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldNotContainSubstring, "workflow1") + }) + }) + Convey("When asked to list", func() { + Convey("rejects an insufficient number of arguments", func() { + response := testApp.Handle(ctx, commandFromChannel1("list")) + So(response["response_type"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "repo") + }) + Convey("correct lists subscribed channels", func() { + testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo")) + testApp.Handle(ctx, commandFromChannel2("subscribe owner/repo workflows:workflow1 failure")) + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, channelID1) + So(response["text"], ShouldContainSubstring, channelID2) + So(response["text"], ShouldContainSubstring, "workflow1") + So(response["text"], ShouldContainSubstring, "failure") + }) + Convey("responds correctly if no channels are subscribed", func() { + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, " no") + }) + }) + Convey("When asked to unsubscribe", func() { + Convey("rejects an insufficient number of arguments", func() { + response := testApp.Handle(ctx, commandFromChannel1("unsubscribe")) + So(response["response_type"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "repo") + }) + Convey("notifies if the channel is not subscribed to the repo", func() { + response := testApp.Handle(ctx, commandFromChannel1("unsubscribe owner/repo")) + So(response["response_type"], ShouldBeNil) + So(response["text"], ShouldContainSubstring, "subscribed") + }) + Convey("correctly unsubscribes from a channel", func() { + testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo")) + testApp.Handle(ctx, commandFromChannel1("unsubscribe owner/repo")) + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, " no") + }) + Convey("correctly unsubscribes from only the requested channel", func() { + testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo")) + testApp.Handle(ctx, commandFromChannel2("subscribe owner/repo workflows:workflow1 failure")) + testApp.Handle(ctx, commandFromChannel1("unsubscribe owner/repo")) + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, channelID2) + So(response["text"], ShouldContainSubstring, "workflow1") + So(response["text"], ShouldContainSubstring, "failure") + }) + Convey("is able to resubscribe", func() { + testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo")) + testApp.Handle(ctx, commandFromChannel1("unsubscribe owner/repo")) + testApp.Handle(ctx, commandFromChannel1("subscribe owner/repo")) + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, channelID1) + }) + }) + }) + Convey("When receiving webhooks, the bot", t, func() { + Convey("has no tests at the moment", func() { + }) + }) +} From f1ab935f8a232d5a1d231b3d4ef90b4fa4d7a150 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Mon, 19 Aug 2024 16:12:31 +0800 Subject: [PATCH 08/13] Extract []string to MessageFilterRule conversion --- pkg/slack/app.go | 1 + pkg/slack/command.go | 10 +++--- pkg/slack/filter.go | 86 ++++++++++++++++++++++++++++---------------- 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 07130ff..15a5de9 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -72,6 +72,7 @@ func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, erro var channelInfos []ChannelInfo err = json.Unmarshal([]byte(data), &channelInfos) + if err != nil { return nil, err } diff --git a/pkg/slack/command.go b/pkg/slack/command.go index 3251e82..1859396 100644 --- a/pkg/slack/command.go +++ b/pkg/slack/command.go @@ -174,8 +174,8 @@ func GetCommands() *[]Command { return NewCLIResult(false, fmt.Sprintf("Invalid repo *%s*\n", repo)) } - filterLayers := array.Unique(env.args[1:]) - filter, err := NewFilter(filterLayers) + filterRuleStrings := array.Unique(env.args[1:]) + filter, err := ParseAsFilter(filterRuleStrings) if err != nil { env.a.logger.Warn("failed to subscribe", zap.Error(err)) return NewCLIResult(false, fmt.Sprintf("Failed to subscribe to *%s*: '%s'\n", repo, err)) @@ -183,15 +183,15 @@ func GetCommands() *[]Command { channelInfo := ChannelInfo{ ChannelID: env.channelID, - Filter: filter, + Filter: *filter, } err = env.a.AddChannel(env.ctx, repo, channelInfo) if err != nil { env.a.logger.Warn("failed to subscribe", zap.Error(err)) return NewCLIResult(false, fmt.Sprintf("Failed to subscribe to *%s*: '%s'\n", repo, err)) } - if len(filterLayers) > 0 { - return NewCLIResult(true, fmt.Sprintf("Subscribed to *%s* with filter layers %s", repo, filter.Whitelists)) + if len(filterRuleStrings) > 0 { + return NewCLIResult(true, fmt.Sprintf("Subscribed to *%s* with filter rules %s", repo, filter.Whitelists)) } else { return NewCLIResult(true, fmt.Sprintf("Subscribed to *%s*\n", repo)) } diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go index 0ea3e19..931036d 100644 --- a/pkg/slack/filter.go +++ b/pkg/slack/filter.go @@ -64,7 +64,7 @@ func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { return false } -func (rule *MessageFilterRule) SetConclusions(conclusions []string) error { +func ParseConclusions(conclusions []string) ([]string, error) { // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} var unsupportedConclusions []string @@ -75,67 +75,91 @@ func (rule *MessageFilterRule) SetConclusions(conclusions []string) error { } if len(unsupportedConclusions) > 0 { - return fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) + return nil, fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) } - rule.Conclusions = conclusions - return nil + return conclusions, nil } -func NewFilter(filterLayers []string) (*MessageFilter, error) { - filter := MessageFilter{ - Whitelists: []MessageFilterRule{}, +func NewFilterRule(key string, values []string, conclusions []string) (*MessageFilterRule, error) { + mfr := &MessageFilterRule{} + switch key { + case "conclusions": + case "workflows": + mfr.Workflows = values + case "branches": + mfr.Branches = values + default: + return nil, fmt.Errorf("unsupported filter type: %s", key) } + conclusions, err := ParseConclusions(conclusions) + if err != nil { + return nil, err + } + + mfr.Conclusions = conclusions + return mfr, nil +} + +func NewFilter(whitelists []MessageFilterRule) MessageFilter { + return MessageFilter{ + Whitelists: whitelists, + } +} +func ParseAsFilter(filterRuleStrings []string) (*MessageFilter, error) { + whitelists := []MessageFilterRule{} used := []string{} - for _, layer := range filterLayers { - definition := strings.Split(layer, ":") + for _, ruleString := range filterRuleStrings { + definition := strings.Split(ruleString, ":") switch len(definition) { case 1: // Assumed format "conclusion1,conclusion2,..." - rule := MessageFilterRule{} if slices.Contains(used, "none") { return nil, fmt.Errorf("duplicated conclusion strings; use commas to separate conclusions") } - conclusions := strings.Split(definition[0], ",") - err := rule.SetConclusions(conclusions) + conclusions := strings.Split(definition[0], ",") + rule, err := NewFilterRule("conclusions", []string{}, conclusions) if err != nil { return nil, err } used = append(used, "none") - filter.Whitelists = append(filter.Whitelists, rule) - case 2, 3: // Assumed format "filterKey:filterValue1,filterValue2,..." - rule := MessageFilterRule{} + whitelists = append(whitelists, *rule) + case 2: // Assumed format "filterKey:filterValue1,filterValue2,..." filterType := definition[0] if slices.Contains(used, filterType) { return nil, fmt.Errorf("duplicated filter type: %s", filterType) } - switch filterType { - case "branches": - branches := strings.Split(definition[1], ",") - rule.Branches = branches - case "workflows": - workflows := strings.Split(definition[1], ",") - rule.Workflows = workflows - default: - return nil, fmt.Errorf("unsupported filter type: %s", filterType) + + values := strings.Split(definition[1], ",") + rule, err := NewFilterRule(filterType, values, []string{}) + if err != nil { + return nil, err } - if len(definition) == 3 { - conclusions := strings.Split(definition[2], ",") + used = append(used, filterType) + whitelists = append(whitelists, *rule) + case 3: + filterType := definition[0] + if slices.Contains(used, filterType) { + return nil, fmt.Errorf("duplicated filter type: %s", filterType) + } - err := rule.SetConclusions(conclusions) - if err != nil { - return nil, err - } + values := strings.Split(definition[1], ",") + conclusions := strings.Split(definition[2], ",") + rule, err := NewFilterRule(filterType, values, conclusions) + if err != nil { + return nil, err } used = append(used, filterType) - filter.Whitelists = append(filter.Whitelists, rule) + whitelists = append(whitelists, *rule) } } + filter := NewFilter(whitelists) + return &filter, nil } From e6880af91e1ac75af4934adc7abb52e43ea3d812 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Mon, 19 Aug 2024 16:14:01 +0800 Subject: [PATCH 09/13] Handle conversion of existing data format --- pkg/slack/app.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 15a5de9..340918c 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "regexp" + "strings" "github.com/oursky/github-actions-manager/pkg/github/jobs" "github.com/oursky/github-actions-manager/pkg/kv" @@ -27,8 +28,8 @@ type App struct { } type ChannelInfo struct { - ChannelID string `json:"channelID"` - Filter *MessageFilter `json:"filter"` + ChannelID string `json:"channelID"` + Filter MessageFilter `json:"filter"` } func (f ChannelInfo) String() string { @@ -74,7 +75,31 @@ func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, erro err = json.Unmarshal([]byte(data), &channelInfos) if err != nil { - return nil, err + // Maybe it's using the old format? Handle this case + channelInfoStrings := strings.Split(data, ";") + var channelInfos []ChannelInfo + + for _, channelString := range channelInfoStrings { + channelID, conclusionsString, _ := strings.Cut(channelString, ":") + var conclusions []string + for _, conclusion := range strings.Split(conclusionsString, ",") { + if len(conclusion) > 0 { + conclusions = append(conclusions, conclusion) + } + } + conclusionRule, err := NewFilterRule("conclusions", []string{}, conclusions) + if err != nil { + return nil, err + } + + filter := NewFilter([]MessageFilterRule{*conclusionRule}) + channelInfos = append(channelInfos, ChannelInfo{ + ChannelID: channelID, + Filter: filter, + }) + } + + return channelInfos, nil } return channelInfos, nil } From 1d089568c230d8e7918f2e1c5f94186be1a2494c Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Mon, 19 Aug 2024 16:36:49 +0800 Subject: [PATCH 10/13] Add tests for subscription updater --- pkg/slack/command_test.go | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pkg/slack/command_test.go b/pkg/slack/command_test.go index 4be28f7..2a9f601 100644 --- a/pkg/slack/command_test.go +++ b/pkg/slack/command_test.go @@ -2,6 +2,7 @@ package slack import ( "context" + "fmt" "testing" "github.com/oursky/github-actions-manager/pkg/kv" @@ -157,4 +158,44 @@ func TestSpec(t *testing.T) { Convey("has no tests at the moment", func() { }) }) + Convey("When reading the previous (deprecated) format, the bot", t, func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + testStore := kv.NewInMemoryStore() + nsSlackSubscriptions := "slack-subscriptions" + testApp := &App{ + logger: zap.NewNop(), + store: testStore, + commandName: testCommand, + commands: GetCommands(), + } + + Convey("correctly converts from filterless", func() { + testStore.Set(ctx, kv.Namespace(nsSlackSubscriptions), "owner/repo", channelID1) + + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, channelID1) + }) + Convey("correctly converts from filtered", func() { + testStore.Set(ctx, kv.Namespace(nsSlackSubscriptions), "owner/repo", fmt.Sprintf("%s:success,failure", channelID1)) + + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, "success") + So(response["text"], ShouldContainSubstring, "failure") + So(response["text"], ShouldContainSubstring, channelID1) + }) + Convey("correctly converts from fusion", func() { + testStore.Set(ctx, kv.Namespace(nsSlackSubscriptions), "owner/repo", fmt.Sprintf("%s;%s:success,failure", channelID1, channelID2)) + + response := testApp.Handle(ctx, commandFromChannel1("list owner/repo")) + So(response["response_type"], ShouldEqual, "in_channel") + So(response["text"], ShouldContainSubstring, "success") + So(response["text"], ShouldContainSubstring, "failure") + So(response["text"], ShouldContainSubstring, channelID1) + So(response["text"], ShouldContainSubstring, channelID2) + }) + }) } From edc82931b51211a6146ccc3a5179efa5c47620de Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Tue, 20 Aug 2024 12:10:12 +0800 Subject: [PATCH 11/13] whoops, I forgot to activate the commands --- pkg/slack/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 340918c..3dfedbc 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -55,7 +55,7 @@ func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { ), store: store, commandName: config.GetCommandName(), - // cli: NewCLI(logger), + commands: GetCommands(), } } From c755960e9644241f99aad990fdaa13e4743ea3a0 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Tue, 20 Aug 2024 12:17:56 +0800 Subject: [PATCH 12/13] Fix spacing in String conversion of commands --- pkg/slack/command.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/slack/command.go b/pkg/slack/command.go index 1859396..ef4952a 100644 --- a/pkg/slack/command.go +++ b/pkg/slack/command.go @@ -53,17 +53,17 @@ func (arg Argument) String() string { if arg.required { return argname } else { - return " [" + argname + "]" + return "[" + argname + "]" } } func (c Command) String() string { output := fmt.Sprintf("`%s`: %s", c.trigger, c.description) output += fmt.Sprintf("\nUsage of `%s`:", c.trigger) - output += fmt.Sprintf("`%s %s`", c.trigger, lo.Reduce(c.arguments, + output += fmt.Sprintf("`%s`", lo.Reduce(c.arguments, func(o string, x Argument, _ int) string { return fmt.Sprintf("%s %s", o, x.String()) - }, "")) + }, c.trigger)) for _, arg := range c.arguments { output += fmt.Sprintf("\n\t`%s`: %s", arg.name, arg.description) } From 045d63dbce5c9abbe48bcbf580698e28324d8fa6 Mon Sep 17 00:00:00 2001 From: Zachary Tsang Date: Tue, 20 Aug 2024 16:10:59 +0800 Subject: [PATCH 13/13] Use enum for conclusions --- pkg/slack/app.go | 21 +++++++--- pkg/slack/filter.go | 95 +++++++++++++++++++++++++++++++++---------- pkg/slack/notifier.go | 13 +++++- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/pkg/slack/app.go b/pkg/slack/app.go index 3dfedbc..82bc47f 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -39,8 +39,15 @@ func (f ChannelInfo) String() string { return fmt.Sprintf("%s with %s", f.ChannelID, f.Filter) } -func (f ChannelInfo) ShouldSend(run *jobs.WorkflowRun) bool { - return f.Filter.Length() == 0 || f.Filter.Any(run) +func (f ChannelInfo) ShouldSend(run *jobs.WorkflowRun) (bool, error) { + if f.Filter.Length() == 0 { + return true, nil + } + result, err := f.Filter.Any(run) + if err != nil { + return false, err + } + return result, nil } func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { @@ -81,9 +88,13 @@ func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, erro for _, channelString := range channelInfoStrings { channelID, conclusionsString, _ := strings.Cut(channelString, ":") - var conclusions []string - for _, conclusion := range strings.Split(conclusionsString, ",") { - if len(conclusion) > 0 { + var conclusions []Conclusion + for _, conclusionString := range strings.Split(conclusionsString, ",") { + if len(conclusionString) > 0 { + conclusion, err := NewConclusionFromString(conclusionString) + if err != nil { + return nil, err + } conclusions = append(conclusions, conclusion) } } diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go index 931036d..6f6c007 100644 --- a/pkg/slack/filter.go +++ b/pkg/slack/filter.go @@ -9,10 +9,23 @@ import ( "k8s.io/utils/strings/slices" ) +type Conclusion string + +const ( + ConclusionActionRequired Conclusion = "action_required" + ConclusionCancelled Conclusion = "cancelled" + ConclusionFailure Conclusion = "failure" + ConclusionNeutral Conclusion = "neutral" + ConclusionSuccess Conclusion = "success" + ConclusionSkipped Conclusion = "skipped" + ConclusionStale Conclusion = "stale" + ConclusionTimedOut Conclusion = "timed_out" +) + type MessageFilterRule struct { - Conclusions []string `json:"conclusions"` - Branches []string `json:"branches"` - Workflows []string `json:"workflows"` + Conclusions []Conclusion `json:"conclusions"` + Branches []string `json:"branches"` + Workflows []string `json:"workflows"` } type MessageFilter struct { @@ -42,8 +55,8 @@ func (mf MessageFilter) Length() int { return len(mf.Whitelists) } -func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { - if len(rule.Conclusions) > 0 && !slices.Contains(rule.Conclusions, run.Conclusion) { +func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun, conclusion Conclusion) bool { + if len(rule.Conclusions) > 0 && !lo.Contains(rule.Conclusions, conclusion) { return false } if len(rule.Branches) > 0 && !slices.Contains(rule.Branches, run.Branch) { @@ -55,23 +68,53 @@ func (rule MessageFilterRule) Pass(run *jobs.WorkflowRun) bool { return true } -func (mf MessageFilter) Any(run *jobs.WorkflowRun) bool { +func (mf MessageFilter) Any(run *jobs.WorkflowRun) (bool, error) { + conclusion, err := NewConclusionFromString(run.Conclusion) + if err != nil { + return false, fmt.Errorf("Workflow run yielded invalid conclusion: %s", conclusion) + } for _, rule := range mf.Whitelists { - if rule.Pass(run) { - return true + if rule.Pass(run, conclusion) { + return true, nil } } - return false + return false, nil +} + +func NewConclusionFromString(str string) (Conclusion, error) { + switch str { + case "action_required": + return ConclusionActionRequired, nil + case "cancelled": + return ConclusionCancelled, nil + case "failure": + return ConclusionFailure, nil + case "neutral": + return ConclusionNeutral, nil + case "success": + return ConclusionSuccess, nil + case "skipped": + return ConclusionSkipped, nil + case "stale": + return ConclusionStale, nil + case "timed_out": + return ConclusionTimedOut, nil + default: + return "", fmt.Errorf("unknown conclusion: %s", str) + } } -func ParseConclusions(conclusions []string) ([]string, error) { +func ParseConclusions(conclusionStrings []string) ([]Conclusion, error) { // Ref: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run--parameters - conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} + // conclusionsEnum := []string{"action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"} + var conclusions []Conclusion var unsupportedConclusions []string - for _, c := range conclusions { - if !slices.Contains(conclusionsEnum, c) { - unsupportedConclusions = append(unsupportedConclusions, c) + for _, c := range conclusionStrings { + conclusion, err := NewConclusionFromString(c) + if err != nil { + return nil, err } + conclusions = append(conclusions, conclusion) } if len(unsupportedConclusions) > 0 { @@ -81,7 +124,7 @@ func ParseConclusions(conclusions []string) ([]string, error) { return conclusions, nil } -func NewFilterRule(key string, values []string, conclusions []string) (*MessageFilterRule, error) { +func NewFilterRule(key string, values []string, conclusions []Conclusion) (*MessageFilterRule, error) { mfr := &MessageFilterRule{} switch key { case "conclusions": @@ -92,10 +135,6 @@ func NewFilterRule(key string, values []string, conclusions []string) (*MessageF default: return nil, fmt.Errorf("unsupported filter type: %s", key) } - conclusions, err := ParseConclusions(conclusions) - if err != nil { - return nil, err - } mfr.Conclusions = conclusions return mfr, nil @@ -119,7 +158,13 @@ func ParseAsFilter(filterRuleStrings []string) (*MessageFilter, error) { return nil, fmt.Errorf("duplicated conclusion strings; use commas to separate conclusions") } - conclusions := strings.Split(definition[0], ",") + conclusionStrings := strings.Split(definition[0], ",") + + conclusions, err := ParseConclusions(conclusionStrings) + if err != nil { + return nil, err + } + rule, err := NewFilterRule("conclusions", []string{}, conclusions) if err != nil { return nil, err @@ -134,7 +179,7 @@ func ParseAsFilter(filterRuleStrings []string) (*MessageFilter, error) { } values := strings.Split(definition[1], ",") - rule, err := NewFilterRule(filterType, values, []string{}) + rule, err := NewFilterRule(filterType, values, []Conclusion{}) if err != nil { return nil, err } @@ -148,7 +193,13 @@ func ParseAsFilter(filterRuleStrings []string) (*MessageFilter, error) { } values := strings.Split(definition[1], ",") - conclusions := strings.Split(definition[2], ",") + conclusionStrings := strings.Split(definition[2], ",") + + conclusions, err := ParseConclusions(conclusionStrings) + if err != nil { + return nil, err + } + rule, err := NewFilterRule(filterType, values, conclusions) if err != nil { return nil, err diff --git a/pkg/slack/notifier.go b/pkg/slack/notifier.go index fc9d2fa..b697e36 100644 --- a/pkg/slack/notifier.go +++ b/pkg/slack/notifier.go @@ -158,10 +158,19 @@ func (n *Notifier) notify(ctx context.Context, run *jobs.WorkflowRun) { } for _, channel := range channels { - if channel.ShouldSend(run) { + send, err := channel.ShouldSend(run) + if err != nil { + n.logger.Warn("failed to send message", + zap.Error(err), + zap.String("channelID", channel.ChannelID), + ) + return + } + if !send { return } - err := n.app.SendMessage(ctx, channel.ChannelID, slack.MsgOptionAttachments(slackMsg)) + + err = n.app.SendMessage(ctx, channel.ChannelID, slack.MsgOptionAttachments(slackMsg)) if err != nil { n.logger.Warn("failed to send message", zap.Error(err),