diff --git a/.chglog/CHANGELOG.tpl.md b/.chglog/CHANGELOG.tpl.md
new file mode 100644
index 0000000..1c61940
--- /dev/null
+++ b/.chglog/CHANGELOG.tpl.md
@@ -0,0 +1,31 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+
+This file is automatically generated by [git-chglog](https://github.com/git-chglog/git-chglog). Don't edit by hand.
+
+{{range .Versions}}
+
+## {{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}} ({{datetime "2006-01-02" .Tag.Date}})
+{{range .CommitGroups}}
+### {{.Title}}
+{{range .Commits}}
+* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}}
+{{end}}{{if .RevertCommits}}
+### Reverts
+{{range .RevertCommits}}
+* {{.Revert.Header}}{{end}}
+{{end}}{{if .MergeCommits}}
+### Pull Requests
+{{range .MergeCommits}}
+* {{.Header}}{{end}}
+{{end}}{{range .NoteGroups}}
+### {{.Title}}
+{{range .Notes}}
+{{.Body}}
+{{end}}
+{{end}}
+{{end}}
diff --git a/.chglog/config.yml b/.chglog/config.yml
new file mode 100644
index 0000000..0be5101
--- /dev/null
+++ b/.chglog/config.yml
@@ -0,0 +1,32 @@
+style: github
+template: CHANGELOG.tpl.md
+info:
+ title: CHANGELOG
+ repository_url: https://github.com/mercari/tfnotify
+options:
+ commits:
+ filters:
+ Type:
+ - add
+ - change
+ - deprecate
+ - remove
+ - fix
+ - security
+ commit_groups:
+ title_maps:
+ add: Added
+ change: Changed
+ deprecate: Deprecated
+ remove: Removed
+ fix: Fixed
+ security: Security
+ header:
+ pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
+ pattern_maps:
+ - Type
+ - Scope
+ - Subject
+ notes:
+ keywords:
+ - BREAKING CHANGE
diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..96c945a
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,53 @@
+version: 2
+
+defaults: &defaults
+ working_directory: /go/src/github.com/mercari/tfnotify
+
+jobs:
+ build:
+ <<: *defaults
+ docker:
+ - image: golang:1.10-stretch
+ steps:
+ - checkout
+ - run:
+ name: Install dpendency tools and vendor
+ command: |
+ go get -u github.com/golang/dep/cmd/dep
+ dep ensure
+ - run:
+ name: Run test
+ command: |
+ make test
+ - run:
+ name: Run coverage
+ command: |
+ make coverage
+ bash <(curl -s https://codecov.io/bash)
+
+ lint:
+ <<: *defaults
+ docker:
+ - image: golang:1.10-stretch
+ steps:
+ - checkout
+ - run:
+ name: Install dpendency tools and vendor
+ command: |
+ make dep
+ dep ensure
+ - run:
+ name: Run lint
+ command: |
+ make reviewdog
+
+workflows:
+ version: 2
+ build-workflow:
+ jobs:
+ - build
+ - lint:
+ filters:
+ branches:
+ ignore:
+ - master
diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000..b52a5da
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,9 @@
+coverage:
+ precision: 2
+ round: down
+ range: 70...90
+
+ignore:
+ - "main.go"
+ - "notifier/github/github.go"
+ - "notifier/slack/slack.go"
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..197aa0d
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# https://help.github.com/articles/about-codeowners/
+* @b4b4r07
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..d85346e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,7 @@
+## WHAT
+
+(Write the change being made with this pull request)
+
+## WHY
+
+(Write the motivation why you submit this pull request)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..606c135
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# Created by https://www.gitignore.io/api/go
+
+### Go ###
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, build with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+
+# End of https://www.gitignore.io/api/go
+
+tfnotify
+vendor
+dist
diff --git a/.reviewdog.yml b/.reviewdog.yml
new file mode 100644
index 0000000..7afd35f
--- /dev/null
+++ b/.reviewdog.yml
@@ -0,0 +1,3 @@
+runner:
+ golint:
+ cmd: golint $(go list ./...)
diff --git a/Gopkg.lock b/Gopkg.lock
new file mode 100644
index 0000000..ba59335
--- /dev/null
+++ b/Gopkg.lock
@@ -0,0 +1,99 @@
+# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
+
+
+[[projects]]
+ name = "github.com/davecgh/go-spew"
+ packages = ["spew"]
+ revision = "346938d642f2ec3594ed81d874461961cd0faa76"
+ version = "v1.1.0"
+
+[[projects]]
+ name = "github.com/golang/protobuf"
+ packages = ["proto"]
+ revision = "925541529c1fa6821df4e44ce2723319eb2be768"
+ version = "v1.0.0"
+
+[[projects]]
+ name = "github.com/google/go-github"
+ packages = ["github"]
+ revision = "e48060a28fac52d0f1cb758bc8b87c07bac4a87d"
+ version = "v15.0.0"
+
+[[projects]]
+ branch = "master"
+ name = "github.com/google/go-querystring"
+ packages = ["query"]
+ revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a"
+
+[[projects]]
+ branch = "master"
+ name = "github.com/lestrrat-go/pdebug"
+ packages = ["."]
+ revision = "39f9a71bcabe9432cbdfe4d3d33f41988acd2ce6"
+
+[[projects]]
+ branch = "master"
+ name = "github.com/lestrrat-go/slack"
+ packages = [".","internal/option","objects"]
+ revision = "c4179c258775571c2e8ef70d1ac980ce92dca109"
+
+[[projects]]
+ name = "github.com/mattn/go-colorable"
+ packages = ["."]
+ revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
+ version = "v0.0.9"
+
+[[projects]]
+ name = "github.com/mattn/go-isatty"
+ packages = ["."]
+ revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
+ version = "v0.0.3"
+
+[[projects]]
+ name = "github.com/pkg/errors"
+ packages = ["."]
+ revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
+ version = "v0.8.0"
+
+[[projects]]
+ name = "github.com/urfave/cli"
+ packages = ["."]
+ revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
+ version = "v1.20.0"
+
+[[projects]]
+ branch = "master"
+ name = "golang.org/x/net"
+ packages = ["context","context/ctxhttp"]
+ revision = "24dd3780ca4f75fed9f321890729414a4b5d3f13"
+
+[[projects]]
+ branch = "master"
+ name = "golang.org/x/oauth2"
+ packages = [".","internal"]
+ revision = "fdc9e635145ae97e6c2cb777c48305600cf515cb"
+
+[[projects]]
+ branch = "master"
+ name = "golang.org/x/sys"
+ packages = ["unix"]
+ revision = "01acb38716e021ed1fc03a602bdb5838e1358c5e"
+
+[[projects]]
+ name = "google.golang.org/appengine"
+ packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"]
+ revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
+ version = "v1.0.0"
+
+[[projects]]
+ name = "gopkg.in/yaml.v2"
+ packages = ["."]
+ revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5"
+ version = "v2.1.1"
+
+[solve-meta]
+ analyzer-name = "dep"
+ analyzer-version = 1
+ inputs-digest = "ed615b7a5cfcf343b8c17d34323b9585503511e563ab959430dd0a9f0fbf9b82"
+ solver-name = "gps-cdcl"
+ solver-version = 1
diff --git a/Gopkg.toml b/Gopkg.toml
new file mode 100644
index 0000000..fdd1050
--- /dev/null
+++ b/Gopkg.toml
@@ -0,0 +1,38 @@
+
+# Gopkg.toml example
+#
+# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
+# for detailed Gopkg.toml documentation.
+#
+# required = ["github.com/user/thing/cmd/thing"]
+# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
+#
+# [[constraint]]
+# name = "github.com/user/project"
+# version = "1.0.0"
+#
+# [[constraint]]
+# name = "github.com/user/project2"
+# branch = "dev"
+# source = "github.com/myfork/project2"
+#
+# [[override]]
+# name = "github.com/x/y"
+# version = "2.4.0"
+
+[prune]
+ go-tests = true
+ unused-packages = true
+ non-go = true
+
+[[constraint]]
+ branch = "master"
+ name = "golang.org/x/oauth2"
+
+[[constraint]]
+ name = "gopkg.in/yaml.v2"
+ version = "2.1.1"
+
+[[constraint]]
+ name = "github.com/urfave/cli"
+ version = "1.20.0"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a07cd56
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2018 Mercari, Inc.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4eea1af
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+PACKAGES := $(shell go list ./...)
+COMMIT = $$(git describe --tags --always)
+DATE = $$(date -u '+%Y-%m-%d_%H:%M:%S')
+BUILD_LDFLAGS = -X $(PKG).commit=$(COMMIT) -X $(PKG).date=$(DATE)
+RELEASE_BUILD_LDFLAGS = -s -w $(BUILD_LDFLAGS)
+
+.PHONY: all
+all: test
+
+.PHONY: build
+build:
+ go build
+
+.PHONY: crossbuild
+crossbuild:
+ $(eval version = $(shell gobump show -r))
+ goxz -pv=v$(version) -os=linux,darwin -arch=386,amd64 -build-ldflags="$(RELEASE_BUILD_LDFLAGS)" \
+ -d=./dist/v$(version)
+
+.PHONY: test
+test:
+ go test -v -parallel=4 ./...
+
+.PHONY: dep
+dep: devel-deps
+ dep ensure -v
+
+.PHONY: reviewdog
+reviewdog: devel-deps
+ reviewdog -ci="circle-ci"
+
+.PHONY: coverage
+coverage: devel-deps
+ goverage -v -covermode=atomic -coverprofile=coverage.txt $(PACKAGES)
+
+.PHONY: release
+release: devel-deps
+ @./misc/bump-and-chglog.sh
+ @./misc/upload-artifacts.sh
+
+.PHONY: devel-deps
+devel-deps:
+ @./misc/install-devel-deps.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8ebbf98
--- /dev/null
+++ b/README.md
@@ -0,0 +1,169 @@
+tfnotify
+========
+
+[![][circleci-svg]][circleci] [![][codecov-svg]](codecov) [![][goreportcard-svg]][goreportcard]
+
+[circleci]: https://circleci.com/gh/mercari/tfnotify/tree/master
+[circleci-svg]: https://circleci.com/gh/mercari/tfnotify/tree/master.svg?style=svg
+[codecov]: https://codecov.io/gh/mercari/tfnotify
+[codecov-svg]: https://codecov.io/gh/mercari/tfnotify/branch/master/graph/badge.svg
+[goreportcard]: https://goreportcard.com/report/github.com/mercari/tfnotify
+[goreportcard-svg]: https://goreportcard.com/badge/github.com/mercari/tfnotify
+
+tfnotify parses Terraform commands' execution result and applies it to an arbitrary template and then notifies it to GitHub comments etc.
+
+## Motivation
+
+There are commands such as `plan` and `apply` on Terraform command, but many developers think they would like to check if the execution of those commands succeeded.
+Terraform commands are often executed via CI like Circle CI, but in that case you need to go to the CI page to check it.
+This is very troublesome. It is very efficient if you can check it with GitHub comments or Slack etc.
+You can do this by using this command.
+
+
+
+
+
+## Installation
+
+Grab the binary from GitHub Releases (Recommended)
+
+or
+
+```console
+$ go get -u github.com/mercari/tfnotify
+```
+
+
+### What tfnotify does
+
+1. Parse the execution result of Terraform
+2. Bind parsed results to Go templates
+3. Notify it to any platform (e.g. GitHub) as you like
+
+Detailed specifications such as templates and notification destinations can be customized from the configration files (described later).
+
+## Usage
+
+### Basic
+
+tfnotify is just CLI command. So you can run it from your local after grabbing the binary.
+
+Basically tfnotify waits for the input from Stdin. So tfnotify needs to pipe the output of Terraform command like the following:
+
+```console
+$ terraform plan | tfnotify plan
+```
+
+For `plan` command, you also need to specify `plan` as the argument of tfnotify. In the case of `apply`, you need to do `apply`. Currently supported commands can be checked with `tfnotify --help`.
+
+### Configurations
+
+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 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.
+
+[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:
+
+Placeholder | Usage
+---|---
+`{{ .Title }}` | Like `## Plan result`
+`{{ .Message }}` | A string that can be set from CLI with `--message` option
+`{{ .Result }}` | Matched result by parsing like `Plan: 1 to add` or `No changes`
+`{{ .Body }}` | The entire of Terraform execution result
+
+#### Template Examples
+
+
+For GitHub
+
+```yaml
+---
+ci: circleci
+notifier:
+ github:
+ token: $GITHUB_TOKEN
+ repository:
+ owner: "mercari"
+ name: "tfnotify"
+terraform:
+ fmt:
+ {{ .Title }}
+
+ {{ .Message }}
+
+ {{ .Result }}
+
+ {{ .Body }}
+ plan:
+ template: |
+ {{ .Title }}
+ {{ .Message }}
+ {{if .Result}}
+ {{ .Result }}
+
+ {{end}}
+ Details (Click me)
+ {{ .Body }}
+
+ apply:
+ template: |
+ {{ .Title }}
+ {{ .Message }}
+ {{if .Result}}
+ {{ .Result }}
+
+ {{end}}
+ Details (Click me)
+ {{ .Body }}
+
+```
+
+
+
+
+For Slack
+
+```yaml
+---
+ci: circleci
+notifier:
+ slack:
+ token: $GITHUB_TOKEN
+terraform:
+ plan:
+ template: |
+ {{ .Message }}
+ {{if .Result}}
+ ```
+ {{ .Result }}
+ ```
+ {{end}}
+ ```
+ {{ .Body }}
+ ```
+```
+
+
+
+### Supported CI
+
+Currently, supported CI are here:
+
+- Circle CI
+- Travis CI
+
+## Committers
+
+ * Masaki ISHIYAMA ([@b4b4r07](https://github.com/b4b4r07))
+
+## Contribution
+
+Please read the CLA below carefully before submitting your contribution.
+
+https://www.mercari.com/cla/
+
+## License
+
+Copyright 2018 Mercari, Inc.
+
+Licensed under the MIT License.
diff --git a/ci.go b/ci.go
new file mode 100644
index 0000000..ac04ac8
--- /dev/null
+++ b/ci.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strconv"
+)
+
+// CI represents a common information obtained from all CI platforms
+type CI struct {
+ PR PullRequest
+ URL string
+}
+
+// PullRequest represents a GitHub pull request
+type PullRequest struct {
+ Revision string
+ Number int
+}
+
+func circleci() (ci CI, err error) {
+ ci.PR.Number = 0
+ ci.PR.Revision = os.Getenv("CIRCLE_SHA1")
+ ci.URL = os.Getenv("CIRCLE_BUILD_URL")
+ pr := os.Getenv("CIRCLE_PULL_REQUEST")
+ if pr == "" {
+ pr = os.Getenv("CI_PULL_REQUEST")
+ }
+ if pr == "" {
+ pr = os.Getenv("CIRCLE_PR_NUMBER")
+ }
+ if pr == "" {
+ return ci, nil
+ }
+ re := regexp.MustCompile(`[1-9]\d*$`)
+ ci.PR.Number, err = strconv.Atoi(re.FindString(pr))
+ if err != nil {
+ return ci, fmt.Errorf("%v: cannot get env", pr)
+ }
+ return ci, nil
+}
+
+func travisci() (ci CI, err error) {
+ ci.PR.Revision = os.Getenv("TRAVIS_PULL_REQUEST_SHA")
+ ci.PR.Number, err = strconv.Atoi(os.Getenv("TRAVIS_PULL_REQUEST"))
+ return ci, err
+}
diff --git a/ci_test.go b/ci_test.go
new file mode 100644
index 0000000..b82a2cf
--- /dev/null
+++ b/ci_test.go
@@ -0,0 +1,211 @@
+package main
+
+import (
+ "os"
+ "reflect"
+ "testing"
+)
+
+func TestCircleci(t *testing.T) {
+ envs := []string{
+ "CIRCLE_SHA1",
+ "CIRCLE_BUILD_URL",
+ "CIRCLE_PULL_REQUEST",
+ "CI_PULL_REQUEST",
+ "CIRCLE_PR_NUMBER",
+ }
+ saveEnvs := make(map[string]string)
+ for _, key := range envs {
+ saveEnvs[key] = os.Getenv(key)
+ os.Unsetenv(key)
+ }
+ defer func() {
+ for key, value := range saveEnvs {
+ os.Setenv(key, value)
+ }
+ }()
+
+ testCases := []struct {
+ fn func()
+ ci CI
+ ok bool
+ }{
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "abcdefg")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "")
+ os.Setenv("CI_PULL_REQUEST", "")
+ os.Setenv("CIRCLE_PR_NUMBER", "")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 0,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "abcdefg")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "https://github.com/owner/repo/pull/1")
+ os.Setenv("CI_PULL_REQUEST", "")
+ os.Setenv("CIRCLE_PR_NUMBER", "")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 1,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "abcdefg")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "")
+ os.Setenv("CI_PULL_REQUEST", "2")
+ os.Setenv("CIRCLE_PR_NUMBER", "")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 2,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "abcdefg")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "")
+ os.Setenv("CI_PULL_REQUEST", "")
+ os.Setenv("CIRCLE_PR_NUMBER", "3")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 3,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "")
+ os.Setenv("CI_PULL_REQUEST", "")
+ os.Setenv("CIRCLE_PR_NUMBER", "")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "",
+ Number: 0,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("CIRCLE_SHA1", "")
+ os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234")
+ os.Setenv("CIRCLE_PULL_REQUEST", "abcdefg")
+ os.Setenv("CI_PULL_REQUEST", "")
+ os.Setenv("CIRCLE_PR_NUMBER", "")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "",
+ Number: 0,
+ },
+ URL: "https://circleci.com/gh/owner/repo/1234",
+ },
+ ok: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ testCase.fn()
+ ci, err := circleci()
+ if !reflect.DeepEqual(ci, testCase.ci) {
+ t.Errorf("got %q but want %q", ci, testCase.ci)
+ }
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
+
+func TestTravisCI(t *testing.T) {
+ envs := []string{
+ "TRAVIS_PULL_REQUEST_SHA",
+ "TRAVIS_PULL_REQUEST",
+ }
+ saveEnvs := make(map[string]string)
+ for _, key := range envs {
+ saveEnvs[key] = os.Getenv(key)
+ os.Unsetenv(key)
+ }
+ defer func() {
+ for key, value := range saveEnvs {
+ os.Setenv(key, value)
+ }
+ }()
+
+ // https://docs.travis-ci.com/user/environment-variables/
+ testCases := []struct {
+ fn func()
+ ci CI
+ ok bool
+ }{
+ {
+ fn: func() {
+ os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg")
+ os.Setenv("TRAVIS_PULL_REQUEST", "1")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 1,
+ },
+ URL: "",
+ },
+ ok: true,
+ },
+ {
+ fn: func() {
+ os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg")
+ os.Setenv("TRAVIS_PULL_REQUEST", "false")
+ },
+ ci: CI{
+ PR: PullRequest{
+ Revision: "abcdefg",
+ Number: 0,
+ },
+ URL: "",
+ },
+ ok: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ testCase.fn()
+ ci, err := travisci()
+ if !reflect.DeepEqual(ci, testCase.ci) {
+ t.Errorf("got %q but want %q", ci, testCase.ci)
+ }
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..951ed76
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,159 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v2"
+)
+
+// Config is for tfnotify config structure
+type Config struct {
+ CI string `yaml:"ci"`
+ Notifier Notifier `yaml:"notifier"`
+ Terraform Terraform `yaml:"terraform"`
+
+ path string
+}
+
+// Notifier is a notification notifier
+type Notifier struct {
+ Github GithubNotifier `yaml:"github"`
+ Slack SlackNotifier `yaml:"slack"`
+}
+
+// GithubNotifier is a notifier for GitHub
+type GithubNotifier struct {
+ Token string `yaml:"token"`
+ Repository Repository `yaml:"repository"`
+}
+
+// Repository represents a GitHub repository
+type Repository struct {
+ Owner string `yaml:"owner"`
+ Name string `yaml:"name"`
+}
+
+// SlackNotifier is a notifier for Slack
+type SlackNotifier struct {
+ Token string `yaml:"token"`
+ Channel string `yaml:"channel"`
+ Bot string `yaml:"bot"`
+}
+
+// Terraform represents terraform configurations
+type Terraform struct {
+ Default Default `yaml:"default"`
+ Fmt Fmt `yaml:"fmt"`
+ Plan Plan `yaml:"plan"`
+ Apply Apply `yaml:"apply"`
+}
+
+// Default is a default setting for terraform commands
+type Default struct {
+ Template string `yaml:"template"`
+}
+
+// Fmt is a terraform fmt config
+type Fmt struct {
+ Template string `yaml:"template"`
+}
+
+// Plan is a terraform plan config
+type Plan struct {
+ Template string `yaml:"template"`
+}
+
+// Apply is a terraform apply config
+type Apply struct {
+ Template string `yaml:"template"`
+}
+
+// LoadFile binds the config file to Config structure
+func (cfg *Config) LoadFile(path string) error {
+ cfg.path = path
+ _, err := os.Stat(cfg.path)
+ if err != nil {
+ return fmt.Errorf("%s: no config file", cfg.path)
+ }
+ raw, _ := ioutil.ReadFile(cfg.path)
+ return yaml.Unmarshal(raw, cfg)
+}
+
+// Validation validates config file
+func (cfg *Config) Validation() error {
+ switch strings.ToLower(cfg.CI) {
+ case "":
+ return errors.New("ci: need to be set")
+ case "circleci", "circle-ci":
+ // ok pattern
+ case "travis", "travisci", "travis-ci":
+ // ok pattern
+ default:
+ return fmt.Errorf("%s: not supported yet", cfg.CI)
+ }
+ if cfg.isDefinedGithub() {
+ if cfg.Notifier.Github.Repository.Owner == "" {
+ return fmt.Errorf("repository owner is missing")
+ }
+ if cfg.Notifier.Github.Repository.Name == "" {
+ return fmt.Errorf("repository name is missing")
+ }
+ }
+ if cfg.isDefinedSlack() {
+ if cfg.Notifier.Slack.Channel == "" {
+ return fmt.Errorf("slack channel id is missing")
+ }
+ }
+ notifier := cfg.GetNotifierType()
+ if notifier == "" {
+ return fmt.Errorf("notifier is missing")
+ }
+ return nil
+}
+
+func (cfg *Config) isDefinedGithub() bool {
+ // not empty
+ return cfg.Notifier.Github != (GithubNotifier{})
+}
+
+func (cfg *Config) isDefinedSlack() bool {
+ // not empty
+ return cfg.Notifier.Slack != (SlackNotifier{})
+}
+
+// GetNotifierType return notifier type described in Config
+func (cfg *Config) GetNotifierType() string {
+ if cfg.isDefinedGithub() {
+ return "github"
+ }
+ if cfg.isDefinedSlack() {
+ return "slack"
+ }
+ return ""
+}
+
+// Find returns config path
+func (cfg *Config) Find(file string) (string, error) {
+ var files []string
+ if file == "" {
+ files = []string{
+ "tfnotify.yaml",
+ "tfnotify.yml",
+ ".tfnotify.yaml",
+ ".tfnotify.yml",
+ }
+ } else {
+ files = []string{file}
+ }
+ for _, file := range files {
+ _, err := os.Stat(file)
+ if err == nil {
+ return file, nil
+ }
+ }
+ return "", errors.New("config for tfnotify is not found at all")
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..803150c
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,319 @@
+package config
+
+import (
+ "os"
+ "reflect"
+ "testing"
+
+ yaml "gopkg.in/yaml.v2"
+)
+
+func helperLoadConfig(contents []byte) (*Config, error) {
+ cfg := &Config{}
+ err := yaml.Unmarshal(contents, cfg)
+ return cfg, err
+}
+
+func TestLoadFile(t *testing.T) {
+ testCases := []struct {
+ file string
+ cfg Config
+ ok bool
+ }{
+ {
+ file: "../example.tfnotify.yaml",
+ cfg: Config{
+ CI: "circleci",
+ Notifier: Notifier{
+ Github: GithubNotifier{
+ Token: "$GITHUB_TOKEN",
+ Repository: Repository{
+ Owner: "mercari",
+ Name: "tfnotify",
+ },
+ },
+ Slack: SlackNotifier{
+ Token: "",
+ Channel: "",
+ Bot: "",
+ },
+ },
+ Terraform: Terraform{
+ Default: Default{
+ Template: "",
+ },
+ Fmt: Fmt{
+ Template: "",
+ },
+ Plan: Plan{
+ Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\nDetails (Click me)
\n {{ .Body }}\n
\n",
+ },
+ Apply: Apply{
+ Template: "",
+ },
+ },
+ path: "../example.tfnotify.yaml",
+ },
+ ok: true,
+ },
+ {
+ file: "no-such-config.yaml",
+ cfg: Config{
+ CI: "circleci",
+ Notifier: Notifier{
+ Github: GithubNotifier{
+ Token: "$GITHUB_TOKEN",
+ Repository: Repository{
+ Owner: "mercari",
+ Name: "tfnotify",
+ },
+ },
+ Slack: SlackNotifier{
+ Token: "",
+ Channel: "",
+ Bot: "",
+ },
+ },
+ Terraform: Terraform{
+ Default: Default{
+ Template: "",
+ },
+ Fmt: Fmt{
+ Template: "",
+ },
+ Plan: Plan{
+ Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n {{ .Result }}\n
\n{{end}}\nDetails (Click me)
\n {{ .Body }}\n
\n",
+ },
+ Apply: Apply{
+ Template: "",
+ },
+ },
+ path: "no-such-config.yaml",
+ },
+ ok: false,
+ },
+ }
+
+ var cfg Config
+ for _, testCase := range testCases {
+ err := cfg.LoadFile(testCase.file)
+ if !reflect.DeepEqual(cfg, testCase.cfg) {
+ t.Errorf("got %q but want %q", cfg, testCase.cfg)
+ }
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
+
+func TestValidation(t *testing.T) {
+ testCases := []struct {
+ contents []byte
+ expected string
+ }{
+ {
+ contents: []byte(""),
+ expected: "ci: need to be set",
+ },
+ {
+ contents: []byte("ci: rare-ci\n"),
+ expected: "rare-ci: not supported yet",
+ },
+ {
+ contents: []byte("ci: circleci\n"),
+ expected: "notifier is missing",
+ },
+ {
+ contents: []byte("ci: travisci\n"),
+ expected: "notifier is missing",
+ },
+ {
+ contents: []byte("ci: circleci\nnotifier:\n github:\n"),
+ expected: "notifier is missing",
+ },
+ {
+ contents: []byte("ci: circleci\nnotifier:\n github:\n token: token\n"),
+ expected: "repository owner is missing",
+ },
+ {
+ contents: []byte(`
+ci: circleci
+notifier:
+ github:
+ token: token
+ repository:
+ owner: owner
+`),
+ expected: "repository name is missing",
+ },
+ {
+ contents: []byte(`
+ci: circleci
+notifier:
+ github:
+ token: token
+ repository:
+ owner: owner
+ name: name
+`),
+ expected: "",
+ },
+ {
+ contents: []byte(`
+ci: circleci
+notifier:
+ slack:
+`),
+ expected: "notifier is missing",
+ },
+ {
+ contents: []byte(`
+ci: circleci
+notifier:
+ slack:
+ token: token
+`),
+ expected: "slack channel id is missing",
+ },
+ {
+ contents: []byte(`
+ci: circleci
+notifier:
+ slack:
+ token: token
+ channel: channel
+`),
+ expected: "",
+ },
+ }
+ for _, testCase := range testCases {
+ cfg, err := helperLoadConfig(testCase.contents)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = cfg.Validation()
+ if err == nil {
+ if testCase.expected != "" {
+ t.Errorf("got no error but want %q", testCase.expected)
+ }
+ } else {
+ if err.Error() != testCase.expected {
+ t.Errorf("got %q but want %q", err.Error(), testCase.expected)
+ }
+ }
+ }
+}
+
+func TestGetNotifierType(t *testing.T) {
+ testCases := []struct {
+ contents []byte
+ expected string
+ }{
+ {
+ contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n github:\n token: token\n"),
+ expected: "github",
+ },
+ {
+ contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n slack:\n token: token\n"),
+ expected: "slack",
+ },
+ }
+ for _, testCase := range testCases {
+ cfg, err := helperLoadConfig(testCase.contents)
+ if err != nil {
+ t.Fatal(err)
+ }
+ actual := cfg.GetNotifierType()
+ if actual != testCase.expected {
+ t.Errorf("got %q but want %q", actual, testCase.expected)
+ }
+ }
+}
+
+func createDummy(file string) {
+ validConfig := func(file string) bool {
+ for _, c := range []string{
+ "tfnotify.yaml",
+ "tfnotify.yml",
+ ".tfnotify.yaml",
+ ".tfnotify.yml",
+ } {
+ if file == c {
+ return true
+ }
+ }
+ return false
+ }
+ if !validConfig(file) {
+ return
+ }
+ if _, err := os.Stat(file); err == nil {
+ return
+ }
+ f, err := os.OpenFile(file, os.O_RDONLY|os.O_CREATE, 0666)
+ if err != nil {
+ panic(err)
+ }
+ defer f.Close()
+}
+
+func removeDummy(file string) {
+ os.Remove(file)
+}
+
+func TestFind(t *testing.T) {
+ testCases := []struct {
+ file string
+ expect string
+ ok bool
+ }{
+ {
+ // valid config
+ file: ".tfnotify.yaml",
+ expect: ".tfnotify.yaml",
+ ok: true,
+ },
+ {
+ // valid config
+ file: "tfnotify.yaml",
+ expect: "tfnotify.yaml",
+ ok: true,
+ },
+ {
+ // valid config
+ file: ".tfnotify.yml",
+ expect: ".tfnotify.yml",
+ ok: true,
+ },
+ {
+ // valid config
+ file: "tfnotify.yml",
+ expect: "tfnotify.yml",
+ ok: true,
+ },
+ {
+ // invalid config
+ file: "codecov.yml",
+ expect: "",
+ ok: false,
+ },
+ {
+ // in case of no args passed
+ file: "",
+ expect: "tfnotify.yaml",
+ ok: true,
+ },
+ }
+ var cfg Config
+ for _, testCase := range testCases {
+ createDummy(testCase.file)
+ defer removeDummy(testCase.file)
+ actual, err := cfg.Find(testCase.file)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ if actual != testCase.expect {
+ t.Errorf("got %q but want %q", actual, testCase.expect)
+ }
+ }
+}
diff --git a/error.go b/error.go
new file mode 100644
index 0000000..ab342f4
--- /dev/null
+++ b/error.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+// Exit codes are int values for the exit code that shell interpreter can interpret
+const (
+ ExitCodeOK int = 0
+ ExitCodeError int = iota
+)
+
+// ErrorFormatter is the interface for format
+type ErrorFormatter interface {
+ Format(s fmt.State, verb rune)
+}
+
+// ExitCoder is the wrapper interface for urfave/cli
+type ExitCoder interface {
+ error
+ ExitCode() int
+}
+
+// ExitError is the wrapper struct for urfave/cli
+type ExitError struct {
+ exitCode int
+ err error
+}
+
+// NewExitError makes a new ExitError
+func NewExitError(exitCode int, err error) *ExitError {
+ return &ExitError{
+ exitCode: exitCode,
+ err: err,
+ }
+}
+
+// Error returns the string message, fulfilling the interface required by `error`
+func (ee *ExitError) Error() string {
+ if ee.err == nil {
+ return ""
+ }
+ return fmt.Sprintf("%v", ee.err)
+}
+
+// ExitCode returns the exit code, fulfilling the interface required by `ExitCoder`
+func (ee *ExitError) ExitCode() int {
+ return ee.exitCode
+}
+
+// HandleExit returns int value that shell interpreter can interpret as the exit code
+// If err has error message, it will be displayed to stderr
+// This function is heavily inspired by urfave/cli.HandleExitCoder
+func HandleExit(err error) int {
+ if err == nil {
+ return ExitCodeOK
+ }
+
+ if exitErr, ok := err.(ExitCoder); ok {
+ if err.Error() != "" {
+ if _, ok := exitErr.(ErrorFormatter); ok {
+ fmt.Fprintf(os.Stderr, "%+v\n", err)
+ } else {
+ fmt.Fprintln(os.Stderr, err)
+ }
+ }
+ return exitErr.ExitCode()
+ }
+
+ if _, ok := err.(error); ok {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ return ExitCodeError
+ }
+
+ return ExitCodeOK
+}
diff --git a/error_test.go b/error_test.go
new file mode 100644
index 0000000..67e28aa
--- /dev/null
+++ b/error_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestHandleError(t *testing.T) {
+ testCases := []struct {
+ err error
+ exitCode int
+ }{
+ {
+ err: NewExitError(1, errors.New("error")),
+ exitCode: 1,
+ },
+ {
+ err: NewExitError(0, errors.New("error")),
+ exitCode: 0,
+ },
+ {
+ err: errors.New("error"),
+ exitCode: 1,
+ },
+ {
+ err: NewExitError(0, nil),
+ exitCode: 0,
+ },
+ {
+ err: NewExitError(1, nil),
+ exitCode: 1,
+ },
+ {
+ err: nil,
+ exitCode: 0,
+ },
+ }
+
+ for _, testCase := range testCases {
+ // TODO: test stderr
+ exitCode := HandleExit(testCase.err)
+ if exitCode != testCase.exitCode {
+ t.Errorf("got %q but want %q", exitCode, testCase.exitCode)
+ }
+ }
+}
diff --git a/example.tfnotify.yaml b/example.tfnotify.yaml
new file mode 100644
index 0000000..b124dcc
--- /dev/null
+++ b/example.tfnotify.yaml
@@ -0,0 +1,19 @@
+ci: circleci
+notifier:
+ github:
+ token: $GITHUB_TOKEN
+ repository:
+ owner: "mercari"
+ name: "tfnotify"
+terraform:
+ plan:
+ template: |
+ {{ .Title }}
+ {{ .Message }}
+ {{if .Result}}
+ {{ .Result }}
+
+ {{end}}
+ Details (Click me)
+ {{ .Body }}
+
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..bf30758
--- /dev/null
+++ b/main.go
@@ -0,0 +1,212 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/mercari/tfnotify/config"
+ "github.com/mercari/tfnotify/notifier"
+ "github.com/mercari/tfnotify/notifier/github"
+ "github.com/mercari/tfnotify/notifier/slack"
+ "github.com/mercari/tfnotify/terraform"
+
+ "github.com/urfave/cli"
+)
+
+const (
+ name = "tfnotify"
+ description = "Notify the execution result of terraform command"
+ version = "0.0.0"
+)
+
+type tfnotify struct {
+ config config.Config
+ context *cli.Context
+ parser terraform.Parser
+ template terraform.Template
+}
+
+// Run sends the notification with notifier
+func (t *tfnotify) Run() error {
+ ciname := t.config.CI
+ if t.context.GlobalString("ci") != "" {
+ ciname = t.context.GlobalString("ci")
+ }
+ ciname = strings.ToLower(ciname)
+ var ci CI
+ var err error
+ switch ciname {
+ case "circleci", "circle-ci":
+ ci, err = circleci()
+ if err != nil {
+ return err
+ }
+ case "travis", "travisci", "travis-ci":
+ ci, err = travisci()
+ if err != nil {
+ return err
+ }
+ case "":
+ return fmt.Errorf("CI service: required (e.g. circleci)")
+ default:
+ return fmt.Errorf("CI service %v: not supported yet", ci)
+ }
+
+ selectedNotifier := t.config.GetNotifierType()
+ if t.context.GlobalString("notifier") != "" {
+ selectedNotifier = t.context.GlobalString("notifier")
+ }
+
+ var notifier notifier.Notifier
+ switch selectedNotifier {
+ case "github":
+ client, err := github.NewClient(github.Config{
+ Token: t.config.Notifier.Github.Token,
+ Owner: t.config.Notifier.Github.Repository.Owner,
+ Repo: t.config.Notifier.Github.Repository.Name,
+ PR: github.PullRequest{
+ Revision: ci.PR.Revision,
+ Number: ci.PR.Number,
+ Message: t.context.String("message"),
+ },
+ CI: ci.URL,
+ Parser: t.parser,
+ Template: t.template,
+ })
+ if err != nil {
+ return err
+ }
+ notifier = client.Notify
+ case "slack":
+ client, err := slack.NewClient(slack.Config{
+ Token: t.config.Notifier.Slack.Token,
+ Channel: t.config.Notifier.Slack.Channel,
+ Botname: t.config.Notifier.Slack.Bot,
+ 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:
+ return fmt.Errorf("%s: not supported notifier yet", t.context.GlobalString("notifier"))
+ }
+
+ if notifier == nil {
+ return fmt.Errorf("no notifier specified at all")
+ }
+
+ return NewExitError(notifier.Notify(tee(os.Stdin, os.Stdout)))
+}
+
+func main() {
+ app := cli.NewApp()
+ app.Name = name
+ app.Usage = description
+ app.Version = version
+ app.Flags = []cli.Flag{
+ cli.StringFlag{Name: "ci", Usage: "name of CI to run tfnotify"},
+ cli.StringFlag{Name: "config", Usage: "config path"},
+ cli.StringFlag{Name: "notifier", Usage: "notification destination"},
+ }
+ app.Commands = []cli.Command{
+ {
+ Name: "fmt",
+ Usage: "Parse stdin as a fmt result",
+ Action: cmdFmt,
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "message, m",
+ Usage: "Specify the message to use for notification",
+ },
+ },
+ },
+ {
+ Name: "plan",
+ Usage: "Parse stdin as a plan result",
+ Action: cmdPlan,
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "message, m",
+ Usage: "Specify the message to use for notification",
+ },
+ },
+ },
+ {
+ Name: "apply",
+ Usage: "Parse stdin as a apply result",
+ Action: cmdApply,
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "message, m",
+ Usage: "Specify the message to use for notification",
+ },
+ },
+ },
+ }
+
+ err := app.Run(os.Args)
+ os.Exit(HandleExit(err))
+}
+
+func newConfig(ctx *cli.Context) (cfg config.Config, err error) {
+ confPath, err := cfg.Find(ctx.GlobalString("config"))
+ if err != nil {
+ return cfg, err
+ }
+ if err := cfg.LoadFile(confPath); err != nil {
+ return cfg, err
+ }
+ if err := cfg.Validation(); err != nil {
+ return cfg, err
+ }
+ return cfg, nil
+}
+
+func cmdFmt(ctx *cli.Context) error {
+ cfg, err := newConfig(ctx)
+ if err != nil {
+ return err
+ }
+ t := &tfnotify{
+ config: cfg,
+ context: ctx,
+ parser: terraform.NewFmtParser(),
+ template: terraform.NewFmtTemplate(cfg.Terraform.Fmt.Template),
+ }
+ return t.Run()
+}
+
+func cmdPlan(ctx *cli.Context) error {
+ cfg, err := newConfig(ctx)
+ if err != nil {
+ return err
+ }
+ t := &tfnotify{
+ config: cfg,
+ context: ctx,
+ parser: terraform.NewPlanParser(),
+ template: terraform.NewPlanTemplate(cfg.Terraform.Plan.Template),
+ }
+ return t.Run()
+}
+
+func cmdApply(ctx *cli.Context) error {
+ cfg, err := newConfig(ctx)
+ if err != nil {
+ return err
+ }
+ t := &tfnotify{
+ config: cfg,
+ context: ctx,
+ parser: terraform.NewApplyParser(),
+ template: terraform.NewApplyTemplate(cfg.Terraform.Apply.Template),
+ }
+ return t.Run()
+}
diff --git a/misc/1.png b/misc/1.png
new file mode 100644
index 0000000..cebcb60
Binary files /dev/null and b/misc/1.png differ
diff --git a/misc/2.png b/misc/2.png
new file mode 100644
index 0000000..b192161
Binary files /dev/null and b/misc/2.png differ
diff --git a/misc/bump-and-chglog.sh b/misc/bump-and-chglog.sh
new file mode 100755
index 0000000..50d34d9
--- /dev/null
+++ b/misc/bump-and-chglog.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+set -e
+
+current_version="$(gobump show -r)"
+
+echo "current version: $current_version"
+while true
+do
+ read -p "Specify [major | minor | patch]: " semver
+ case "$semver" in
+ major | minor | patch )
+ gobump "$semver" -w
+ next_version="$(gobump show -r)"
+ break
+ ;;
+ *)
+ echo "Invalid semver type" >&2
+ continue
+ ;;
+ esac
+ shift
+done
+
+git commit -am "Bump version $next_version"
+git tag "v$next_version"
+
+git-chglog -o CHANGELOG.md
+git commit -am "Update changelog"
+
+git push && git push --tags
diff --git a/misc/install-devel-deps.sh b/misc/install-devel-deps.sh
new file mode 100755
index 0000000..74865d8
--- /dev/null
+++ b/misc/install-devel-deps.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+go get -v -u github.com/Songmu/ghch/cmd/ghch
+go get -v -u github.com/Songmu/goxz/cmd/goxz
+go get -v -u github.com/git-chglog/git-chglog/cmd/git-chglog
+go get -v -u github.com/golang/dep/cmd/dep
+go get -v -u github.com/golang/lint/golint
+go get -v -u github.com/haya14busa/goverage
+go get -v -u github.com/haya14busa/reviewdog/cmd/reviewdog
+go get -v -u github.com/motemen/gobump/cmd/gobump
+go get -v -u github.com/tcnksm/ghr
diff --git a/misc/upload-artifacts.sh b/misc/upload-artifacts.sh
new file mode 100755
index 0000000..e69769a
--- /dev/null
+++ b/misc/upload-artifacts.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+set -e
+
+version="v$(gobump show -r)"
+make crossbuild
+ghr -username mercari -replace "$version" "dist/$version"
diff --git a/notifier/github/client.go b/notifier/github/client.go
new file mode 100644
index 0000000..d36ecde
--- /dev/null
+++ b/notifier/github/client.go
@@ -0,0 +1,91 @@
+package github
+
+import (
+ "errors"
+ "os"
+ "strings"
+
+ "github.com/google/go-github/github"
+ "github.com/mercari/tfnotify/terraform"
+ "golang.org/x/oauth2"
+)
+
+// EnvToken is GitHub API Token
+const EnvToken = "GITHUB_TOKEN"
+
+// Client is a API client for GitHub
+type Client struct {
+ *github.Client
+ Debug bool
+
+ Config Config
+
+ common service
+
+ Comment *CommentService
+ Commits *CommitsService
+ Notify *NotifyService
+
+ API API
+}
+
+// Config is a configuration for GitHub client
+type Config struct {
+ Token string
+ Owner string
+ Repo string
+ PR PullRequest
+ CI string
+ Parser terraform.Parser
+ Template terraform.Template
+}
+
+// PullRequest represents GitHub Pull Request metadata
+type PullRequest struct {
+ Revision string
+ Message string
+ Number int
+}
+
+type service struct {
+ client *Client
+}
+
+// NewClient returns Client initialized with Config
+func NewClient(cfg Config) (*Client, error) {
+ token := cfg.Token
+ token = strings.TrimPrefix(token, "$")
+ if token == EnvToken {
+ token = os.Getenv(EnvToken)
+ }
+ if token == "" {
+ return &Client{}, errors.New("github token is missing")
+ }
+ ts := oauth2.StaticTokenSource(
+ &oauth2.Token{AccessToken: token},
+ )
+ tc := oauth2.NewClient(oauth2.NoContext, ts)
+ client := github.NewClient(tc)
+
+ c := &Client{
+ Config: cfg,
+ Client: client,
+ }
+ c.common.client = c
+ c.Comment = (*CommentService)(&c.common)
+ c.Commits = (*CommitsService)(&c.common)
+ c.Notify = (*NotifyService)(&c.common)
+
+ c.API = &GitHub{
+ Client: client,
+ owner: cfg.Owner,
+ repo: cfg.Repo,
+ }
+
+ return c, nil
+}
+
+// IsNumber returns true if PullRequest is Pull Request build
+func (pr *PullRequest) IsNumber() bool {
+ return pr.Number != 0
+}
diff --git a/notifier/github/client_test.go b/notifier/github/client_test.go
new file mode 100644
index 0000000..7c6e692
--- /dev/null
+++ b/notifier/github/client_test.go
@@ -0,0 +1,98 @@
+package github
+
+import (
+ "os"
+ "testing"
+)
+
+func TestNewClient(t *testing.T) {
+ githubToken := os.Getenv(EnvToken)
+ defer func() {
+ os.Setenv(EnvToken, githubToken)
+ }()
+ os.Setenv(EnvToken, "")
+
+ testCases := []struct {
+ config Config
+ envToken string
+ expect string
+ }{
+ {
+ // specify directly
+ config: Config{Token: "abcdefg"},
+ envToken: "",
+ expect: "",
+ },
+ {
+ // specify via env but not to be set env (part 1)
+ config: Config{Token: "GITHUB_TOKEN"},
+ envToken: "",
+ expect: "github token is missing",
+ },
+ {
+ // specify via env (part 1)
+ config: Config{Token: "GITHUB_TOKEN"},
+ envToken: "abcdefg",
+ expect: "",
+ },
+ {
+ // specify via env but not to be set env (part 2)
+ config: Config{Token: "$GITHUB_TOKEN"},
+ envToken: "",
+ expect: "github token is missing",
+ },
+ {
+ // specify via env (part 2)
+ config: Config{Token: "$GITHUB_TOKEN"},
+ envToken: "abcdefg",
+ expect: "",
+ },
+ {
+ // no specification (part 1)
+ config: Config{},
+ envToken: "",
+ expect: "github token is missing",
+ },
+ {
+ // no specification (part 2)
+ config: Config{},
+ envToken: "abcdefg",
+ expect: "github 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)
+ }
+ }
+}
+
+func TestIsNumber(t *testing.T) {
+ testCases := []struct {
+ pr PullRequest
+ isPR bool
+ }{
+ {
+ pr: PullRequest{
+ Number: 0,
+ },
+ isPR: false,
+ },
+ {
+ pr: PullRequest{
+ Number: 123,
+ },
+ isPR: true,
+ },
+ }
+ for _, testCase := range testCases {
+ if testCase.pr.IsNumber() != testCase.isPR {
+ t.Errorf("got %v but want %v", testCase.pr.IsNumber(), testCase.isPR)
+ }
+ }
+}
diff --git a/notifier/github/comment.go b/notifier/github/comment.go
new file mode 100644
index 0000000..5610ded
--- /dev/null
+++ b/notifier/github/comment.go
@@ -0,0 +1,86 @@
+package github
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/github"
+)
+
+// CommentService handles communication with the comment related
+// methods of GitHub API
+type CommentService service
+
+// PostOptions specifies the optional parameters to post comments to a pull request
+type PostOptions struct {
+ Number int
+ Revision string
+}
+
+// Post posts comment
+func (g *CommentService) Post(body string, opt PostOptions) error {
+ if opt.Number != 0 {
+ _, _, err := g.client.API.IssuesCreateComment(
+ context.Background(),
+ opt.Number,
+ &github.IssueComment{Body: &body},
+ )
+ return err
+ }
+ if opt.Revision != "" {
+ _, _, err := g.client.API.RepositoriesCreateComment(
+ context.Background(),
+ opt.Revision,
+ &github.RepositoryComment{Body: &body},
+ )
+ return err
+ }
+ return fmt.Errorf("github.comment.post: Number or Revision is required")
+}
+
+// List lists comments on GitHub issues/pull requests
+func (g *CommentService) List(number int) ([]*github.IssueComment, error) {
+ comments, _, err := g.client.API.IssuesListComments(
+ context.Background(),
+ number,
+ &github.IssueListCommentsOptions{},
+ )
+ return comments, err
+}
+
+// Delete deletes comment on GitHub issues/pull requests
+func (g *CommentService) Delete(id int) error {
+ _, err := g.client.API.IssuesDeleteComment(
+ context.Background(),
+ int64(id),
+ )
+ return err
+}
+
+// DeleteDuplicates deletes duplicate comments containing arbitrary character strings
+func (g *CommentService) DeleteDuplicates(title string) {
+ var ids []int64
+ comments := g.getDuplicates(title)
+ for _, comment := range comments {
+ ids = append(ids, *comment.ID)
+ }
+ for _, id := range ids {
+ // don't handle error
+ g.client.Comment.Delete(int(id))
+ }
+}
+
+func (g *CommentService) getDuplicates(title string) []*github.IssueComment {
+ var dup []*github.IssueComment
+ re := regexp.MustCompile(`(?m)^(\n+)?` + title + `\n+` + g.client.Config.PR.Message + `\n+`)
+
+ comments, _ := g.client.Comment.List(g.client.Config.PR.Number)
+ for _, comment := range comments {
+ if re.MatchString(*comment.Body) {
+ dup = append(dup, comment)
+ }
+ }
+
+ return dup
+}
diff --git a/notifier/github/comment_test.go b/notifier/github/comment_test.go
new file mode 100644
index 0000000..5c6fdb3
--- /dev/null
+++ b/notifier/github/comment_test.go
@@ -0,0 +1,217 @@
+package github
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "github.com/google/go-github/github"
+)
+
+func TestCommentPost(t *testing.T) {
+ testCases := []struct {
+ config Config
+ body string
+ opt PostOptions
+ ok bool
+ }{
+ {
+ config: newFakeConfig(),
+ body: "",
+ opt: PostOptions{
+ Number: 1,
+ Revision: "abcd",
+ },
+ ok: true,
+ },
+ {
+ config: newFakeConfig(),
+ body: "",
+ opt: PostOptions{
+ Number: 0,
+ Revision: "abcd",
+ },
+ ok: true,
+ },
+ {
+ config: newFakeConfig(),
+ body: "",
+ opt: PostOptions{
+ Number: 2,
+ Revision: "",
+ },
+ ok: true,
+ },
+ {
+ config: newFakeConfig(),
+ body: "",
+ opt: PostOptions{
+ Number: 0,
+ Revision: "",
+ },
+ ok: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ client, err := NewClient(testCase.config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ err = client.Comment.Post(testCase.body, testCase.opt)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
+
+func TestCommentList(t *testing.T) {
+ comments := []*github.IssueComment{
+ &github.IssueComment{
+ ID: github.Int64(371748792),
+ Body: github.String("comment 1"),
+ },
+ &github.IssueComment{
+ ID: github.Int64(371765743),
+ Body: github.String("comment 2"),
+ },
+ }
+ testCases := []struct {
+ config Config
+ number int
+ ok bool
+ comments []*github.IssueComment
+ }{
+ {
+ config: newFakeConfig(),
+ number: 1,
+ ok: true,
+ comments: comments,
+ },
+ {
+ config: newFakeConfig(),
+ number: 12,
+ ok: true,
+ comments: comments,
+ },
+ {
+ config: newFakeConfig(),
+ number: 123,
+ ok: true,
+ comments: comments,
+ },
+ }
+
+ for _, testCase := range testCases {
+ client, err := NewClient(testCase.config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ comments, err := client.Comment.List(testCase.number)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ if !reflect.DeepEqual(comments, testCase.comments) {
+ t.Errorf("got %v but want %v", comments, testCase.comments)
+ }
+ }
+}
+
+func TestCommentDelete(t *testing.T) {
+ testCases := []struct {
+ config Config
+ id int
+ ok bool
+ }{
+ {
+ config: newFakeConfig(),
+ id: 1,
+ ok: true,
+ },
+ {
+ config: newFakeConfig(),
+ id: 12,
+ ok: true,
+ },
+ {
+ config: newFakeConfig(),
+ id: 123,
+ ok: true,
+ },
+ }
+
+ for _, testCase := range testCases {
+ client, err := NewClient(testCase.config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ err = client.Comment.Delete(testCase.id)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
+
+func TestCommentGetDuplicates(t *testing.T) {
+ api := newFakeAPI()
+ api.FakeIssuesListComments = func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
+ var comments []*github.IssueComment
+ comments = []*github.IssueComment{
+ &github.IssueComment{
+ ID: github.Int64(371748792),
+ Body: github.String("## Plan result\nfoo message\n"),
+ },
+ &github.IssueComment{
+ ID: github.Int64(371765743),
+ Body: github.String("## Plan result\nbar message\n"),
+ },
+ &github.IssueComment{
+ ID: github.Int64(371765744),
+ Body: github.String("## Plan result\nbaz message\n"),
+ },
+ }
+ return comments, nil, nil
+ }
+
+ testCases := []struct {
+ title string
+ message string
+ comments []*github.IssueComment
+ }{
+ {
+ title: "## Plan result",
+ message: "foo message",
+ comments: []*github.IssueComment{
+ &github.IssueComment{
+ ID: github.Int64(371748792),
+ Body: github.String("## Plan result\nfoo message\n"),
+ },
+ },
+ },
+ {
+ title: "## Plan result",
+ message: "hoge message",
+ comments: nil,
+ },
+ }
+
+ for _, testCase := range testCases {
+ cfg := newFakeConfig()
+ cfg.PR.Message = testCase.message
+ client, err := NewClient(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client.API = &api
+ comments := client.Comment.getDuplicates(testCase.title)
+ if !reflect.DeepEqual(comments, testCase.comments) {
+ t.Errorf("got %q but want %q", comments, testCase.comments)
+ }
+ }
+}
diff --git a/notifier/github/commits.go b/notifier/github/commits.go
new file mode 100644
index 0000000..dffa87b
--- /dev/null
+++ b/notifier/github/commits.go
@@ -0,0 +1,47 @@
+package github
+
+import (
+ "context"
+ "errors"
+
+ "github.com/google/go-github/github"
+)
+
+// CommitsService handles communication with the commits related
+// methods of GitHub API
+type CommitsService service
+
+// List lists commits on a repository
+func (g *CommitsService) List(revision string) ([]string, error) {
+ if revision == "" {
+ return []string{}, errors.New("no revision specified")
+ }
+ var s []string
+ commits, _, err := g.client.API.RepositoriesListCommits(
+ context.Background(),
+ &github.CommitsListOptions{SHA: revision},
+ )
+ if err != nil {
+ return s, err
+ }
+ for _, commit := range commits {
+ s = append(s, *commit.SHA)
+ }
+ return s, nil
+}
+
+// Last returns the hash of the previous commit of the given commit
+func (g *CommitsService) lastOne(commits []string, revision string) (string, error) {
+ if revision == "" {
+ return "", errors.New("no revision specified")
+ }
+ if len(commits) == 0 {
+ return "", errors.New("no commits")
+ }
+ // e.g.
+ // a0ce5bf 2018/04/05 20:50:01 (HEAD -> master, origin/master)
+ // 5166cfc 2018/04/05 20:40:12
+ // 74c4d6e 2018/04/05 20:34:31
+ // 9260c54 2018/04/05 20:16:20
+ return commits[1], nil
+}
diff --git a/notifier/github/commits_test.go b/notifier/github/commits_test.go
new file mode 100644
index 0000000..31f55e1
--- /dev/null
+++ b/notifier/github/commits_test.go
@@ -0,0 +1,91 @@
+package github
+
+import (
+ "testing"
+)
+
+func TestCommitsList(t *testing.T) {
+ testCases := []struct {
+ revision string
+ ok bool
+ }{
+ {
+ revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
+ ok: true,
+ },
+ {
+ revision: "",
+ ok: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ cfg := newFakeConfig()
+ client, err := NewClient(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ _, err = client.Commits.List(testCase.revision)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ }
+}
+
+func TestCommitsLastOne(t *testing.T) {
+ testCases := []struct {
+ commits []string
+ revision string
+ lastRev string
+ ok bool
+ }{
+ {
+ // ok
+ commits: []string{
+ "04e0917e448b662c2b16330fad50e97af16ff27a",
+ "04e0917e448b662c2b16330fad50e97af16ff27b",
+ "04e0917e448b662c2b16330fad50e97af16ff27c",
+ },
+ revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
+ lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b",
+ ok: true,
+ },
+ {
+ // no revision
+ commits: []string{
+ "04e0917e448b662c2b16330fad50e97af16ff27a",
+ "04e0917e448b662c2b16330fad50e97af16ff27b",
+ "04e0917e448b662c2b16330fad50e97af16ff27c",
+ },
+ revision: "",
+ lastRev: "",
+ ok: false,
+ },
+ {
+ // no commits
+ commits: []string{},
+ revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
+ lastRev: "",
+ ok: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ cfg := newFakeConfig()
+ client, err := NewClient(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ commit, err := client.Commits.lastOne(testCase.commits, testCase.revision)
+ if (err == nil) != testCase.ok {
+ t.Errorf("got error %q", err)
+ }
+ if commit != testCase.lastRev {
+ t.Errorf("got %q but want %q", commit, testCase.lastRev)
+ }
+ }
+}
diff --git a/notifier/github/github.go b/notifier/github/github.go
new file mode 100644
index 0000000..a4ee463
--- /dev/null
+++ b/notifier/github/github.go
@@ -0,0 +1,47 @@
+package github
+
+import (
+ "context"
+
+ "github.com/google/go-github/github"
+)
+
+// API is GitHub API interface
+type API interface {
+ IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
+ IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error)
+ IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
+ RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
+ RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
+}
+
+// GitHub represents the attribute information necessary for requesting GitHub API
+type GitHub struct {
+ *github.Client
+ owner, repo string
+}
+
+// IssuesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.CreateComment
+func (g *GitHub) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
+ return g.Client.Issues.CreateComment(ctx, g.owner, g.repo, number, comment)
+}
+
+// IssuesDeleteComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.DeleteComment
+func (g *GitHub) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) {
+ return g.Client.Issues.DeleteComment(ctx, g.owner, g.repo, int(commentID))
+}
+
+// IssuesListComments is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListComments
+func (g *GitHub) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
+ return g.Client.Issues.ListComments(ctx, g.owner, g.repo, number, opt)
+}
+
+// RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment
+func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
+ return g.Client.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment)
+}
+
+// RepositoriesListCommits is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.ListCommits
+func (g *GitHub) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
+ return g.Client.Repositories.ListCommits(ctx, g.owner, g.repo, opt)
+}
diff --git a/notifier/github/github_test.go b/notifier/github/github_test.go
new file mode 100644
index 0000000..98cfdf4
--- /dev/null
+++ b/notifier/github/github_test.go
@@ -0,0 +1,102 @@
+package github
+
+import (
+ "context"
+
+ "github.com/google/go-github/github"
+ "github.com/mercari/tfnotify/terraform"
+)
+
+type fakeAPI struct {
+ API
+ FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
+ FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error)
+ FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
+ FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
+ FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
+}
+
+func (g *fakeAPI) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
+ return g.FakeIssuesCreateComment(ctx, number, comment)
+}
+
+func (g *fakeAPI) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) {
+ return g.FakeIssuesDeleteComment(ctx, commentID)
+}
+
+func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
+ return g.FakeIssuesListComments(ctx, number, opt)
+}
+
+func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
+ return g.FakeRepositoriesCreateComment(ctx, sha, comment)
+}
+
+func (g *fakeAPI) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
+ return g.FakeRepositoriesListCommits(ctx, opt)
+}
+
+func newFakeAPI() fakeAPI {
+ return fakeAPI{
+ FakeIssuesCreateComment: func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
+ return &github.IssueComment{
+ ID: github.Int64(371748792),
+ Body: github.String("comment 1"),
+ }, nil, nil
+ },
+ FakeIssuesDeleteComment: func(ctx context.Context, commentID int64) (*github.Response, error) {
+ return nil, nil
+ },
+ FakeIssuesListComments: func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
+ var comments []*github.IssueComment
+ comments = []*github.IssueComment{
+ &github.IssueComment{
+ ID: github.Int64(371748792),
+ Body: github.String("comment 1"),
+ },
+ &github.IssueComment{
+ ID: github.Int64(371765743),
+ Body: github.String("comment 2"),
+ },
+ }
+ return comments, nil, nil
+ },
+ FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
+ return &github.RepositoryComment{
+ ID: github.Int64(28427394),
+ CommitID: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"),
+ Body: github.String("comment 1"),
+ }, nil, nil
+ },
+ FakeRepositoriesListCommits: func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
+ var commits []*github.RepositoryCommit
+ commits = []*github.RepositoryCommit{
+ &github.RepositoryCommit{
+ SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"),
+ },
+ &github.RepositoryCommit{
+ SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27b"),
+ },
+ &github.RepositoryCommit{
+ SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27c"),
+ },
+ }
+ return commits, nil, nil
+ },
+ }
+}
+
+func newFakeConfig() Config {
+ return Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "abcd",
+ Number: 1,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ }
+}
diff --git a/notifier/github/notify.go b/notifier/github/notify.go
new file mode 100644
index 0000000..cc19cba
--- /dev/null
+++ b/notifier/github/notify.go
@@ -0,0 +1,55 @@
+package github
+
+import (
+ "github.com/mercari/tfnotify/terraform"
+)
+
+// NotifyService handles communication with the notification related
+// methods of GitHub API
+type NotifyService service
+
+// Notify posts comment optimized for notifications
+func (g *NotifyService) Notify(body string) (exit int, err error) {
+ cfg := g.client.Config
+ parser := g.client.Config.Parser
+ template := g.client.Config.Template
+
+ 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{
+ Message: cfg.PR.Message,
+ Result: result.Result,
+ Body: body,
+ })
+ body, err = template.Execute()
+ if err != nil {
+ return result.ExitCode, err
+ }
+
+ value := template.GetValue()
+
+ if cfg.PR.IsNumber() {
+ g.client.Comment.DeleteDuplicates(value.Title)
+ }
+
+ _, isApply := parser.(*terraform.ApplyParser)
+ if !cfg.PR.IsNumber() && isApply {
+ commits, err := g.client.Commits.List(cfg.PR.Revision)
+ if err != nil {
+ return result.ExitCode, err
+ }
+ lastRevision, _ := g.client.Commits.lastOne(commits, cfg.PR.Revision)
+ cfg.PR.Revision = lastRevision
+ }
+
+ return result.ExitCode, g.client.Comment.Post(body, PostOptions{
+ Number: cfg.PR.Number,
+ Revision: cfg.PR.Revision,
+ })
+}
diff --git a/notifier/github/notify_test.go b/notifier/github/notify_test.go
new file mode 100644
index 0000000..7f7bb32
--- /dev/null
+++ b/notifier/github/notify_test.go
@@ -0,0 +1,141 @@
+package github
+
+import (
+ "testing"
+
+ "github.com/mercari/tfnotify/terraform"
+)
+
+func TestNotifyNotify(t *testing.T) {
+ testCases := []struct {
+ config Config
+ body string
+ ok bool
+ exitCode int
+ }{
+ {
+ // invalid body (cannot parse)
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "abcd",
+ Number: 1,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "body",
+ ok: false,
+ exitCode: 1,
+ },
+ {
+ // invalid pr
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "",
+ Number: 0,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Plan: 1 to add",
+ ok: false,
+ exitCode: 0,
+ },
+ {
+ // valid, error
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "",
+ Number: 1,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Error: hoge",
+ ok: true,
+ exitCode: 1,
+ },
+ {
+ // valid, and isPR
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "",
+ Number: 1,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Plan: 1 to add",
+ ok: true,
+ exitCode: 0,
+ },
+ {
+ // valid, and isRevision
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "revision-revision",
+ Number: 0,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Plan: 1 to add",
+ ok: true,
+ exitCode: 0,
+ },
+ {
+ // apply case
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "revision",
+ Number: 0, // For apply, it is always 0
+ Message: "message",
+ },
+ Parser: terraform.NewApplyParser(),
+ Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate),
+ },
+ body: "Apply complete!",
+ ok: true,
+ exitCode: 0,
+ },
+ }
+
+ for _, testCase := range testCases {
+ client, err := NewClient(testCase.config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := newFakeAPI()
+ client.API = &api
+ 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/notifier.go b/notifier/notifier.go
new file mode 100644
index 0000000..f6488ea
--- /dev/null
+++ b/notifier/notifier.go
@@ -0,0 +1,6 @@
+package notifier
+
+// Notifier is a notification interface
+type Notifier interface {
+ Notify(body string) (exit int, err error)
+}
diff --git a/notifier/notifier_test.go b/notifier/notifier_test.go
new file mode 100644
index 0000000..ed45f23
--- /dev/null
+++ b/notifier/notifier_test.go
@@ -0,0 +1 @@
+package notifier
diff --git a/notifier/slack/client.go b/notifier/slack/client.go
new file mode 100644
index 0000000..6736feb
--- /dev/null
+++ b/notifier/slack/client.go
@@ -0,0 +1,66 @@
+package slack
+
+import (
+ "errors"
+ "os"
+ "strings"
+
+ "github.com/mercari/tfnotify/terraform"
+ "github.com/lestrrat-go/slack"
+)
+
+// EnvToken is Slack API Token
+const EnvToken = "SLACK_TOKEN"
+
+// Client is a API client for Slack
+type Client struct {
+ *slack.Client
+
+ Config Config
+
+ common service
+
+ Notify *NotifyService
+
+ API API
+}
+
+// Config is a configuration for GitHub client
+type Config struct {
+ Token string
+ Channel string
+ Botname 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 := cfg.Token
+ token = strings.TrimPrefix(token, "$")
+ if token == EnvToken {
+ token = os.Getenv(EnvToken)
+ }
+ if token == "" {
+ return &Client{}, errors.New("slack token is missing")
+ }
+ client := slack.New(token)
+ c := &Client{
+ Config: cfg,
+ Client: client,
+ }
+ c.common.client = c
+ c.Notify = (*NotifyService)(&c.common)
+ c.API = &Slack{
+ Client: client,
+ Channel: cfg.Channel,
+ Botname: cfg.Botname,
+ }
+ return c, nil
+}
diff --git a/notifier/slack/client_test.go b/notifier/slack/client_test.go
new file mode 100644
index 0000000..b925107
--- /dev/null
+++ b/notifier/slack/client_test.go
@@ -0,0 +1,73 @@
+package slack
+
+import (
+ "os"
+ "testing"
+)
+
+func TestNewClient(t *testing.T) {
+ slackToken := os.Getenv(EnvToken)
+ defer func() {
+ os.Setenv(EnvToken, slackToken)
+ }()
+ os.Setenv(EnvToken, "")
+
+ testCases := []struct {
+ config Config
+ envToken string
+ expect string
+ }{
+ {
+ // specify directly
+ config: Config{Token: "abcdefg"},
+ envToken: "",
+ expect: "",
+ },
+ {
+ // specify via env but not to be set env (part 1)
+ config: Config{Token: "SLACK_TOKEN"},
+ envToken: "",
+ expect: "slack token is missing",
+ },
+ {
+ // specify via env (part 1)
+ config: Config{Token: "SLACK_TOKEN"},
+ envToken: "abcdefg",
+ expect: "",
+ },
+ {
+ // specify via env but not to be set env (part 2)
+ config: Config{Token: "$SLACK_TOKEN"},
+ envToken: "",
+ expect: "slack token is missing",
+ },
+ {
+ // specify via env (part 2)
+ config: Config{Token: "$SLACK_TOKEN"},
+ envToken: "abcdefg",
+ expect: "",
+ },
+ {
+ // no specification (part 1)
+ config: Config{},
+ envToken: "",
+ expect: "slack token is missing",
+ },
+ {
+ // no specification (part 2)
+ config: Config{},
+ envToken: "abcdefg",
+ expect: "slack 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/slack/notify.go b/notifier/slack/notify.go
new file mode 100644
index 0000000..5076b75
--- /dev/null
+++ b/notifier/slack/notify.go
@@ -0,0 +1,64 @@
+package slack
+
+import (
+ "context"
+ "errors"
+
+ "github.com/mercari/tfnotify/terraform"
+ "github.com/lestrrat-go/slack/objects"
+)
+
+// NotifyService handles communication with the notification related
+// methods of Slack API
+type NotifyService service
+
+// Notify posts comment optimized for notifications
+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.Channel == "" {
+ return terraform.ExitFail, errors.New("channel id is required")
+ }
+
+ result := parser.Parse(body)
+ if result.Error != nil {
+ return result.ExitCode, result.Error
+ }
+ if result.Result == "" {
+ return result.ExitCode, result.Error
+ }
+
+ color := "warning"
+ switch result.ExitCode {
+ case terraform.ExitPass:
+ color = "good"
+ case terraform.ExitFail:
+ color = "danger"
+ }
+
+ template.SetValue(terraform.CommonTemplate{
+ Message: cfg.Message,
+ Result: result.Result,
+ Body: body,
+ })
+ text, err := template.Execute()
+ if err != nil {
+ return result.ExitCode, err
+ }
+
+ var attachments objects.AttachmentList
+ attachment := &objects.Attachment{
+ Color: color,
+ Fallback: text,
+ Footer: cfg.CI,
+ Text: text,
+ Title: template.GetValue().Title,
+ }
+
+ attachments.Append(attachment)
+ // _, err = s.client.Chat().PostMessage(cfg.Channel).Username(cfg.Botname).SetAttachments(attachments).Do(cfg.Context)
+ _, err = s.client.API.ChatPostMessage(context.Background(), attachments)
+ return result.ExitCode, err
+}
diff --git a/notifier/slack/notify_test.go b/notifier/slack/notify_test.go
new file mode 100644
index 0000000..1c49cc0
--- /dev/null
+++ b/notifier/slack/notify_test.go
@@ -0,0 +1,65 @@
+package slack
+
+import (
+ "context"
+ "testing"
+
+ "github.com/mercari/tfnotify/terraform"
+ "github.com/lestrrat-go/slack/objects"
+)
+
+func TestNotify(t *testing.T) {
+ testCases := []struct {
+ config Config
+ body string
+ exitCode int
+ ok bool
+ }{
+ {
+ config: Config{
+ Token: "token",
+ Channel: "channel",
+ Botname: "botname",
+ Message: "",
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Plan: 1 to add",
+ exitCode: 0,
+ ok: true,
+ },
+ {
+ config: Config{
+ Token: "token",
+ Channel: "",
+ Botname: "botname",
+ Message: "",
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ },
+ body: "Plan: 1 to add",
+ exitCode: 1,
+ ok: false,
+ },
+ }
+ fake := fakeAPI{
+ FakeChatPostMessage: func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
+ return 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/slack/slack.go b/notifier/slack/slack.go
new file mode 100644
index 0000000..65e1def
--- /dev/null
+++ b/notifier/slack/slack.go
@@ -0,0 +1,25 @@
+package slack
+
+import (
+ "context"
+
+ "github.com/lestrrat-go/slack"
+ "github.com/lestrrat-go/slack/objects"
+)
+
+// API is Slack API interface
+type API interface {
+ ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error)
+}
+
+// Slack represents the attribute information necessary for requesting Slack API
+type Slack struct {
+ *slack.Client
+ Channel string
+ Botname string
+}
+
+// ChatPostMessage is a wrapper of https://godoc.org/github.com/lestrrat-go/slack#ChatPostMessageCall
+func (s *Slack) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
+ return s.Client.Chat().PostMessage(s.Channel).Username(s.Botname).SetAttachments(attachments).Do(ctx)
+}
diff --git a/notifier/slack/slack_test.go b/notifier/slack/slack_test.go
new file mode 100644
index 0000000..f23a556
--- /dev/null
+++ b/notifier/slack/slack_test.go
@@ -0,0 +1,16 @@
+package slack
+
+import (
+ "context"
+
+ "github.com/lestrrat-go/slack/objects"
+)
+
+type fakeAPI struct {
+ API
+ FakeChatPostMessage func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error)
+}
+
+func (g *fakeAPI) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
+ return g.FakeChatPostMessage(ctx, attachments)
+}
diff --git a/tee.go b/tee.go
new file mode 100644
index 0000000..ef858da
--- /dev/null
+++ b/tee.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+
+ "github.com/mattn/go-colorable"
+)
+
+func tee(stdin io.Reader, stdout io.Writer) string {
+ var b1 bytes.Buffer
+ var b2 bytes.Buffer
+
+ tee := io.TeeReader(stdin, &b1)
+ s := bufio.NewScanner(tee)
+ for s.Scan() {
+ fmt.Fprintln(stdout, s.Text())
+ }
+
+ uncolorize := colorable.NewNonColorable(&b2)
+ uncolorize.Write(b1.Bytes())
+
+ return b2.String()
+}
diff --git a/tee_test.go b/tee_test.go
new file mode 100644
index 0000000..075180d
--- /dev/null
+++ b/tee_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "testing"
+)
+
+func TestTee(t *testing.T) {
+ testCases := []struct {
+ stdin io.Reader
+ stdout string
+ body string
+ }{
+ {
+ // Regular
+ stdin: bytes.NewBufferString("Plan: 1 to add\n"),
+ stdout: "Plan: 1 to add\n",
+ body: "Plan: 1 to add\n",
+ },
+ {
+ // ANSI color codes are included
+ stdin: bytes.NewBufferString("\033[mPlan: 1 to add\033[m\n"),
+ stdout: "\033[mPlan: 1 to add\033[m\n",
+ body: "Plan: 1 to add\n",
+ },
+ }
+
+ for _, testCase := range testCases {
+ stdout := new(bytes.Buffer)
+ body := tee(testCase.stdin, stdout)
+ if body != testCase.body {
+ t.Errorf("got %q but want %q", body, testCase.body)
+ }
+ if stdout.String() != testCase.stdout {
+ t.Errorf("got %q but want %q", stdout.String(), testCase.stdout)
+ }
+ }
+}
diff --git a/terraform/parser.go b/terraform/parser.go
new file mode 100644
index 0000000..b70594f
--- /dev/null
+++ b/terraform/parser.go
@@ -0,0 +1,160 @@
+package terraform
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Parser is an interface for parsing terraform execution result
+type Parser interface {
+ Parse(body string) ParseResult
+}
+
+// ParseResult represents the result of parsed terraform execution
+type ParseResult struct {
+ Result string
+ ExitCode int
+ Error error
+}
+
+// DefaultParser is a parser for terraform commands
+type DefaultParser struct {
+}
+
+// FmtParser is a parser for terraform fmt
+type FmtParser struct {
+ Pass *regexp.Regexp
+ Fail *regexp.Regexp
+}
+
+// PlanParser is a parser for terraform plan
+type PlanParser struct {
+ Pass *regexp.Regexp
+ Fail *regexp.Regexp
+}
+
+// ApplyParser is a parser for terraform apply
+type ApplyParser struct {
+ Pass *regexp.Regexp
+ Fail *regexp.Regexp
+}
+
+// NewDefaultParser is DefaultParser initializer
+func NewDefaultParser() *DefaultParser {
+ return &DefaultParser{}
+}
+
+// NewFmtParser is FmtParser initialized with its Regexp
+func NewFmtParser() *FmtParser {
+ return &FmtParser{
+ Fail: regexp.MustCompile(`(?m)^(diff a/)`),
+ }
+}
+
+// NewPlanParser is PlanParser initialized with its Regexp
+func NewPlanParser() *PlanParser {
+ return &PlanParser{
+ Pass: regexp.MustCompile(`(?m)^(Plan: \d|No changes.)`),
+ Fail: regexp.MustCompile(`(?m)^(Error: )`),
+ }
+}
+
+// NewApplyParser is ApplyParser initialized with its Regexp
+func NewApplyParser() *ApplyParser {
+ return &ApplyParser{
+ Pass: regexp.MustCompile(`(?m)^(Apply complete!)`),
+ Fail: regexp.MustCompile(`(?m)^(Error: Error applying plan:)`),
+ }
+}
+
+// Parse returns ParseResult related with terraform commands
+func (p *DefaultParser) Parse(body string) ParseResult {
+ return ParseResult{
+ Result: body,
+ ExitCode: ExitPass,
+ Error: nil,
+ }
+}
+
+// Parse returns ParseResult related with terraform fmt
+func (p *FmtParser) Parse(body string) ParseResult {
+ result := ParseResult{}
+ if p.Fail.MatchString(body) {
+ result.Result = "There is diff in your .tf file (need to be formatted)"
+ result.ExitCode = ExitFail
+ }
+ return result
+}
+
+// Parse returns ParseResult related with terraform plan
+func (p *PlanParser) Parse(body string) ParseResult {
+ var exitCode int
+ switch {
+ case p.Pass.MatchString(body):
+ exitCode = ExitPass
+ case p.Fail.MatchString(body):
+ exitCode = ExitFail
+ default:
+ return ParseResult{
+ Result: "",
+ ExitCode: ExitFail,
+ Error: fmt.Errorf("cannot parse plan result"),
+ }
+ }
+ var result string
+ lines := strings.Split(body, "\n")
+ for _, line := range lines {
+ if p.Pass.MatchString(line) || p.Fail.MatchString(line) {
+ result = line
+ }
+ }
+ return ParseResult{
+ Result: result,
+ ExitCode: exitCode,
+ Error: nil,
+ }
+}
+
+// Parse returns ParseResult related with terraform apply
+func (p *ApplyParser) Parse(body string) ParseResult {
+ var exitCode int
+ switch {
+ case p.Pass.MatchString(body):
+ exitCode = ExitPass
+ case p.Fail.MatchString(body):
+ exitCode = ExitFail
+ default:
+ return ParseResult{
+ Result: "",
+ ExitCode: ExitFail,
+ Error: fmt.Errorf("cannot parse apply result"),
+ }
+ }
+ var result string
+ lines := strings.Split(body, "\n")
+ var i int
+ for idx, line := range lines {
+ if p.Pass.MatchString(line) || p.Fail.MatchString(line) {
+ i = idx
+ break
+ }
+ }
+ result = strings.Join(trimLastNewline(lines[i:]), "\n")
+ return ParseResult{
+ Result: result,
+ ExitCode: exitCode,
+ Error: nil,
+ }
+}
+
+func trimLastNewline(s []string) []string {
+ if len(s) == 0 {
+ return s
+ }
+ last := len(s) - 1
+ if s[last] == "" {
+ return s[:last]
+ }
+ return s
+}
diff --git a/terraform/parser_test.go b/terraform/parser_test.go
new file mode 100644
index 0000000..c38f07e
--- /dev/null
+++ b/terraform/parser_test.go
@@ -0,0 +1,378 @@
+package terraform
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+const fmtSuccessResult = `
+google_spanner_database.tf
+diff a/google_spanner_database.tf b/google_spanner_database.tf
+--- /tmp/398669432
++++ /tmp/536670071
+@@ -9,3 +9,4 @@
+ # instance = "${google_spanner_instance.my_service_dev.name}"
+ # name = "my-service-dev"
+ # }
++
+
+google_spanner_instance.tf
+diff a/google_spanner_instance.tf b/google_spanner_instance.tf
+--- /tmp/314409578
++++ /tmp/686207681
+@@ -13,3 +13,4 @@
+ # name = "my-service-dev"
+ # num_nodes = 1
+ # }
++
+`
+
+const planSuccessResult = `
+Refreshing Terraform state in-memory prior to plan...
+The refreshed state will be used to calculate this plan, but will not be
+persisted to local or remote state storage.
+
+data.terraform_remote_state.teams_platform_development: Refreshing state...
+google_project.my_project: Refreshing state...
+aws_iam_policy.datadog_aws_integration: Refreshing state...
+aws_iam_user.teams_terraform: Refreshing state...
+aws_iam_role.datadog_aws_integration: Refreshing state...
+google_project_services.my_project: Refreshing state...
+google_bigquery_dataset.gateway_access_log: Refreshing state...
+aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state...
+google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state...
+google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state...
+google_dns_managed_zone.tfnotifyapps_com: Refreshing state...
+google_dns_record_set.dev_tfnotifyapps_com: Refreshing state...
+
+------------------------------------------------------------------------
+
+An execution plan has been generated and is shown below.
+Resource actions are indicated with the following symbols:
+ + create
+
+Terraform will perform the following actions:
+
+ + google_compute_global_address.my_another_project
+ id:
+ address:
+ ip_version: "IPV4"
+ name: "my-another-project"
+ project: "my-project"
+ self_link:
+
+
+Plan: 1 to add, 0 to change, 0 to destroy.
+
+------------------------------------------------------------------------
+
+Note: You didn't specify an "-out" parameter to save this plan, so Terraform
+can't guarantee that exactly these actions will be performed if
+"terraform apply" is subsequently run.
+`
+
+const planFailureResult = `
+xxxxxxxxx
+xxxxxxxxx
+xxxxxxxxx
+Error: Required variable not set: my_service_dev_google_sql_user_proxyuser_password
+`
+
+const planNoChanges = `
+google_bigquery_dataset.tfnotify_echo: Refreshing state...
+google_project.team: Refreshing state...
+pagerduty_team.team: Refreshing state...
+data.pagerduty_vendor.datadog: Refreshing state...
+data.pagerduty_user.service_owner[1]: Refreshing state...
+data.pagerduty_user.service_owner[2]: Refreshing state...
+data.pagerduty_user.service_owner[0]: Refreshing state...
+google_project_services.team: Refreshing state...
+google_project_iam_member.team[1]: Refreshing state...
+google_project_iam_member.team[2]: Refreshing state...
+google_project_iam_member.team[0]: Refreshing state...
+google_project_iam_member.team_platform[1]: Refreshing state...
+google_project_iam_member.team_platform[2]: Refreshing state...
+google_project_iam_member.team_platform[0]: Refreshing state...
+pagerduty_team_membership.team[2]: Refreshing state...
+pagerduty_schedule.secondary: Refreshing state...
+pagerduty_schedule.primary: Refreshing state...
+pagerduty_team_membership.team[0]: Refreshing state...
+pagerduty_team_membership.team[1]: Refreshing state...
+pagerduty_escalation_policy.team: Refreshing state...
+pagerduty_service.team: Refreshing state...
+pagerduty_service_integration.datadog: Refreshing state...
+
+------------------------------------------------------------------------
+
+No changes. Infrastructure is up-to-date.
+
+This means that Terraform did not detect any differences between your
+configuration and real physical resources that exist. As a result, no
+actions need to be performed.
+`
+
+const applySuccessResult = `
+data.terraform_remote_state.teams_platform_development: Refreshing state...
+google_project.my_service: Refreshing state...
+google_storage_bucket.chartmuseum: Refreshing state...
+google_storage_bucket.ark_tfnotify_prod: Refreshing state...
+google_bigquery_dataset.gateway_access_log: Refreshing state...
+google_compute_global_address.chartmuseum_tfnotifyapps_com: Refreshing state...
+google_compute_global_address.reviews_web_tfnotify_in: Refreshing state...
+google_compute_global_address.reviews_api_tfnotify_in: Refreshing state...
+google_compute_global_address.teams_web_tfnotify_in: Refreshing state...
+google_project_services.my_service: Refreshing state...
+google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state...
+google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state...
+aws_s3_bucket.teams_terraform_private_modules: Refreshing state...
+aws_iam_role.datadog_aws_integration: Refreshing state...
+aws_s3_bucket.terraform_backend: Refreshing state...
+aws_iam_user.teams_terraform: Refreshing state...
+aws_iam_policy.datadog_aws_integration: Refreshing state...
+aws_iam_user_policy.teams_terraform: Refreshing state...
+aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state...
+google_dns_managed_zone.tfnotifyapps_com: Refreshing state...
+google_dns_record_set.dev_tfnotifyapps_com: Refreshing state...
+
+Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
+`
+
+const applyFailureResult = `
+data.terraform_remote_state.teams_platform_development: Refreshing state...
+google_project.tfnotify_jp_tfnotify_prod: Refreshing state...
+google_project_services.tfnotify_jp_tfnotify_prod: Refreshing state...
+google_bigquery_dataset.gateway_access_log: Refreshing state...
+google_compute_global_address.reviews_web_tfnotify_in: Refreshing state...
+google_compute_global_address.chartmuseum_tfnotifyapps_com: Refreshing state...
+google_storage_bucket.chartmuseum: Refreshing state...
+google_storage_bucket.ark_tfnotify_prod: Refreshing state...
+google_compute_global_address.reviews_api_tfnotify_in: Refreshing state...
+google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state...
+google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state...
+aws_s3_bucket.terraform_backend: Refreshing state...
+aws_s3_bucket.teams_terraform_private_modules: Refreshing state...
+aws_iam_policy.datadog_aws_integration: Refreshing state...
+aws_iam_role.datadog_aws_integration: Refreshing state...
+aws_iam_user.teams_terraform: Refreshing state...
+aws_iam_user_policy.teams_terraform: Refreshing state...
+aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state...
+google_dns_managed_zone.tfnotifyapps_com: Refreshing state...
+google_dns_record_set.dev_tfnotifyapps_com: Refreshing state...
+google_compute_global_address.teams_web_tfnotify_in: Creating...
+ address: "" => ""
+ ip_version: "" => "IPV4"
+ name: "" => "web-tfnotify-in"
+ project: "" => "tfnotify-jp-tfnotify-prod"
+ self_link: "" => ""
+
+Error: Error applying plan:
+
+1 error(s) occurred:
+
+* google_compute_global_address.teams_web_tfnotify_in: 1 error(s) occurred:
+
+* google_compute_global_address.teams_web_tfnotify_in: Error creating address: googleapi: Error 409: The resource 'projects/tfnotify-jp-tfnotify-prod/global/addresses/teams-web-tfnotify-in' already exists, alreadyExists
+
+Terraform does not automatically rollback in the face of errors.
+Instead, your Terraform state file has been partially updated with
+any resources that successfully completed. Please address the error
+above and apply again to incrementally change your infrastructure.
+`
+
+func TestDefaultParserParse(t *testing.T) {
+ testCases := []struct {
+ body string
+ result ParseResult
+ }{
+ {
+ body: "",
+ result: ParseResult{
+ Result: "",
+ ExitCode: 0,
+ Error: nil,
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ result := NewDefaultParser().Parse(testCase.body)
+ if !reflect.DeepEqual(result, testCase.result) {
+ t.Errorf("got %v but want %v", result, testCase.result)
+ }
+ }
+}
+
+func TestFmtParserParse(t *testing.T) {
+ testCases := []struct {
+ name string
+ body string
+ result ParseResult
+ }{
+ {
+ name: "diff",
+ body: fmtSuccessResult,
+ result: ParseResult{
+ Result: "There is diff in your .tf file (need to be formatted)",
+ ExitCode: 1,
+ Error: nil,
+ },
+ },
+ {
+ name: "no stdin",
+ body: "",
+ result: ParseResult{
+ Result: "",
+ ExitCode: 0,
+ Error: nil,
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ result := NewFmtParser().Parse(testCase.body)
+ if !reflect.DeepEqual(result, testCase.result) {
+ t.Errorf("got %v but want %v", result, testCase.result)
+ }
+ }
+}
+
+func TestPlanParserParse(t *testing.T) {
+ testCases := []struct {
+ name string
+ body string
+ result ParseResult
+ }{
+ {
+ name: "plan ok pattern",
+ body: planSuccessResult,
+ result: ParseResult{
+ Result: "Plan: 1 to add, 0 to change, 0 to destroy.",
+ ExitCode: 0,
+ Error: nil,
+ },
+ },
+ {
+ name: "no stdin",
+ body: "",
+ result: ParseResult{
+ Result: "",
+ ExitCode: 1,
+ Error: errors.New("cannot parse plan result"),
+ },
+ },
+ {
+ name: "plan ng pattern",
+ body: planFailureResult,
+ result: ParseResult{
+ Result: "Error: Required variable not set: my_service_dev_google_sql_user_proxyuser_password",
+ ExitCode: 1,
+ Error: nil,
+ },
+ },
+ {
+ name: "plan no changes",
+ body: planNoChanges,
+ result: ParseResult{
+ Result: "No changes. Infrastructure is up-to-date.",
+ ExitCode: 0,
+ Error: nil,
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ result := NewPlanParser().Parse(testCase.body)
+ if !reflect.DeepEqual(result, testCase.result) {
+ t.Errorf("got %v but want %v", result, testCase.result)
+ }
+ }
+}
+
+func TestApplyParserParse(t *testing.T) {
+ testCases := []struct {
+ name string
+ body string
+ result ParseResult
+ }{
+ {
+ name: "no stdin",
+ body: "",
+ result: ParseResult{
+ Result: "",
+ ExitCode: 1,
+ Error: errors.New("cannot parse apply result"),
+ },
+ },
+ {
+ name: "apply ok pattern",
+ body: applySuccessResult,
+ result: ParseResult{
+ Result: "Apply complete! Resources: 0 added, 0 changed, 0 destroyed.",
+ ExitCode: 0,
+ Error: nil,
+ },
+ },
+ {
+ name: "apply ng pattern",
+ body: applyFailureResult,
+ result: ParseResult{
+ Result: `Error: Error applying plan:
+
+1 error(s) occurred:
+
+* google_compute_global_address.teams_web_tfnotify_in: 1 error(s) occurred:
+
+* google_compute_global_address.teams_web_tfnotify_in: Error creating address: googleapi: Error 409: The resource 'projects/tfnotify-jp-tfnotify-prod/global/addresses/teams-web-tfnotify-in' already exists, alreadyExists
+
+Terraform does not automatically rollback in the face of errors.
+Instead, your Terraform state file has been partially updated with
+any resources that successfully completed. Please address the error
+above and apply again to incrementally change your infrastructure.`,
+ ExitCode: 1,
+ Error: nil,
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ result := NewApplyParser().Parse(testCase.body)
+ if !reflect.DeepEqual(result, testCase.result) {
+ t.Errorf("got %v but want %v", result, testCase.result)
+ }
+ }
+}
+
+func TestTrimLastNewline(t *testing.T) {
+ testCases := []struct {
+ data []string
+ expected []string
+ }{
+ {
+ data: []string{},
+ expected: []string{},
+ },
+ {
+ data: []string{"a", "b", "c", ""},
+ expected: []string{"a", "b", "c"},
+ },
+ {
+ data: []string{"a", ""},
+ expected: []string{"a"},
+ },
+ {
+ data: []string{""},
+ expected: []string{},
+ },
+ {
+ data: []string{"a", "b", "c"},
+ expected: []string{"a", "b", "c"},
+ },
+ {
+ data: []string{"a"},
+ expected: []string{"a"},
+ },
+ }
+ for _, testCase := range testCases {
+ actual := trimLastNewline(testCase.data)
+ if !reflect.DeepEqual(actual, testCase.expected) {
+ t.Errorf("got %v but want %v", actual, testCase.expected)
+ }
+ }
+}
diff --git a/terraform/template.go b/terraform/template.go
new file mode 100644
index 0000000..35ebef3
--- /dev/null
+++ b/terraform/template.go
@@ -0,0 +1,287 @@
+package terraform
+
+import (
+ "bytes"
+ "html/template"
+)
+
+const (
+ // DefaultDefaultTitle is a default title for terraform commands
+ DefaultDefaultTitle = "## Terraform result"
+ // DefaultFmtTitle is a default title for terraform fmt
+ DefaultFmtTitle = "## Fmt result"
+ // DefaultPlanTitle is a default title for terraform plan
+ DefaultPlanTitle = "## Plan result"
+ // DefaultApplyTitle is a default title for terraform apply
+ DefaultApplyTitle = "## Apply result"
+
+ // DefaultDefaultTemplate is a default template for terraform commands
+ DefaultDefaultTemplate = `
+{{ .Title }}
+
+{{ .Message }}
+
+{{if .Result}}
+{{ .Result }}
+
+{{end}}
+
+Details (Click me)
+{{ .Body }}
+
+`
+
+ // DefaultFmtTemplate is a default template for terraform fmt
+ DefaultFmtTemplate = `
+{{ .Title }}
+
+{{ .Message }}
+
+{{ .Result }}
+
+{{ .Body }}
+`
+
+ // DefaultPlanTemplate is a default template for terraform plan
+ DefaultPlanTemplate = `
+{{ .Title }}
+
+{{ .Message }}
+
+{{if .Result}}
+{{ .Result }}
+
+{{end}}
+
+Details (Click me)
+{{ .Body }}
+
+`
+
+ // DefaultApplyTemplate is a default template for terraform apply
+ DefaultApplyTemplate = `
+{{ .Title }}
+
+{{ .Message }}
+
+{{if .Result}}
+{{ .Result }}
+
+{{end}}
+
+Details (Click me)
+{{ .Body }}
+
+`
+)
+
+// Template is an template interface for parsed terraform execution result
+type Template interface {
+ Execute() (resp string, err error)
+ SetValue(template CommonTemplate)
+ GetValue() CommonTemplate
+}
+
+// CommonTemplate represents template entities
+type CommonTemplate struct {
+ Title string
+ Message string
+ Result string
+ Body string
+}
+
+// DefaultTemplate is a default template for terraform commands
+type DefaultTemplate struct {
+ Template string
+
+ CommonTemplate
+}
+
+// FmtTemplate is a default template for terraform fmt
+type FmtTemplate struct {
+ Template string
+
+ CommonTemplate
+}
+
+// PlanTemplate is a default template for terraform plan
+type PlanTemplate struct {
+ Template string
+
+ CommonTemplate
+}
+
+// ApplyTemplate is a default template for terraform apply
+type ApplyTemplate struct {
+ Template string
+
+ CommonTemplate
+}
+
+// NewDefaultTemplate is DefaultTemplate initializer
+func NewDefaultTemplate(template string) *DefaultTemplate {
+ if template == "" {
+ template = DefaultDefaultTemplate
+ }
+ return &DefaultTemplate{
+ Template: template,
+ }
+}
+
+// NewFmtTemplate is FmtTemplate initializer
+func NewFmtTemplate(template string) *FmtTemplate {
+ if template == "" {
+ template = DefaultFmtTemplate
+ }
+ return &FmtTemplate{
+ Template: template,
+ }
+}
+
+// NewPlanTemplate is PlanTemplate initializer
+func NewPlanTemplate(template string) *PlanTemplate {
+ if template == "" {
+ template = DefaultPlanTemplate
+ }
+ return &PlanTemplate{
+ Template: template,
+ }
+}
+
+// NewApplyTemplate is ApplyTemplate initializer
+func NewApplyTemplate(template string) *ApplyTemplate {
+ if template == "" {
+ template = DefaultApplyTemplate
+ }
+ return &ApplyTemplate{
+ Template: template,
+ }
+}
+
+// Execute binds the execution result of terraform command into tepmlate
+func (t *DefaultTemplate) Execute() (resp string, err error) {
+ tpl, err := template.New("default").Parse(t.Template)
+ if err != nil {
+ return resp, err
+ }
+ var b bytes.Buffer
+ if err := tpl.Execute(&b, map[string]interface{}{
+ "Title": t.Title,
+ "Message": t.Message,
+ "Result": "",
+ "Body": t.Result,
+ }); err != nil {
+ return resp, err
+ }
+ resp = b.String()
+ return resp, err
+}
+
+// Execute binds the execution result of terraform fmt into tepmlate
+func (t *FmtTemplate) Execute() (resp string, err error) {
+ tpl, err := template.New("fmt").Parse(t.Template)
+ if err != nil {
+ return resp, err
+ }
+ var b bytes.Buffer
+ if err := tpl.Execute(&b, map[string]interface{}{
+ "Title": t.Title,
+ "Message": t.Message,
+ "Result": "",
+ "Body": t.Result,
+ }); err != nil {
+ return resp, err
+ }
+ resp = b.String()
+ return resp, err
+}
+
+// Execute binds the execution result of terraform plan into tepmlate
+func (t *PlanTemplate) Execute() (resp string, err error) {
+ tpl, err := template.New("plan").Parse(t.Template)
+ if err != nil {
+ return resp, err
+ }
+ var b bytes.Buffer
+ if err := tpl.Execute(&b, map[string]interface{}{
+ "Title": t.Title,
+ "Message": t.Message,
+ "Result": t.Result,
+ "Body": t.Body,
+ }); err != nil {
+ return resp, err
+ }
+ resp = b.String()
+ return resp, err
+}
+
+// Execute binds the execution result of terraform apply into tepmlate
+func (t *ApplyTemplate) Execute() (resp string, err error) {
+ tpl, err := template.New("apply").Parse(t.Template)
+ if err != nil {
+ return resp, err
+ }
+ var b bytes.Buffer
+ if err := tpl.Execute(&b, map[string]interface{}{
+ "Title": t.Title,
+ "Message": t.Message,
+ "Result": t.Result,
+ "Body": t.Body,
+ }); err != nil {
+ return resp, err
+ }
+ resp = b.String()
+ return resp, err
+}
+
+// SetValue sets template entities to CommonTemplate
+func (t *DefaultTemplate) SetValue(ct CommonTemplate) {
+ if ct.Title == "" {
+ ct.Title = DefaultDefaultTitle
+ }
+ t.CommonTemplate = ct
+}
+
+// SetValue sets template entities about terraform fmt to CommonTemplate
+func (t *FmtTemplate) SetValue(ct CommonTemplate) {
+ if ct.Title == "" {
+ ct.Title = DefaultFmtTitle
+ }
+ t.CommonTemplate = ct
+}
+
+// SetValue sets template entities about terraform plan to CommonTemplate
+func (t *PlanTemplate) SetValue(ct CommonTemplate) {
+ if ct.Title == "" {
+ ct.Title = DefaultPlanTitle
+ }
+ t.CommonTemplate = ct
+}
+
+// SetValue sets template entities about terraform apply to CommonTemplate
+func (t *ApplyTemplate) SetValue(ct CommonTemplate) {
+ if ct.Title == "" {
+ ct.Title = DefaultApplyTitle
+ }
+ t.CommonTemplate = ct
+}
+
+// GetValue gets template entities
+func (t *DefaultTemplate) GetValue() CommonTemplate {
+ return t.CommonTemplate
+}
+
+// GetValue gets template entities
+func (t *FmtTemplate) GetValue() CommonTemplate {
+ return t.CommonTemplate
+}
+
+// GetValue gets template entities
+func (t *PlanTemplate) GetValue() CommonTemplate {
+ return t.CommonTemplate
+}
+
+// GetValue gets template entities
+func (t *ApplyTemplate) GetValue() CommonTemplate {
+ return t.CommonTemplate
+}
diff --git a/terraform/template_test.go b/terraform/template_test.go
new file mode 100644
index 0000000..86c8ba2
--- /dev/null
+++ b/terraform/template_test.go
@@ -0,0 +1,293 @@
+package terraform
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestDefaultTemplateExecute(t *testing.T) {
+ testCases := []struct {
+ template string
+ value CommonTemplate
+ resp string
+ }{
+ {
+ template: DefaultDefaultTemplate,
+ value: CommonTemplate{},
+ resp: "\n## Terraform result\n\n\n\n\n\nDetails (Click me)
\n\n
\n",
+ },
+ {
+ template: DefaultDefaultTemplate,
+ value: CommonTemplate{
+ Message: "message",
+ },
+ resp: "\n## Terraform result\n\nmessage\n\n\n\nDetails (Click me)
\n\n
\n",
+ },
+ {
+ template: DefaultDefaultTemplate,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "\na\n\nb\n\n\n\nDetails (Click me)
\nc\n
\n",
+ },
+
+ {
+ template: "",
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "\na\n\nb\n\n\n\nDetails (Click me)
\nc\n
\n",
+ },
+ {
+ template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "should be used as body",
+ Body: "should be empty",
+ },
+ resp: "a-b--should be used as body",
+ },
+ }
+ for _, testCase := range testCases {
+ template := NewDefaultTemplate(testCase.template)
+ template.SetValue(testCase.value)
+ resp, err := template.Execute()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp != testCase.resp {
+ t.Errorf("got %q but want %q", resp, testCase.resp)
+ }
+ }
+}
+
+func TestFmtTemplateExecute(t *testing.T) {
+ testCases := []struct {
+ template string
+ value CommonTemplate
+ resp string
+ }{
+ {
+ template: DefaultFmtTemplate,
+ value: CommonTemplate{},
+ resp: "\n## Fmt result\n\n\n\n\n\n\n",
+ },
+ {
+ template: DefaultFmtTemplate,
+ value: CommonTemplate{
+ Message: "message",
+ },
+ resp: "\n## Fmt result\n\nmessage\n\n\n\n\n",
+ },
+ {
+ template: DefaultFmtTemplate,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "\na\n\nb\n\n\n\nc\n",
+ },
+
+ {
+ template: "",
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "\na\n\nb\n\n\n\nc\n",
+ },
+ {
+ template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "should be used as body",
+ Body: "should be empty",
+ },
+ resp: "a-b--should be used as body",
+ },
+ }
+ for _, testCase := range testCases {
+ template := NewFmtTemplate(testCase.template)
+ template.SetValue(testCase.value)
+ resp, err := template.Execute()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp != testCase.resp {
+ t.Errorf("got %q but want %q", resp, testCase.resp)
+ }
+ }
+}
+
+func TestPlanTemplateExecute(t *testing.T) {
+ testCases := []struct {
+ template string
+ value CommonTemplate
+ resp string
+ }{
+ {
+ template: DefaultPlanTemplate,
+ value: CommonTemplate{},
+ resp: "\n## Plan result\n\n\n\n\n\nDetails (Click me)
\n\n
\n",
+ },
+ {
+ template: DefaultPlanTemplate,
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "result",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\nresult\n
\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: DefaultPlanTemplate,
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: "",
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "a-b-c-d",
+ },
+ }
+ for _, testCase := range testCases {
+ template := NewPlanTemplate(testCase.template)
+ template.SetValue(testCase.value)
+ resp, err := template.Execute()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp != testCase.resp {
+ t.Errorf("got %q but want %q", resp, testCase.resp)
+ }
+ }
+}
+
+func TestApplyTemplateExecute(t *testing.T) {
+ testCases := []struct {
+ template string
+ value CommonTemplate
+ resp string
+ }{
+ {
+ template: DefaultApplyTemplate,
+ value: CommonTemplate{},
+ resp: "\n## Apply result\n\n\n\n\n\nDetails (Click me)
\n\n
\n",
+ },
+ {
+ template: DefaultApplyTemplate,
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "result",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\nresult\n
\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: DefaultApplyTemplate,
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: "",
+ value: CommonTemplate{
+ Title: "title",
+ Message: "message",
+ Result: "",
+ Body: "body",
+ },
+ resp: "\ntitle\n\nmessage\n\n\n\nDetails (Click me)
\nbody\n
\n",
+ },
+ {
+ template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
+ value: CommonTemplate{
+ Title: "a",
+ Message: "b",
+ Result: "c",
+ Body: "d",
+ },
+ resp: "a-b-c-d",
+ },
+ }
+ for _, testCase := range testCases {
+ template := NewApplyTemplate(testCase.template)
+ template.SetValue(testCase.value)
+ resp, err := template.Execute()
+ if err != nil {
+ t.Error(err)
+ }
+ if resp != testCase.resp {
+ t.Errorf("got %q but want %q", resp, testCase.resp)
+ }
+ }
+}
+
+func TestGetValue(t *testing.T) {
+ testCases := []struct {
+ template Template
+ expected CommonTemplate
+ }{
+ {
+ template: NewDefaultTemplate(""),
+ expected: CommonTemplate{},
+ },
+ {
+ template: NewFmtTemplate(""),
+ expected: CommonTemplate{},
+ },
+ {
+ template: NewPlanTemplate(""),
+ expected: CommonTemplate{},
+ },
+ {
+ template: NewApplyTemplate(""),
+ expected: CommonTemplate{},
+ },
+ }
+ for _, testCase := range testCases {
+ template := testCase.template
+ value := template.GetValue()
+ if !reflect.DeepEqual(value, testCase.expected) {
+ t.Errorf("got %q but want %q", value, testCase.expected)
+ }
+ }
+}
diff --git a/terraform/terraform.go b/terraform/terraform.go
new file mode 100644
index 0000000..3135da5
--- /dev/null
+++ b/terraform/terraform.go
@@ -0,0 +1,9 @@
+package terraform
+
+const (
+ // ExitPass is status code zero
+ ExitPass int = iota
+
+ // ExitFail is status code non-zero
+ ExitFail
+)
diff --git a/terraform/terraform_test.go b/terraform/terraform_test.go
new file mode 100644
index 0000000..cc3ee2f
--- /dev/null
+++ b/terraform/terraform_test.go
@@ -0,0 +1 @@
+package terraform