diff --git a/go.mod b/go.mod index 3554815..5afd6e1 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,12 @@ 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 + 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.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 @@ -22,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 @@ -31,6 +35,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 @@ -81,9 +86,9 @@ 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.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 @@ -91,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 b6311cd..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= @@ -358,6 +362,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= @@ -365,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= @@ -529,8 +539,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= @@ -590,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= @@ -603,8 +614,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/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/app.go b/pkg/slack/app.go index 1308c25..82bc47f 100644 --- a/pkg/slack/app.go +++ b/pkg/slack/app.go @@ -2,18 +2,18 @@ package slack import ( "context" + "encoding/json" "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" "golang.org/x/sync/errgroup" - "k8s.io/utils/strings/slices" ) var repoRegex = regexp.MustCompile("[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)?") @@ -24,11 +24,30 @@ type App struct { api *slack.Client store kv.Store commandName string + commands *[]Command } type ChannelInfo struct { - channelID string - conclusions []string + ChannelID string `json:"channelID"` + Filter MessageFilter `json:"filter"` +} + +func (f ChannelInfo) String() string { + if f.Filter.Length() == 0 { + return f.ChannelID + } + return fmt.Sprintf("%s with %s", f.ChannelID, f.Filter) +} + +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 { @@ -43,6 +62,7 @@ func NewApp(logger *zap.Logger, config *Config, store kv.Store) *App { ), store: store, commandName: config.GetCommandName(), + commands: GetCommands(), } } @@ -50,15 +70,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 +77,41 @@ func (a *App) GetChannels(ctx context.Context, repo string) ([]ChannelInfo, erro } else if data == "" { return nil, nil } - channelInfoStrings := strings.Split(data, ";") + var channelInfos []ChannelInfo + err = json.Unmarshal([]byte(data), &channelInfos) - 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) + if err != nil { + // 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 []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) + } } + conclusionRule, err := NewFilterRule("conclusions", []string{}, conclusions) + if err != nil { + return nil, err + } + + filter := NewFilter([]MessageFilterRule{*conclusionRule}) + channelInfos = append(channelInfos, ChannelInfo{ + ChannelID: channelID, + Filter: filter, + }) } - channelInfos = append(channelInfos, ChannelInfo{ - channelID: channelID, - conclusions: conclusions, - }) - } + return channelInfos, nil + } return channelInfos, nil } @@ -92,31 +121,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 +145,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 { @@ -196,74 +220,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": - channelInfo := ChannelInfo{ - channelID: data.ChannelID, - conclusions: conclusions, - } - 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..ef4952a --- /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`", 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) + } + + 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)) + } + + 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)) + } + + 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(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)) + } + }, + }, + { + 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 +} diff --git a/pkg/slack/command_test.go b/pkg/slack/command_test.go new file mode 100644 index 0000000..2a9f601 --- /dev/null +++ b/pkg/slack/command_test.go @@ -0,0 +1,201 @@ +package slack + +import ( + "context" + "fmt" + "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() { + }) + }) + 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) + }) + }) +} diff --git a/pkg/slack/filter.go b/pkg/slack/filter.go new file mode 100644 index 0000000..6f6c007 --- /dev/null +++ b/pkg/slack/filter.go @@ -0,0 +1,216 @@ +package slack + +import ( + "fmt" + "strings" + + "github.com/oursky/github-actions-manager/pkg/github/jobs" + "github.com/samber/lo" + "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 []Conclusion `json:"conclusions"` + Branches []string `json:"branches"` + Workflows []string `json:"workflows"` +} + +type MessageFilter struct { + Whitelists []MessageFilterRule `json:"filters"` + // 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, 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) { + 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, 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, conclusion) { + return true, nil + } + } + 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(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"} + var conclusions []Conclusion + var unsupportedConclusions []string + for _, c := range conclusionStrings { + conclusion, err := NewConclusionFromString(c) + if err != nil { + return nil, err + } + conclusions = append(conclusions, conclusion) + } + + if len(unsupportedConclusions) > 0 { + return nil, fmt.Errorf("unsupported conclusions: %s", strings.Join(unsupportedConclusions, ", ")) + } + + return conclusions, nil +} + +func NewFilterRule(key string, values []string, conclusions []Conclusion) (*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) + } + + 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 _, ruleString := range filterRuleStrings { + definition := strings.Split(ruleString, ":") + + switch len(definition) { + case 1: // Assumed format "conclusion1,conclusion2,..." + if slices.Contains(used, "none") { + return nil, fmt.Errorf("duplicated conclusion strings; use commas to separate conclusions") + } + + 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 + } + + used = append(used, "none") + 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) + } + + values := strings.Split(definition[1], ",") + rule, err := NewFilterRule(filterType, values, []Conclusion{}) + if err != nil { + return nil, err + } + + 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) + } + + values := strings.Split(definition[1], ",") + 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 + } + + used = append(used, filterType) + whitelists = append(whitelists, *rule) + } + } + + filter := NewFilter(whitelists) + + return &filter, nil +} diff --git a/pkg/slack/notifier.go b/pkg/slack/notifier.go index 7889429..b697e36 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,14 +158,23 @@ 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) { + send, err := channel.ShouldSend(run) + if err != nil { + n.logger.Warn("failed to send message", + zap.Error(err), + zap.String("channelID", channel.ChannelID), + ) return } - err := n.app.SendMessage(ctx, channel.channelID, slack.MsgOptionAttachments(slackMsg)) + if !send { + return + } + + 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), ) } }