diff --git a/.codecov.yml b/.codecov.yml index b52a5da..0d496e0 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -7,3 +7,4 @@ ignore: - "main.go" - "notifier/github/github.go" - "notifier/slack/slack.go" + - "notifier/typetalk/typetalk.go" diff --git a/Gopkg.lock b/Gopkg.lock index 1b23eca..8098e47 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -69,6 +69,18 @@ revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" version = "v0.0.4" +[[projects]] + digest = "1:f2506d323836b8fa307d3cdcadd5475015367b95f465a002a8a0a7d77b505b8b" + name = "github.com/nulab/go-typetalk" + packages = [ + "typetalk/internal", + "typetalk/shared", + "typetalk/v1", + ] + pruneopts = "NUT" + revision = "a2c8b0c4afa7b874f4d911a1e9199999bc15b2f4" + version = "v2.1.0" + [[projects]] digest = "1:5cf3f025cbee5951a4ee961de067c8a89fc95a5adabead774f82822efabab121" name = "github.com/pkg/errors" @@ -147,6 +159,8 @@ "github.com/lestrrat-go/slack", "github.com/lestrrat-go/slack/objects", "github.com/mattn/go-colorable", + "github.com/nulab/go-typetalk/typetalk/shared", + "github.com/nulab/go-typetalk/typetalk/v1", "github.com/urfave/cli", "golang.org/x/oauth2", "gopkg.in/yaml.v2", diff --git a/README.md b/README.md index 8c4d6f7..5907d72 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ You can do this by using this command. + + ## Installation Grab the binary from GitHub Releases (Recommended) @@ -60,7 +62,7 @@ For `plan` command, you also need to specify `plan` as the argument of tfnotify. When running tfnotify, you can specify the configuration path via `--config` option (if it's omitted, it defaults to `{.,}tfnotify.y{,a}ml`). -The example settings of GitHub, GitHub Enterprise and Slack are as follows. Incidentally, there is no need to replace TOKEN string such as `$GITHUB_TOKEN` with the actual token. Instead, it must be defined as environment variables in CI settings. +The example settings of GitHub and GitHub Enterprise, Slack, [Typetalk](https://www.typetalk.com/) are as follows. Incidentally, there is no need to replace TOKEN string such as `$GITHUB_TOKEN` with the actual token. Instead, it must be defined as environment variables in CI settings. [template](https://golang.org/pkg/text/template/) of Go can be used for `template`. The templates can be used in `tfnotify.yaml` are as follows: @@ -198,6 +200,32 @@ terraform: +
+For Typetalk + +```yaml +--- +ci: circleci +notifier: + typetalk: + token: $TYPETALK_TOKEN + topic_id: $TYPETALK_TOPIC_ID +terraform: + plan: + template: | + {{ .Message }} + {{if .Result}} + ``` + {{ .Result }} + ``` + {{end}} + ``` + {{ .Body }} + ``` +``` + +
+ ### Supported CI Currently, supported CI are here: diff --git a/config/config.go b/config/config.go index 11a257a..419a4b1 100644 --- a/config/config.go +++ b/config/config.go @@ -21,8 +21,9 @@ type Config struct { // Notifier is a notification notifier type Notifier struct { - Github GithubNotifier `yaml:"github"` - Slack SlackNotifier `yaml:"slack"` + Github GithubNotifier `yaml:"github"` + Slack SlackNotifier `yaml:"slack"` + Typetalk TypetalkNotifier `yaml:"typetalk"` } // GithubNotifier is a notifier for GitHub @@ -45,6 +46,12 @@ type SlackNotifier struct { Bot string `yaml:"bot"` } +// TypetalkNotifier is a notifier for Typetalk +type TypetalkNotifier struct { + Token string `yaml:"token"` + TopicID string `yaml:"topic_id"` +} + // Terraform represents terraform configurations type Terraform struct { Default Default `yaml:"default"` @@ -111,6 +118,11 @@ func (cfg *Config) Validation() error { return fmt.Errorf("slack channel id is missing") } } + if cfg.isDefinedTypetalk() { + if cfg.Notifier.Typetalk.TopicID == "" { + return fmt.Errorf("Typetalk topic id is missing") + } + } notifier := cfg.GetNotifierType() if notifier == "" { return fmt.Errorf("notifier is missing") @@ -128,6 +140,11 @@ func (cfg *Config) isDefinedSlack() bool { return cfg.Notifier.Slack != (SlackNotifier{}) } +func (cfg *Config) isDefinedTypetalk() bool { + // not empty + return cfg.Notifier.Typetalk != (TypetalkNotifier{}) +} + // GetNotifierType return notifier type described in Config func (cfg *Config) GetNotifierType() string { if cfg.isDefinedGithub() { @@ -136,6 +153,9 @@ func (cfg *Config) GetNotifierType() string { if cfg.isDefinedSlack() { return "slack" } + if cfg.isDefinedTypetalk() { + return "typetalk" + } return "" } diff --git a/config/config_test.go b/config/config_test.go index 7d73d39..fcd29b7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -37,6 +37,10 @@ func TestLoadFile(t *testing.T) { Channel: "", Bot: "", }, + Typetalk: TypetalkNotifier{ + Token: "", + TopicID: "", + }, }, Terraform: Terraform{ Default: Default{ @@ -73,6 +77,10 @@ func TestLoadFile(t *testing.T) { Channel: "", Bot: "", }, + Typetalk: TypetalkNotifier{ + Token: "", + TopicID: "", + }, }, Terraform: Terraform{ Default: Default{ @@ -186,6 +194,33 @@ notifier: slack: token: token channel: channel +`), + expected: "", + }, + { + contents: []byte(` +ci: circleci +notifier: + typetalk: +`), + expected: "notifier is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + typetalk: + token: token +`), + expected: "Typetalk topic id is missing", + }, + { + contents: []byte(` +ci: circleci +notifier: + typetalk: + token: token + topic_id: 12345 `), expected: "", }, @@ -221,6 +256,10 @@ func TestGetNotifierType(t *testing.T) { contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n slack:\n token: token\n"), expected: "slack", }, + { + contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n typetalk:\n token: token\n"), + expected: "typetalk", + }, } for _, testCase := range testCases { cfg, err := helperLoadConfig(testCase.contents) diff --git a/main.go b/main.go index f563198..4b2fca7 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/mercari/tfnotify/notifier" "github.com/mercari/tfnotify/notifier/github" "github.com/mercari/tfnotify/notifier/slack" + "github.com/mercari/tfnotify/notifier/typetalk" "github.com/mercari/tfnotify/terraform" "github.com/urfave/cli" @@ -100,6 +101,20 @@ func (t *tfnotify) Run() error { return err } notifier = client.Notify + case "typetalk": + client, err := typetalk.NewClient(typetalk.Config{ + Token: t.config.Notifier.Typetalk.Token, + TopicID: t.config.Notifier.Typetalk.TopicID, + Title: t.context.String("title"), + Message: t.context.String("message"), + CI: ci.URL, + Parser: t.parser, + Template: t.template, + }) + if err != nil { + return err + } + notifier = client.Notify case "": return fmt.Errorf("notifier is missing") default: diff --git a/misc/images/3.png b/misc/images/3.png new file mode 100644 index 0000000..f16ce9a Binary files /dev/null and b/misc/images/3.png differ diff --git a/notifier/typetalk/client.go b/notifier/typetalk/client.go new file mode 100644 index 0000000..283cbf2 --- /dev/null +++ b/notifier/typetalk/client.go @@ -0,0 +1,79 @@ +package typetalk + +import ( + "errors" + "os" + "strconv" + + "github.com/mercari/tfnotify/terraform" + typetalk "github.com/nulab/go-typetalk/typetalk/v1" +) + +// EnvToken is Typetalk API Token +const EnvToken = "TYPETALK_TOKEN" + +// EnvTopicID is Typetalk topic ID +const EnvTopicID = "TYPETALK_TOPIC_ID" + +// Client represents Typetalk API client. +type Client struct { + *typetalk.Client + Config Config + common service + Notify *NotifyService + API API +} + +// Config is a configuration for Typetalk Client +type Config struct { + Token string + Title string + TopicID string + Message string + CI string + Parser terraform.Parser + Template terraform.Template +} + +type service struct { + client *Client +} + +// NewClient returns Client initialized with Config +func NewClient(cfg Config) (*Client, error) { + token := os.ExpandEnv(cfg.Token) + if token == EnvToken { + token = os.Getenv(EnvToken) + } + if token == "" { + return &Client{}, errors.New("Typetalk token is missing") + } + + topicIDString := os.ExpandEnv(cfg.TopicID) + if topicIDString == EnvTopicID { + topicIDString = os.Getenv(EnvTopicID) + } + if topicIDString == "" { + return &Client{}, errors.New("Typetalk topic ID is missing") + } + + topicID, err := strconv.Atoi(topicIDString) + if err != nil { + return &Client{}, errors.New("Typetalk topic ID is not numeric value") + } + + client := typetalk.NewClient(nil) + client.SetTypetalkToken(token) + c := &Client{ + Config: cfg, + Client: client, + } + c.common.client = c + c.Notify = (*NotifyService)(&c.common) + c.API = &Typetalk{ + Client: client, + TopicID: topicID, + } + + return c, nil +} diff --git a/notifier/typetalk/client_test.go b/notifier/typetalk/client_test.go new file mode 100644 index 0000000..dced5cc --- /dev/null +++ b/notifier/typetalk/client_test.go @@ -0,0 +1,73 @@ +package typetalk + +import ( + "os" + "testing" +) + +func TestNewClient(t *testing.T) { + typetalkToken := os.Getenv(EnvToken) + defer func() { + os.Setenv(EnvToken, typetalkToken) + }() + os.Setenv(EnvToken, "") + + testCases := []struct { + config Config + envToken string + expect string + }{ + { + // specify directly + config: Config{Token: "abcdefg", TopicID: "12345"}, + envToken: "", + expect: "", + }, + { + // specify via env but not to be set env (part 1) + config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"}, + envToken: "", + expect: "Typetalk token is missing", + }, + { + // specify via env (part 1) + config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"}, + envToken: "abcdefg", + expect: "", + }, + { + // specify via env but not to be set env (part 2) + config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"}, + envToken: "", + expect: "typetalk token is missing", + }, + { + // specify via env (part 2) + config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"}, + envToken: "abcdefg", + expect: "", + }, + { + // no specification (part 1) + config: Config{TopicID: "12345"}, + envToken: "", + expect: "Typetalk token is missing", + }, + { + // no specification (part 2) + config: Config{TopicID: "12345"}, + envToken: "abcdefg", + expect: "Typetalk token is missing", + }, + } + for _, testCase := range testCases { + os.Setenv(EnvToken, testCase.envToken) + _, err := NewClient(testCase.config) + if err == nil { + continue + } + if err.Error() != testCase.expect { + t.Errorf("got %q but want %q", err.Error(), testCase.expect) + } + } +} diff --git a/notifier/typetalk/notify.go b/notifier/typetalk/notify.go new file mode 100644 index 0000000..e4663f2 --- /dev/null +++ b/notifier/typetalk/notify.go @@ -0,0 +1,44 @@ +package typetalk + +import ( + "context" + "errors" + + "github.com/mercari/tfnotify/terraform" +) + +// NotifyService handles notification process. +type NotifyService service + +// Notify posts message to Typetalk. +func (s *NotifyService) Notify(body string) (exit int, err error) { + cfg := s.client.Config + parser := s.client.Config.Parser + template := s.client.Config.Template + + if cfg.TopicID == "" { + return terraform.ExitFail, errors.New("topic id is required") + } + + result := parser.Parse(body) + if result.Error != nil { + return result.ExitCode, result.Error + } + if result.Result == "" { + return result.ExitCode, result.Error + } + + template.SetValue(terraform.CommonTemplate{ + Title: cfg.Title, + Message: cfg.Message, + Result: result.Result, + Body: body, + }) + text, err := template.Execute() + if err != nil { + return result.ExitCode, err + } + + _, _, err = s.client.API.ChatPostMessage(context.Background(), text) + return result.ExitCode, err +} diff --git a/notifier/typetalk/notify_test.go b/notifier/typetalk/notify_test.go new file mode 100644 index 0000000..20bba6f --- /dev/null +++ b/notifier/typetalk/notify_test.go @@ -0,0 +1,64 @@ +package typetalk + +import ( + "context" + "testing" + + "github.com/mercari/tfnotify/terraform" + typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" + typetalk "github.com/nulab/go-typetalk/typetalk/v1" +) + +func TestNotify(t *testing.T) { + testCases := []struct { + config Config + body string + exitCode int + ok bool + }{ + { + config: Config{ + Token: "token", + TopicID: "12345", + Message: "", + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "Plan: 1 to add", + exitCode: 0, + ok: true, + }, + { + config: Config{ + Token: "token", + TopicID: "12345", + Message: "", + Parser: terraform.NewPlanParser(), + Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), + }, + body: "BLUR BLUR BLUR", + exitCode: 1, + ok: false, + }, + } + fake := fakeAPI{ + FakeChatPostMessage: func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { + return nil, nil, nil + }, + } + + for _, testCase := range testCases { + client, err := NewClient(testCase.config) + if err != nil { + t.Fatal(err) + } + client.API = &fake + exitCode, err := client.Notify.Notify(testCase.body) + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + if exitCode != testCase.exitCode { + t.Errorf("got %q but want %q", exitCode, testCase.exitCode) + } + } +} diff --git a/notifier/typetalk/typetalk.go b/notifier/typetalk/typetalk.go new file mode 100644 index 0000000..df96c2f --- /dev/null +++ b/notifier/typetalk/typetalk.go @@ -0,0 +1,24 @@ +package typetalk + +import ( + "context" + + typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" + typetalk "github.com/nulab/go-typetalk/typetalk/v1" +) + +// API is Typetalk API interface +type API interface { + ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) +} + +// Typetalk represents the attribute information necessary for requesting Typetalk API +type Typetalk struct { + *typetalk.Client + TopicID int +} + +// ChatPostMessage is wrapper for https://godoc.org/github.com/nulab/go-typetalk/typetalk/v1#MessagesService.PostMessage +func (t *Typetalk) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { + return t.Client.Messages.PostMessage(ctx, t.TopicID, message, nil) +} diff --git a/notifier/typetalk/typetalk_test.go b/notifier/typetalk/typetalk_test.go new file mode 100644 index 0000000..501252c --- /dev/null +++ b/notifier/typetalk/typetalk_test.go @@ -0,0 +1,17 @@ +package typetalk + +import ( + "context" + + typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" + typetalk "github.com/nulab/go-typetalk/typetalk/v1" +) + +type fakeAPI struct { + API + FakeChatPostMessage func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) +} + +func (g *fakeAPI) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { + return g.FakeChatPostMessage(ctx, message) +}