diff --git a/cmd/server.go b/cmd/server.go index 694688eb..5f41c4d6 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -84,7 +84,7 @@ var stringFlags = []stringFlag{ env: "ATLANTIS_GITLAB_TOKEN", }, { - name: GitlabWebHookSecret, + name: GitlabWebHookSecret, description: "Optional secret used to validate GitLab webhooks." + " If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitLab. " + "Can also be specified via the ATLANTIS_GITLAB_WEBHOOK_SECRET environment variable.", diff --git a/server/events/apply_executor.go b/server/events/apply_executor.go index 867b2df6..e89fec1c 100644 --- a/server/events/apply_executor.go +++ b/server/events/apply_executor.go @@ -12,6 +12,7 @@ import ( "github.com/hootsuite/atlantis/server/events/run" "github.com/hootsuite/atlantis/server/events/terraform" "github.com/hootsuite/atlantis/server/events/vcs" + "github.com/hootsuite/atlantis/server/events/webhooks" ) type ApplyExecutor struct { @@ -21,6 +22,7 @@ type ApplyExecutor struct { Run *run.Run Workspace Workspace ProjectPreExecute *ProjectPreExecute + Webhooks webhooks.Sender } func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse { @@ -92,6 +94,15 @@ func (a *ApplyExecutor) apply(ctx *CommandContext, repoDir string, plan models.P env := ctx.Command.Environment tfApplyCmd := append(append(append([]string{"apply", "-no-color"}, applyExtraArgs...), ctx.Command.Flags...), plan.LocalPath) output, err := a.Terraform.RunCommandWithVersion(ctx.Log, absolutePath, tfApplyCmd, terraformVersion, env) + + a.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck + Workspace: env, + User: ctx.User, + Repo: ctx.BaseRepo, + Pull: ctx.Pull, + Success: err == nil, + }) + if err != nil { return ProjectResult{Error: fmt.Errorf("%s\n%s", err.Error(), output)} } diff --git a/server/events/command_handler_test.go b/server/events/command_handler_test.go index ccf6fd23..83823d38 100644 --- a/server/events/command_handler_test.go +++ b/server/events/command_handler_test.go @@ -57,7 +57,7 @@ func setup(t *testing.T) { MarkdownRenderer: &events.MarkdownRenderer{}, GithubPullGetter: githubGetter, GitlabMergeRequestGetter: gitlabGetter, - Logger: logger, + Logger: logger, } } diff --git a/server/events/mocks/mock_commit_status_updater.go b/server/events/mocks/mock_commit_status_updater.go index 91b4205f..d0177f22 100644 --- a/server/events/mocks/mock_commit_status_updater.go +++ b/server/events/mocks/mock_commit_status_updater.go @@ -4,11 +4,12 @@ package mocks import ( + "reflect" + events "github.com/hootsuite/atlantis/server/events" models "github.com/hootsuite/atlantis/server/events/models" vcs "github.com/hootsuite/atlantis/server/events/vcs" pegomock "github.com/petergtz/pegomock" - "reflect" ) type MockCommitStatusUpdater struct { diff --git a/server/events/pull_closed_executor_test.go b/server/events/pull_closed_executor_test.go index d16df315..ceaab893 100644 --- a/server/events/pull_closed_executor_test.go +++ b/server/events/pull_closed_executor_test.go @@ -1,19 +1,19 @@ package events_test import ( + "errors" "reflect" + "testing" + "github.com/hootsuite/atlantis/server/events" + lockmocks "github.com/hootsuite/atlantis/server/events/locking/mocks" + "github.com/hootsuite/atlantis/server/events/mocks" "github.com/hootsuite/atlantis/server/events/models" + "github.com/hootsuite/atlantis/server/events/models/fixtures" + "github.com/hootsuite/atlantis/server/events/vcs" + vcsmocks "github.com/hootsuite/atlantis/server/events/vcs/mocks" . "github.com/hootsuite/atlantis/testing" . "github.com/petergtz/pegomock" - "testing" - "github.com/hootsuite/atlantis/server/events/mocks" - lockmocks "github.com/hootsuite/atlantis/server/events/locking/mocks" - vcsmocks "github.com/hootsuite/atlantis/server/events/vcs/mocks" - "github.com/hootsuite/atlantis/server/events" - "errors" - "github.com/hootsuite/atlantis/server/events/vcs" - "github.com/hootsuite/atlantis/server/events/models/fixtures" ) func TestCleanUpPullWorkspaceErr(t *testing.T) { @@ -155,4 +155,3 @@ func AnyPullRequest() models.PullRequest { RegisterMatcher(NewAnyMatcher(reflect.TypeOf(models.PullRequest{}))) return models.PullRequest{} } - diff --git a/server/events/webhooks/mocks/mock_sender.go b/server/events/webhooks/mocks/mock_sender.go new file mode 100644 index 00000000..2270d9f2 --- /dev/null +++ b/server/events/webhooks/mocks/mock_sender.go @@ -0,0 +1,81 @@ +// Automatically generated by pegomock. DO NOT EDIT! +// Source: github.com/hootsuite/atlantis/server/events/webhooks (interfaces: Sender) + +package mocks + +import ( + "reflect" + + webhooks "github.com/hootsuite/atlantis/server/events/webhooks" + logging "github.com/hootsuite/atlantis/server/logging" + pegomock "github.com/petergtz/pegomock" +) + +type MockSender struct { + fail func(message string, callerSkip ...int) +} + +func NewMockSender() *MockSender { + return &MockSender{fail: pegomock.GlobalFailHandler} +} + +func (mock *MockSender) Send(log *logging.SimpleLogger, applyResult webhooks.ApplyResult) error { + params := []pegomock.Param{log, applyResult} + result := pegomock.GetGenericMockFrom(mock).Invoke("Send", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockSender) VerifyWasCalledOnce() *VerifierSender { + return &VerifierSender{mock, pegomock.Times(1), nil} +} + +func (mock *MockSender) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierSender { + return &VerifierSender{mock, invocationCountMatcher, nil} +} + +func (mock *MockSender) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierSender { + return &VerifierSender{mock, invocationCountMatcher, inOrderContext} +} + +type VerifierSender struct { + mock *MockSender + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext +} + +func (verifier *VerifierSender) Send(log *logging.SimpleLogger, applyResult webhooks.ApplyResult) *Sender_Send_OngoingVerification { + params := []pegomock.Param{log, applyResult} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", params) + return &Sender_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type Sender_Send_OngoingVerification struct { + mock *MockSender + methodInvocations []pegomock.MethodInvocation +} + +func (c *Sender_Send_OngoingVerification) GetCapturedArguments() (*logging.SimpleLogger, webhooks.ApplyResult) { + log, applyResult := c.GetAllCapturedArguments() + return log[len(log)-1], applyResult[len(applyResult)-1] +} + +func (c *Sender_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []*logging.SimpleLogger, _param1 []webhooks.ApplyResult) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*logging.SimpleLogger, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*logging.SimpleLogger) + } + _param1 = make([]webhooks.ApplyResult, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.(webhooks.ApplyResult) + } + } + return +} diff --git a/server/events/webhooks/mocks/mock_slack_client.go b/server/events/webhooks/mocks/mock_slack_client.go new file mode 100644 index 00000000..50659089 --- /dev/null +++ b/server/events/webhooks/mocks/mock_slack_client.go @@ -0,0 +1,181 @@ +// Automatically generated by pegomock. DO NOT EDIT! +// Source: github.com/hootsuite/atlantis/server/events/webhooks (interfaces: SlackClient) + +package mocks + +import ( + "reflect" + + webhooks "github.com/hootsuite/atlantis/server/events/webhooks" + pegomock "github.com/petergtz/pegomock" +) + +type MockSlackClient struct { + fail func(message string, callerSkip ...int) +} + +func NewMockSlackClient() *MockSlackClient { + return &MockSlackClient{fail: pegomock.GlobalFailHandler} +} + +func (mock *MockSlackClient) AuthTest() error { + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("AuthTest", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockSlackClient) TokenIsSet() bool { + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("TokenIsSet", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + } + return ret0 +} + +func (mock *MockSlackClient) ChannelExists(channelName string) (bool, error) { + params := []pegomock.Param{channelName} + result := pegomock.GetGenericMockFrom(mock).Invoke("ChannelExists", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 bool + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockSlackClient) PostMessage(channel string, applyResult webhooks.ApplyResult) error { + params := []pegomock.Param{channel, applyResult} + result := pegomock.GetGenericMockFrom(mock).Invoke("PostMessage", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockSlackClient) VerifyWasCalledOnce() *VerifierSlackClient { + return &VerifierSlackClient{mock, pegomock.Times(1), nil} +} + +func (mock *MockSlackClient) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierSlackClient { + return &VerifierSlackClient{mock, invocationCountMatcher, nil} +} + +func (mock *MockSlackClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierSlackClient { + return &VerifierSlackClient{mock, invocationCountMatcher, inOrderContext} +} + +type VerifierSlackClient struct { + mock *MockSlackClient + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext +} + +func (verifier *VerifierSlackClient) AuthTest() *SlackClient_AuthTest_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "AuthTest", params) + return &SlackClient_AuthTest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type SlackClient_AuthTest_OngoingVerification struct { + mock *MockSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *SlackClient_AuthTest_OngoingVerification) GetCapturedArguments() { +} + +func (c *SlackClient_AuthTest_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierSlackClient) TokenIsSet() *SlackClient_TokenIsSet_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TokenIsSet", params) + return &SlackClient_TokenIsSet_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type SlackClient_TokenIsSet_OngoingVerification struct { + mock *MockSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *SlackClient_TokenIsSet_OngoingVerification) GetCapturedArguments() { +} + +func (c *SlackClient_TokenIsSet_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierSlackClient) ChannelExists(channelName string) *SlackClient_ChannelExists_OngoingVerification { + params := []pegomock.Param{channelName} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ChannelExists", params) + return &SlackClient_ChannelExists_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type SlackClient_ChannelExists_OngoingVerification struct { + mock *MockSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *SlackClient_ChannelExists_OngoingVerification) GetCapturedArguments() string { + channelName := c.GetAllCapturedArguments() + return channelName[len(channelName)-1] +} + +func (c *SlackClient_ChannelExists_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierSlackClient) PostMessage(channel string, applyResult webhooks.ApplyResult) *SlackClient_PostMessage_OngoingVerification { + params := []pegomock.Param{channel, applyResult} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PostMessage", params) + return &SlackClient_PostMessage_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type SlackClient_PostMessage_OngoingVerification struct { + mock *MockSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *SlackClient_PostMessage_OngoingVerification) GetCapturedArguments() (string, webhooks.ApplyResult) { + channel, applyResult := c.GetAllCapturedArguments() + return channel[len(channel)-1], applyResult[len(applyResult)-1] +} + +func (c *SlackClient_PostMessage_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []webhooks.ApplyResult) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([]webhooks.ApplyResult, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.(webhooks.ApplyResult) + } + } + return +} diff --git a/server/events/webhooks/mocks/mock_underlying_slack_client.go b/server/events/webhooks/mocks/mock_underlying_slack_client.go new file mode 100644 index 00000000..16d07ef6 --- /dev/null +++ b/server/events/webhooks/mocks/mock_underlying_slack_client.go @@ -0,0 +1,168 @@ +// Automatically generated by pegomock. DO NOT EDIT! +// Source: github.com/hootsuite/atlantis/server/events/webhooks (interfaces: UnderlyingSlackClient) + +package mocks + +import ( + "reflect" + + slack "github.com/nlopes/slack" + pegomock "github.com/petergtz/pegomock" +) + +type MockUnderlyingSlackClient struct { + fail func(message string, callerSkip ...int) +} + +func NewMockUnderlyingSlackClient() *MockUnderlyingSlackClient { + return &MockUnderlyingSlackClient{fail: pegomock.GlobalFailHandler} +} + +func (mock *MockUnderlyingSlackClient) AuthTest() (*slack.AuthTestResponse, error) { + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("AuthTest", params, []reflect.Type{reflect.TypeOf((**slack.AuthTestResponse)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 *slack.AuthTestResponse + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(*slack.AuthTestResponse) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockUnderlyingSlackClient) GetChannels(excludeArchived bool) ([]slack.Channel, error) { + params := []pegomock.Param{excludeArchived} + result := pegomock.GetGenericMockFrom(mock).Invoke("GetChannels", params, []reflect.Type{reflect.TypeOf((*[]slack.Channel)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 []slack.Channel + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]slack.Channel) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockUnderlyingSlackClient) PostMessage(channel string, text string, parameters slack.PostMessageParameters) (string, string, error) { + params := []pegomock.Param{channel, text, parameters} + result := pegomock.GetGenericMockFrom(mock).Invoke("PostMessage", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 string + var ret1 string + var ret2 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(string) + } + if result[2] != nil { + ret2 = result[2].(error) + } + } + return ret0, ret1, ret2 +} + +func (mock *MockUnderlyingSlackClient) VerifyWasCalledOnce() *VerifierUnderlyingSlackClient { + return &VerifierUnderlyingSlackClient{mock, pegomock.Times(1), nil} +} + +func (mock *MockUnderlyingSlackClient) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierUnderlyingSlackClient { + return &VerifierUnderlyingSlackClient{mock, invocationCountMatcher, nil} +} + +func (mock *MockUnderlyingSlackClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierUnderlyingSlackClient { + return &VerifierUnderlyingSlackClient{mock, invocationCountMatcher, inOrderContext} +} + +type VerifierUnderlyingSlackClient struct { + mock *MockUnderlyingSlackClient + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext +} + +func (verifier *VerifierUnderlyingSlackClient) AuthTest() *UnderlyingSlackClient_AuthTest_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "AuthTest", params) + return &UnderlyingSlackClient_AuthTest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type UnderlyingSlackClient_AuthTest_OngoingVerification struct { + mock *MockUnderlyingSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *UnderlyingSlackClient_AuthTest_OngoingVerification) GetCapturedArguments() { +} + +func (c *UnderlyingSlackClient_AuthTest_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierUnderlyingSlackClient) GetChannels(excludeArchived bool) *UnderlyingSlackClient_GetChannels_OngoingVerification { + params := []pegomock.Param{excludeArchived} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetChannels", params) + return &UnderlyingSlackClient_GetChannels_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type UnderlyingSlackClient_GetChannels_OngoingVerification struct { + mock *MockUnderlyingSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *UnderlyingSlackClient_GetChannels_OngoingVerification) GetCapturedArguments() bool { + excludeArchived := c.GetAllCapturedArguments() + return excludeArchived[len(excludeArchived)-1] +} + +func (c *UnderlyingSlackClient_GetChannels_OngoingVerification) GetAllCapturedArguments() (_param0 []bool) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]bool, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(bool) + } + } + return +} + +func (verifier *VerifierUnderlyingSlackClient) PostMessage(channel string, text string, parameters slack.PostMessageParameters) *UnderlyingSlackClient_PostMessage_OngoingVerification { + params := []pegomock.Param{channel, text, parameters} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PostMessage", params) + return &UnderlyingSlackClient_PostMessage_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type UnderlyingSlackClient_PostMessage_OngoingVerification struct { + mock *MockUnderlyingSlackClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *UnderlyingSlackClient_PostMessage_OngoingVerification) GetCapturedArguments() (string, string, slack.PostMessageParameters) { + channel, text, parameters := c.GetAllCapturedArguments() + return channel[len(channel)-1], text[len(text)-1], parameters[len(parameters)-1] +} + +func (c *UnderlyingSlackClient_PostMessage_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []slack.PostMessageParameters) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([]string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.(string) + } + _param2 = make([]slack.PostMessageParameters, len(params[2])) + for u, param := range params[2] { + _param2[u] = param.(slack.PostMessageParameters) + } + } + return +} diff --git a/server/events/webhooks/slack.go b/server/events/webhooks/slack.go new file mode 100644 index 00000000..990c939c --- /dev/null +++ b/server/events/webhooks/slack.go @@ -0,0 +1,45 @@ +package webhooks + +import ( + "regexp" + + "fmt" + + "github.com/hootsuite/atlantis/server/logging" + "github.com/pkg/errors" +) + +// SlackWebhook sends webhooks to Slack. +type SlackWebhook struct { + Client SlackClient + WorkspaceRegex *regexp.Regexp + Channel string +} + +func NewSlack(r *regexp.Regexp, channel string, client SlackClient) (*SlackWebhook, error) { + if err := client.AuthTest(); err != nil { + return nil, fmt.Errorf("testing slack authentication: %s. Verify your slack-token is valid", err) + } + + channelExists, err := client.ChannelExists(channel) + if err != nil { + return nil, err + } + if !channelExists { + return nil, errors.Errorf("slack channel %q doesn't exist", channel) + } + + return &SlackWebhook{ + Client: client, + WorkspaceRegex: r, + Channel: channel, + }, nil +} + +// Send sends the webhook to Slack if the workspace matches the regex. +func (s *SlackWebhook) Send(log *logging.SimpleLogger, applyResult ApplyResult) error { + if !s.WorkspaceRegex.MatchString(applyResult.Workspace) { + return nil + } + return s.Client.PostMessage(s.Channel, applyResult) +} diff --git a/server/events/webhooks/slack_client.go b/server/events/webhooks/slack_client.go new file mode 100644 index 00000000..73fa4ad5 --- /dev/null +++ b/server/events/webhooks/slack_client.go @@ -0,0 +1,106 @@ +package webhooks + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +const ( + slackSuccessColour = "good" + slackFailureColour = "danger" +) + +//go:generate pegomock generate --use-experimental-model-gen --package mocks -o mocks/mock_slack_client.go SlackClient + +// SlackClient handles making API calls to Slack. +type SlackClient interface { + AuthTest() error + TokenIsSet() bool + ChannelExists(channelName string) (bool, error) + PostMessage(channel string, applyResult ApplyResult) error +} + +//go:generate pegomock generate --use-experimental-model-gen --package mocks -o mocks/mock_underlying_slack_client.go UnderlyingSlackClient + +// UnderlyingSlackClient wraps the nlopes/slack.Client implementation so +// we can mock it during tests. +type UnderlyingSlackClient interface { + AuthTest() (response *slack.AuthTestResponse, error error) + GetChannels(excludeArchived bool) ([]slack.Channel, error) + PostMessage(channel, text string, parameters slack.PostMessageParameters) (string, string, error) +} + +type DefaultSlackClient struct { + Slack UnderlyingSlackClient + Token string +} + +func NewSlackClient(token string) SlackClient { + return &DefaultSlackClient{ + Slack: slack.New(token), + Token: token, + } +} + +func (d *DefaultSlackClient) AuthTest() error { + _, err := d.Slack.AuthTest() + return err +} + +func (d *DefaultSlackClient) TokenIsSet() bool { + return d.Token != "" +} + +func (d *DefaultSlackClient) ChannelExists(channelName string) (bool, error) { + channels, err := d.Slack.GetChannels(true) + if err != nil { + return false, err + } + for _, channel := range channels { + if channel.Name == channelName { + return true, nil + } + } + return false, nil +} + +func (d *DefaultSlackClient) PostMessage(channel string, applyResult ApplyResult) error { + params := slack.NewPostMessageParameters() + params.Attachments = d.createAttachments(applyResult) + params.AsUser = true + params.EscapeText = false + _, _, err := d.Slack.PostMessage(channel, "", params) + return err +} + +func (d *DefaultSlackClient) createAttachments(applyResult ApplyResult) []slack.Attachment { + var colour string + var successWord string + if applyResult.Success { + colour = slackSuccessColour + successWord = "succeeded" + } else { + colour = slackFailureColour + successWord = "failed" + } + + text := fmt.Sprintf("Apply %s for <%s|%s>", successWord, applyResult.Pull.URL, applyResult.Repo.FullName) + attachment := slack.Attachment{ + Color: colour, + Text: text, + Fields: []slack.AttachmentField{ + { + Title: "Workspace", + Value: applyResult.Workspace, + Short: true, + }, + { + Title: "User", + Value: applyResult.User.Username, + Short: true, + }, + }, + } + return []slack.Attachment{attachment} +} diff --git a/server/events/webhooks/slack_client_test.go b/server/events/webhooks/slack_client_test.go new file mode 100644 index 00000000..40814a32 --- /dev/null +++ b/server/events/webhooks/slack_client_test.go @@ -0,0 +1,171 @@ +package webhooks_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/hootsuite/atlantis/server/events/models" + "github.com/hootsuite/atlantis/server/events/webhooks" + "github.com/hootsuite/atlantis/server/events/webhooks/mocks" + "github.com/nlopes/slack" + + . "github.com/hootsuite/atlantis/testing" + . "github.com/petergtz/pegomock" +) + +var underlying *mocks.MockUnderlyingSlackClient +var client webhooks.DefaultSlackClient +var result webhooks.ApplyResult + +func TestAuthTest_Success(t *testing.T) { + t.Log("When the underylying client suceeds, function should succeed") + setup(t) + err := client.AuthTest() + Ok(t, err) +} + +func TestAuthTest_Error(t *testing.T) { + t.Log("When the underylying slack client errors, an error should be returned") + setup(t) + When(underlying.AuthTest()).ThenReturn(nil, errors.New("")) + err := client.AuthTest() + Assert(t, err != nil, "expected error") +} + +func TestTokenIsSet(t *testing.T) { + t.Log("When the Token is an empty string, function should return false") + c := webhooks.DefaultSlackClient{ + Token: "", + } + Equals(t, false, c.TokenIsSet()) + + t.Log("When the Token is not an empty string, function should return true") + c.Token = "random" + Equals(t, true, c.TokenIsSet()) +} + +func TestChannelExists_False(t *testing.T) { + t.Log("When the slack channel doesn't exist, function should return false") + setup(t) + When(underlying.GetChannels(true)).ThenReturn(nil, nil) + exists, err := client.ChannelExists("somechannel") + Ok(t, err) + Equals(t, false, exists) +} + +func TestChannelExists_True(t *testing.T) { + t.Log("When the slack channel exists, function should return true") + setup(t) + channelJSON := `{"name":"existingchannel"}` + var channel slack.Channel + err := json.Unmarshal([]byte(channelJSON), &channel) + Ok(t, err) + When(underlying.GetChannels(true)).ThenReturn([]slack.Channel{channel}, nil) + + exists, err := client.ChannelExists("existingchannel") + Ok(t, err) + Equals(t, true, exists) +} + +func TestChannelExists_Error(t *testing.T) { + t.Log("When the underylying slack client errors, an error should be returned") + setup(t) + When(underlying.GetChannels(true)).ThenReturn(nil, errors.New("")) + + _, err := client.ChannelExists("anychannel") + Assert(t, err != nil, "expected error") +} + +func TestPostMessage_Success(t *testing.T) { + t.Log("When apply succeds, function should succeed and indicate success") + setup(t) + + expParams := slack.NewPostMessageParameters() + expParams.Attachments = []slack.Attachment{{ + Color: "good", + Text: "Apply succeeded for ", + Fields: []slack.AttachmentField{ + { + Title: "Workspace", + Value: result.Workspace, + Short: true, + }, + { + Title: "User", + Value: result.User.Username, + Short: true, + }, + }, + }} + expParams.AsUser = true + expParams.EscapeText = false + + channel := "somechannel" + err := client.PostMessage(channel, result) + Ok(t, err) + underlying.VerifyWasCalledOnce().PostMessage(channel, "", expParams) + + t.Log("When apply fails, function should succeed and indicate failure") + result.Success = false + expParams.Attachments[0].Color = "danger" + expParams.Attachments[0].Text = "Apply failed for " + + err = client.PostMessage(channel, result) + Ok(t, err) + underlying.VerifyWasCalledOnce().PostMessage(channel, "", expParams) +} + +func TestPostMessage_Error(t *testing.T) { + t.Log("When the underylying slack client errors, an error should be returned") + setup(t) + + expParams := slack.NewPostMessageParameters() + expParams.Attachments = []slack.Attachment{{ + Color: "good", + Text: "Apply succeeded for ", + Fields: []slack.AttachmentField{ + { + Title: "Workspace", + Value: result.Workspace, + Short: true, + }, + { + Title: "User", + Value: result.User.Username, + Short: true, + }, + }, + }} + expParams.AsUser = true + expParams.EscapeText = false + + channel := "somechannel" + When(underlying.PostMessage(channel, "", expParams)).ThenReturn("", "", errors.New("")) + + err := client.PostMessage(channel, result) + Assert(t, err != nil, "expected error") +} + +func setup(t *testing.T) { + RegisterMockTestingT(t) + underlying = mocks.NewMockUnderlyingSlackClient() + client = webhooks.DefaultSlackClient{ + Slack: underlying, + Token: "sometoken", + } + result = webhooks.ApplyResult{ + Workspace: "production", + Repo: models.Repo{ + FullName: "hootsuite/atlantis", + }, + Pull: models.PullRequest{ + Num: 1, + URL: "url", + }, + User: models.User{ + Username: "lkysow", + }, + Success: true, + } +} diff --git a/server/events/webhooks/slack_test.go b/server/events/webhooks/slack_test.go new file mode 100644 index 00000000..4f2f25e8 --- /dev/null +++ b/server/events/webhooks/slack_test.go @@ -0,0 +1,55 @@ +package webhooks_test + +import ( + "regexp" + "testing" + + "github.com/hootsuite/atlantis/server/events/webhooks" + "github.com/hootsuite/atlantis/server/events/webhooks/mocks" + "github.com/hootsuite/atlantis/server/logging" + . "github.com/hootsuite/atlantis/testing" + . "github.com/petergtz/pegomock" +) + +func TestSend_PostMessage(t *testing.T) { + t.Log("Sending a hook with a matching regex should call PostMessage") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + regex, err := regexp.Compile(".*") + Ok(t, err) + + channel := "somechannel" + hook := webhooks.SlackWebhook{ + Client: client, + WorkspaceRegex: regex, + Channel: channel, + } + result := webhooks.ApplyResult{ + Workspace: "production", + } + + t.Log("PostMessage should be called, doesn't matter if it errors or not") + _ = hook.Send(logging.NewNoopLogger(), result) + client.VerifyWasCalledOnce().PostMessage(channel, result) +} + +func TestSend_NoopSuccess(t *testing.T) { + t.Log("Sending a hook with a non-matching regex should succeed") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + regex, err := regexp.Compile("weirdemv") + Ok(t, err) + + channel := "somechannel" + hook := webhooks.SlackWebhook{ + Client: client, + WorkspaceRegex: regex, + Channel: channel, + } + result := webhooks.ApplyResult{ + Workspace: "production", + } + err = hook.Send(logging.NewNoopLogger(), result) + Ok(t, err) + client.VerifyWasCalled(Never()).PostMessage(channel, result) +} diff --git a/server/events/webhooks/webhooks.go b/server/events/webhooks/webhooks.go new file mode 100644 index 00000000..d85d0921 --- /dev/null +++ b/server/events/webhooks/webhooks.go @@ -0,0 +1,89 @@ +package webhooks + +import ( + "fmt" + "regexp" + + "errors" + + "github.com/hootsuite/atlantis/server/events/models" + "github.com/hootsuite/atlantis/server/logging" +) + +const SlackKind = "slack" +const ApplyEvent = "apply" + +//go:generate pegomock generate --use-experimental-model-gen --package mocks -o mocks/mock_sender.go Sender + +// Sender sends webhooks. +type Sender interface { + // Send sends the webhook (if the implementation thinks it should). + Send(log *logging.SimpleLogger, applyResult ApplyResult) error +} + +// ApplyResult is the result of a terraform apply. +type ApplyResult struct { + Workspace string + Repo models.Repo + Pull models.PullRequest + User models.User + Success bool +} + +// MultiWebhookSender sends multiple webhooks for each one it's configured for. +type MultiWebhookSender struct { + Webhooks []Sender +} + +type Config struct { + Event string + WorkspaceRegex string + Kind string + Channel string +} + +func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookSender, error) { + var webhooks []Sender + for _, c := range configs { + r, err := regexp.Compile(c.WorkspaceRegex) + if err != nil { + return nil, err + } + if c.Kind == "" || c.Event == "" { + return nil, errors.New("must specify \"kind\" and \"event\" keys for webhooks") + } + if c.Event != ApplyEvent { + return nil, fmt.Errorf("\"event: %s\" not supported. Only \"event: %s\" is supported right now", c.Event, ApplyEvent) + } + switch c.Kind { + case SlackKind: + if !client.TokenIsSet() { + return nil, errors.New("must specify top-level \"slack-token\" if using a webhook of \"kind: slack\"") + } + if c.Channel == "" { + return nil, errors.New("must specify \"channel\" if using a webhook of \"kind: slack\"") + } + slack, err := NewSlack(r, c.Channel, client) + if err != nil { + return nil, err + } + webhooks = append(webhooks, slack) + default: + return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" is supported right now", c.Kind, SlackKind) + } + } + + return &MultiWebhookSender{ + Webhooks: webhooks, + }, nil +} + +// Send sends the webhook using its Webhooks. +func (w *MultiWebhookSender) Send(log *logging.SimpleLogger, result ApplyResult) error { + for _, w := range w.Webhooks { + if err := w.Send(log, result); err != nil { + log.Warn("error sending slack webhook: %s", err) + } + } + return nil +} diff --git a/server/events/webhooks/webhooks_test.go b/server/events/webhooks/webhooks_test.go new file mode 100644 index 00000000..d8c233cb --- /dev/null +++ b/server/events/webhooks/webhooks_test.go @@ -0,0 +1,175 @@ +package webhooks_test + +import ( + "strings" + "testing" + + "github.com/hootsuite/atlantis/server/events/webhooks" + "github.com/hootsuite/atlantis/server/events/webhooks/mocks" + "github.com/hootsuite/atlantis/server/logging" + . "github.com/hootsuite/atlantis/testing" + . "github.com/petergtz/pegomock" +) + +const ( + validEvent = webhooks.ApplyEvent + validRegex = ".*" + validKind = webhooks.SlackKind + validChannel = "validchannel" +) + +var validConfig = webhooks.Config{ + Event: validEvent, + WorkspaceRegex: validRegex, + Kind: validKind, + Channel: validChannel, +} + +func validConfigs() []webhooks.Config { + return []webhooks.Config{validConfig} +} + +func TestNewWebhooksManager_InvalidRegex(t *testing.T) { + t.Log("When given an invalid regex in a config, an error is returned") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + When(client.ChannelExists(validChannel)).ThenReturn(true, nil) + + invalidRegex := "(" + configs := validConfigs() + configs[0].WorkspaceRegex = invalidRegex + _, err := webhooks.NewMultiWebhookSender(configs, client) + Assert(t, err != nil, "expected error") + Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") +} + +func TestNewWebhooksManager_NoEvent(t *testing.T) { + t.Log("When the event key is not specified in a config, an error is returned") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + configs := validConfigs() + configs[0].Event = "" + _, err := webhooks.NewMultiWebhookSender(configs, client) + Assert(t, err != nil, "expected error") + Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) +} + +func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) { + t.Log("When given an unsupported event in a config, an error is returned") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + When(client.ChannelExists(validChannel)).ThenReturn(true, nil) + + unsupportedEvent := "badevent" + configs := validConfigs() + configs[0].Event = unsupportedEvent + _, err := webhooks.NewMultiWebhookSender(configs, client) + Assert(t, err != nil, "expected error") + Equals(t, "\"event: badevent\" not supported. Only \"event: apply\" is supported right now", err.Error()) +} + +func TestNewWebhooksManager_NoKind(t *testing.T) { + t.Log("When the kind key is not specified in a config, an error is returned") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + configs := validConfigs() + configs[0].Kind = "" + _, err := webhooks.NewMultiWebhookSender(configs, client) + Assert(t, err != nil, "expected error") + Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) +} + +func TestNewWebhooksManager_UnsupportedKind(t *testing.T) { + t.Log("When given an unsupported kind in a config, an error is returned") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + When(client.ChannelExists(validChannel)).ThenReturn(true, nil) + + unsupportedKind := "badkind" + configs := validConfigs() + configs[0].Kind = unsupportedKind + _, err := webhooks.NewMultiWebhookSender(configs, client) + Assert(t, err != nil, "expected error") + Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" is supported right now", err.Error()) +} + +func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) { + t.Log("When there are no configs, function should succeed") + t.Log("passing any client should succeed") + var emptyConfigs []webhooks.Config + emptyToken := "" + m, err := webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.NewSlackClient(emptyToken)) + Ok(t, err) + Assert(t, m != nil, "manager shouldn't be nil") + Equals(t, 0, len(m.Webhooks)) + + t.Log("passing nil client hould succeed") + m, err = webhooks.NewMultiWebhookSender(emptyConfigs, nil) + Ok(t, err) + Assert(t, m != nil, "manager shouldn't be nil") + Equals(t, 0, len(m.Webhooks)) +} +func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) { + t.Log("When there is one valid config, function should succeed") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + When(client.TokenIsSet()).ThenReturn(true) + When(client.ChannelExists(validChannel)).ThenReturn(true, nil) + + configs := validConfigs() + m, err := webhooks.NewMultiWebhookSender(configs, client) + Ok(t, err) + Assert(t, m != nil, "manager shouldn't be nil") + Equals(t, 1, len(m.Webhooks)) +} + +func TestNewWebhooksManager_MultipleConfigSuccess(t *testing.T) { + t.Log("When there are multiple valid configs, function should succeed") + RegisterMockTestingT(t) + client := mocks.NewMockSlackClient() + When(client.TokenIsSet()).ThenReturn(true) + When(client.ChannelExists(validChannel)).ThenReturn(true, nil) + + var configs []webhooks.Config + nConfigs := 5 + for i := 0; i < nConfigs; i++ { + configs = append(configs, validConfig) + } + m, err := webhooks.NewMultiWebhookSender(configs, client) + Ok(t, err) + Assert(t, m != nil, "manager shouldn't be nil") + Equals(t, nConfigs, len(m.Webhooks)) +} + +func TestSend_SingleSuccess(t *testing.T) { + t.Log("Sending one webhook should succeed") + RegisterMockTestingT(t) + sender := mocks.NewMockSender() + manager := webhooks.MultiWebhookSender{ + Webhooks: []webhooks.Sender{sender}, + } + logger := logging.NewNoopLogger() + result := webhooks.ApplyResult{} + manager.Send(logger, result) // nolint: errcheck + sender.VerifyWasCalledOnce().Send(logger, result) +} + +func TestSend_MultipleSuccess(t *testing.T) { + t.Log("Sending multiple webhooks should succeed") + RegisterMockTestingT(t) + senders := []*mocks.MockSender{ + mocks.NewMockSender(), + mocks.NewMockSender(), + mocks.NewMockSender(), + } + manager := webhooks.MultiWebhookSender{ + Webhooks: []webhooks.Sender{senders[0], senders[1], senders[2]}, + } + logger := logging.NewNoopLogger() + result := webhooks.ApplyResult{} + err := manager.Send(logger, result) + Ok(t, err) + for _, s := range senders { + s.VerifyWasCalledOnce().Send(logger, result) + } +} diff --git a/server/gitlab_request_parser_test.go b/server/gitlab_request_parser_test.go index d3022402..6b93d7fc 100644 --- a/server/gitlab_request_parser_test.go +++ b/server/gitlab_request_parser_test.go @@ -1,13 +1,14 @@ package server_test import ( + "bytes" + "net/http" "testing" + "github.com/hootsuite/atlantis/server" - "bytes" . "github.com/hootsuite/atlantis/testing" - . "github.com/petergtz/pegomock" - "net/http" "github.com/lkysow/go-gitlab" + . "github.com/petergtz/pegomock" ) var parser = server.DefaultGitlabRequestParser{} @@ -20,7 +21,7 @@ func TestValidate_InvalidSecret(t *testing.T) { Ok(t, err) req.Header.Set("X-Gitlab-Token", "does-not-match") _, err = parser.Validate(req, []byte("secret")) - Assert(t, err != nil , "should be an error") + Assert(t, err != nil, "should be an error") Equals(t, "header X-Gitlab-Token=does-not-match did not match expected secret", err.Error()) } @@ -58,7 +59,7 @@ func TestValidate_InvalidMergeEvent(t *testing.T) { Ok(t, err) req.Header.Set("X-Gitlab-Event", "Merge Request Hook") _, err = parser.Validate(req, nil) - Assert(t, err != nil , "should be an error") + Assert(t, err != nil, "should be an error") Equals(t, "unexpected end of JSON input", err.Error()) } @@ -70,7 +71,7 @@ func TestValidate_InvalidMergeCommentEvent(t *testing.T) { Ok(t, err) req.Header.Set("X-Gitlab-Event", "Note Hook") _, err = parser.Validate(req, nil) - Assert(t, err != nil , "should be an error") + Assert(t, err != nil, "should be an error") Equals(t, "unexpected end of JSON input", err.Error()) } diff --git a/server/mocks/mock_gitlab_request_parser.go b/server/mocks/mock_gitlab_request_parser.go index 2f6e1143..1517792f 100644 --- a/server/mocks/mock_gitlab_request_parser.go +++ b/server/mocks/mock_gitlab_request_parser.go @@ -4,9 +4,10 @@ package mocks import ( - pegomock "github.com/petergtz/pegomock" http "net/http" "reflect" + + pegomock "github.com/petergtz/pegomock" ) type MockGitlabRequestParser struct { diff --git a/server/server.go b/server/server.go index aa1e7642..de2a54b1 100644 --- a/server/server.go +++ b/server/server.go @@ -10,6 +10,8 @@ import ( "os" "strings" + "flag" + "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" "github.com/hootsuite/atlantis/server/events" @@ -18,13 +20,13 @@ import ( "github.com/hootsuite/atlantis/server/events/run" "github.com/hootsuite/atlantis/server/events/terraform" "github.com/hootsuite/atlantis/server/events/vcs" + "github.com/hootsuite/atlantis/server/events/webhooks" "github.com/hootsuite/atlantis/server/logging" "github.com/hootsuite/atlantis/server/static" "github.com/lkysow/go-gitlab" "github.com/pkg/errors" "github.com/urfave/cli" "github.com/urfave/negroni" - "flag" ) const LockRouteName = "lock-detail" @@ -47,19 +49,29 @@ type Server struct { // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type Config struct { - AtlantisURL string `mapstructure:"atlantis-url"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebHookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RequireApproval bool `mapstructure:"require-approval"` + AtlantisURL string `mapstructure:"atlantis-url"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebHookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + RequireApproval bool `mapstructure:"require-approval"` + SlackToken string `mapstructure:"slack-token"` + Webhooks []WebhookConfig `mapstructure:"webhooks"` +} + +type WebhookConfig struct { + Event string `mapstructure:"event"` + WorkspaceRegex string `mapstructure:"workspace-regex"` + Kind string `mapstructure:"kind"` + // Slack specific + Channel string `mapstructure:"channel"` } func NewServer(config Config) (*Server, error) { @@ -80,6 +92,20 @@ func NewServer(config Config) (*Server, error) { Client: gitlab.NewClient(nil, config.GitlabToken), } } + var webhooksConfig []webhooks.Config + for _, c := range config.Webhooks { + config := webhooks.Config{ + Channel: c.Channel, + Event: c.Event, + Kind: c.Kind, + WorkspaceRegex: c.WorkspaceRegex, + } + webhooksConfig = append(webhooksConfig, config) + } + webhooksManager, err := webhooks.NewMultiWebhookSender(webhooksConfig, webhooks.NewSlackClient(config.SlackToken)) + if err != nil { + return nil, errors.Wrap(err, "initializing webhooks") + } vcsClient := vcs.NewDefaultClientProxy(githubClient, gitlabClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient} terraformClient, err := terraform.NewClient() @@ -114,6 +140,7 @@ func NewServer(config Config) (*Server, error) { Run: run, Workspace: workspace, ProjectPreExecute: projectPreExecute, + Webhooks: webhooksManager, } planExecutor := &events.PlanExecutor{ VCSClient: vcsClient,