├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── reviewdog.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── .reviewdog.yml ├── LICENSE ├── README.md ├── ci.go ├── ci_test.go ├── config ├── config.go └── config_test.go ├── error.go ├── error_test.go ├── example-use-raw-output.tfnotify.yaml ├── example-with-destroy-and-result-labels.tfnotify.yaml ├── example.tfnotify.yaml ├── go.mod ├── go.sum ├── main.go ├── misc └── images │ ├── 1.png │ ├── 2.png │ └── 3.png ├── notifier ├── github │ ├── client.go │ ├── client_test.go │ ├── comment.go │ ├── comment_test.go │ ├── commits.go │ ├── commits_test.go │ ├── github.go │ ├── github_test.go │ ├── notify.go │ └── notify_test.go ├── gitlab │ ├── client.go │ ├── client_test.go │ ├── comment.go │ ├── comment_test.go │ ├── commits.go │ ├── commits_test.go │ ├── gitlab.go │ ├── gitlab_test.go │ ├── notify.go │ └── notify_test.go ├── notifier.go ├── notifier_test.go ├── slack │ ├── client.go │ ├── client_test.go │ ├── notify.go │ ├── notify_test.go │ ├── slack.go │ └── slack_test.go └── typetalk │ ├── client.go │ ├── client_test.go │ ├── notify.go │ ├── notify_test.go │ ├── typetalk.go │ └── typetalk_test.go ├── tee.go ├── tee_test.go └── terraform ├── parser.go ├── parser_test.go ├── template.go ├── template_test.go ├── terraform.go └── terraform_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | * @b4b4r07 @dtan4 @drlau @micnncim @KeisukeYamashita @tyuhara 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | (Write what you need) 4 | 5 | ## WHY 6 | 7 | (Write the background of this issue) 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | (Write the change being made with this pull request) 4 | 5 | ## WHY 6 | 7 | (Write the motivation why you submit this pull request) 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | - name: Setup Go 13 | uses: actions/setup-go@v1 14 | with: 15 | go-version: '1.18.x' 16 | - name: Run GoReleaser 17 | uses: goreleaser/goreleaser-action@v1 18 | with: 19 | version: latest 20 | args: release --rm-dist 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.go' 7 | - '.github/workflows/reviewdog.yml' 8 | 9 | jobs: 10 | golangci-lint: 11 | name: Run golangci-lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Lint 17 | uses: reviewdog/action-golangci-lint@v1 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | push: 7 | branches: [master] 8 | paths: 9 | - '**.go' 10 | - '.github/workflows/test.yml' 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Setup Go 17 | uses: actions/setup-go@v1 18 | with: 19 | go-version: '1.18.x' 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | - name: Test 23 | run: go test -v -race ./... 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go 2 | 3 | ### Go ### 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | 18 | # End of https://www.gitignore.io/api/go 19 | 20 | tfnotify 21 | vendor 22 | dist 23 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: tfnotify 2 | env: 3 | - GO111MODULE=on 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - main: . 9 | binary: tfnotify 10 | ldflags: 11 | - -s -w 12 | - -X main.version={{.Version}} 13 | env: 14 | - CGO_ENABLED=0 15 | archives: 16 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 17 | replacements: 18 | darwin: 'darwin' 19 | linux: 'linux' 20 | windows: 'windows' 21 | 386: '386' 22 | amd64: 'amd64' 23 | format_overrides: 24 | - goos: 'windows' 25 | format: 'zip' 26 | release: 27 | prerelease: auto 28 | -------------------------------------------------------------------------------- /.reviewdog.yml: -------------------------------------------------------------------------------- 1 | runner: 2 | golint: 3 | cmd: golint $(go list ./...) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Mercari, Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tfnotify 2 | ======== 3 | 4 | [![][release-svg]][release] [![][test-svg]][test] [![][goreportcard-svg]][goreportcard] 5 | 6 | [release]: https://github.com/mercari/tfnotify/actions?query=workflow%3Arelease 7 | [release-svg]: https://github.com/mercari/tfnotify/workflows/release/badge.svg 8 | [test]: https://github.com/mercari/tfnotify/actions?query=workflow%3Atest 9 | [test-svg]: https://github.com/mercari/tfnotify/workflows/test/badge.svg 10 | [goreportcard]: https://goreportcard.com/report/github.com/mercari/tfnotify 11 | [goreportcard-svg]: https://goreportcard.com/badge/github.com/mercari/tfnotify 12 | 13 | tfnotify parses Terraform commands' execution result and applies it to an arbitrary template and then notifies it to GitHub comments etc. 14 | 15 | ## Motivation 16 | 17 | 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. 18 | 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. 19 | This is very troublesome. It is very efficient if you can check it with GitHub comments or Slack etc. 20 | You can do this by using this command. 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ## Installation 29 | 30 | Grab the binary from GitHub Releases (Recommended) 31 | 32 | or 33 | 34 | ```console 35 | $ go get -u github.com/mercari/tfnotify 36 | ``` 37 | 38 | 39 | ### What tfnotify does 40 | 41 | 1. Parse the execution result of Terraform 42 | 2. Bind parsed results to Go templates 43 | 3. Notify it to any platform (e.g. GitHub) as you like 44 | 45 | Detailed specifications such as templates and notification destinations can be customized from the configuration files (described later). 46 | 47 | ## Usage 48 | 49 | ### Basic 50 | 51 | tfnotify is just CLI command. So you can run it from your local after grabbing the binary. 52 | 53 | Basically tfnotify waits for the input from Stdin. So tfnotify needs to pipe the output of Terraform command like the following: 54 | 55 | ```console 56 | $ terraform plan | tfnotify plan 57 | ``` 58 | 59 | 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`. 60 | 61 | ### Configurations 62 | 63 | When running tfnotify, you can specify the configuration path via `--config` option (if it's omitted, it defaults to `{.,}tfnotify.y{,a}ml`). 64 | 65 | The example settings of GitHub and GitHub Enterprise, Slack, [Typetalk](https://www.typetalk.com/) are as follows. Incidentally, there is no need to replace TOKEN string such as `$GITHUB_TOKEN` with the actual token. Instead, it must be defined as environment variables in CI settings. 66 | 67 | [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: 68 | 69 | Placeholder | Usage 70 | ---|--- 71 | `{{ .Title }}` | Like `## Plan result` 72 | `{{ .Message }}` | A string that can be set from CLI with `--message` option 73 | `{{ .Result }}` | Matched result by parsing like `Plan: 1 to add` or `No changes` 74 | `{{ .Body }}` | The entire of Terraform execution result 75 | `{{ .Link }}` | The link of the build page on CI 76 | 77 | On GitHub, tfnotify can also put a warning message if the plan result contains resource deletion (optional). 78 | 79 | #### Template Examples 80 | 81 |
82 | For GitHub 83 | 84 | ```yaml 85 | --- 86 | ci: circleci 87 | notifier: 88 | github: 89 | token: $GITHUB_TOKEN 90 | repository: 91 | owner: "mercari" 92 | name: "tfnotify" 93 | terraform: 94 | fmt: 95 | template: | 96 | {{ .Title }} 97 | 98 | {{ .Message }} 99 | 100 | {{ .Result }} 101 | 102 | {{ .Body }} 103 | plan: 104 | template: | 105 | {{ .Title }} [CI link]( {{ .Link }} ) 106 | {{ .Message }} 107 | {{if .Result}} 108 |
{{ .Result }}
109 |       
110 | {{end}} 111 |
Details (Click me) 112 | 113 |
{{ .Body }}
114 |       
115 | apply: 116 | template: | 117 | {{ .Title }} 118 | {{ .Message }} 119 | {{if .Result}} 120 |
{{ .Result }}
121 |       
122 | {{end}} 123 |
Details (Click me) 124 | 125 |
{{ .Body }}
126 |       
127 | ``` 128 | 129 | If you would like to let tfnotify warn the resource deletion, add `when_destroy` configuration as below. 130 | 131 | ```yaml 132 | --- 133 | # ... 134 | terraform: 135 | # ... 136 | plan: 137 | template: | 138 | {{ .Title }} [CI link]( {{ .Link }} ) 139 | {{ .Message }} 140 | {{if .Result}} 141 |
{{ .Result }}
142 |       
143 | {{end}} 144 |
Details (Click me) 145 | 146 |
{{ .Body }}
147 |       
148 | when_destroy: 149 | template: | 150 | ## :warning: WARNING: Resource Deletion will happen :warning: 151 | 152 | This plan contains **resource deletion**. Please check the plan result very carefully! 153 | # ... 154 | ``` 155 | 156 | You can also let tfnotify add a label to PRs depending on the `terraform plan` output result. Currently, this feature is for Github labels only. 157 | 158 | ```yaml 159 | --- 160 | # ... 161 | terraform: 162 | # ... 163 | plan: 164 | template: | 165 | {{ .Title }} [CI link]( {{ .Link }} ) 166 | {{ .Message }} 167 | {{if .Result}} 168 |
{{ .Result }}
169 |       
170 | {{end}} 171 |
Details (Click me) 172 | 173 |
{{ .Body }}
174 |       
175 | when_add_or_update_only: 176 | label: "add-or-update" 177 | when_destroy: 178 | label: "destroy" 179 | when_no_changes: 180 | label: "no-changes" 181 | when_plan_error: 182 | label: "error" 183 | # ... 184 | ``` 185 | 186 | Sometimes you may want not to HTML-escape Terraform command outputs. 187 | For example, when you use code block to print command output, it's better to use raw characters instead of character references (e.g. `-/+` -> `-/+`, `"` -> `"`). 188 | 189 | You can disable HTML escape by adding `use_raw_output: true` configuration. 190 | With this configuration, Terraform doesn't HTML-escape any Terraform output. 191 | 192 | ~~~yaml 193 | --- 194 | # ... 195 | terraform: 196 | use_raw_output: true 197 | # ... 198 | plan: 199 | template: | 200 | {{ .Title }} [CI link]( {{ .Link }} ) 201 | {{ .Message }} 202 | {{if .Result}} 203 | ``` 204 | {{ .Result }} 205 | ``` 206 | {{end}} 207 |
Details (Click me) 208 | 209 | ``` 210 | {{ .Body }} 211 | ``` 212 | # ... 213 | ~~~ 214 | 215 |
216 | 217 |
218 | For GitHub Enterprise 219 | 220 | ```yaml 221 | --- 222 | ci: circleci 223 | notifier: 224 | github: 225 | token: $GITHUB_TOKEN 226 | base_url: $GITHUB_BASE_URL # Example: https://github.example.com/api/v3 227 | repository: 228 | owner: "mercari" 229 | name: "tfnotify" 230 | terraform: 231 | fmt: 232 | template: | 233 | {{ .Title }} 234 | 235 | {{ .Message }} 236 | 237 | {{ .Result }} 238 | 239 | {{ .Body }} 240 | plan: 241 | template: | 242 | {{ .Title }} [CI link]( {{ .Link }} ) 243 | {{ .Message }} 244 | {{if .Result}} 245 |
{{ .Result }}
246 |       
247 | {{end}} 248 |
Details (Click me) 249 | 250 |
{{ .Body }}
251 |       
252 | apply: 253 | template: | 254 | {{ .Title }} 255 | {{ .Message }} 256 | {{if .Result}} 257 |
{{ .Result }}
258 |       
259 | {{end}} 260 |
Details (Click me) 261 | 262 |
{{ .Body }}
263 |       
264 | ``` 265 | 266 |
267 | 268 |
269 | For GitLab 270 | 271 | ```yaml 272 | --- 273 | ci: gitlabci 274 | notifier: 275 | gitlab: 276 | token: $GITLAB_TOKEN 277 | base_url: $GITLAB_BASE_URL 278 | repository: 279 | owner: "mercari" 280 | name: "tfnotify" 281 | terraform: 282 | fmt: 283 | template: | 284 | {{ .Title }} 285 | 286 | {{ .Message }} 287 | 288 | {{ .Result }} 289 | 290 | {{ .Body }} 291 | plan: 292 | template: | 293 | {{ .Title }} [CI link]( {{ .Link }} ) 294 | {{ .Message }} 295 | {{if .Result}} 296 |
 {{ .Result }}
297 |       
298 | {{end}} 299 |
Details (Click me) 300 |
 {{ .Body }}
301 |       
302 | apply: 303 | template: | 304 | {{ .Title }} 305 | {{ .Message }} 306 | {{if .Result}} 307 |
 {{ .Result }}
308 |       
309 | {{end}} 310 |
Details (Click me) 311 |
 {{ .Body }}
312 |       
313 | ``` 314 |
315 | 316 |
317 | For Slack 318 | 319 | ```yaml 320 | --- 321 | ci: circleci 322 | notifier: 323 | slack: 324 | token: $SLACK_TOKEN 325 | channel: $SLACK_CHANNEL_ID 326 | bot: $SLACK_BOT_NAME 327 | terraform: 328 | plan: 329 | template: | 330 | {{ .Message }} 331 | {{if .Result}} 332 | ``` 333 | {{ .Result }} 334 | ``` 335 | {{end}} 336 | ``` 337 | {{ .Body }} 338 | ``` 339 | ``` 340 | 341 | Sometimes you may want not to HTML-escape Terraform command outputs. 342 | For example, when you use code block to print command output, it's better to use raw characters instead of character references (e.g. `-/+` -> `-/+`, `"` -> `"`). 343 | 344 | You can disable HTML escape by adding `use_raw_output: true` configuration. 345 | With this configuration, Terraform doesn't HTML-escape any Terraform output. 346 | 347 | ~~~yaml 348 | --- 349 | # ... 350 | terraform: 351 | use_raw_output: true 352 | # ... 353 | plan: 354 | # ... 355 | ~~~ 356 | 357 |
358 | 359 |
360 | For Typetalk 361 | 362 | ```yaml 363 | --- 364 | ci: circleci 365 | notifier: 366 | typetalk: 367 | token: $TYPETALK_TOKEN 368 | topic_id: $TYPETALK_TOPIC_ID 369 | terraform: 370 | plan: 371 | template: | 372 | {{ .Message }} 373 | {{if .Result}} 374 | ``` 375 | {{ .Result }} 376 | ``` 377 | {{end}} 378 | ``` 379 | {{ .Body }} 380 | ``` 381 | ``` 382 | 383 |
384 | 385 | ### Supported CI 386 | 387 | Currently, supported CI are here: 388 | 389 | - Circle CI 390 | - Travis CI 391 | - AWS CodeBuild 392 | - TeamCity 393 | - Drone 394 | - Jenkins 395 | - GitLab CI 396 | - GitHub Actions 397 | - Google Cloud Build 398 | 399 | ### Private Repository Considerations 400 | GitHub private repositories require the `repo` and `write:discussion` permissions. 401 | 402 | ### Jenkins Considerations 403 | - Plugin 404 | - [Git Plugin](https://wiki.jenkins.io/display/JENKINS/Git+Plugin) 405 | - Environment Variable 406 | - `PULL_REQUEST_NUMBER` or `PULL_REQUEST_URL` are required to set by user for Pull Request Usage 407 | 408 | ### Google Cloud Build Considerations 409 | 410 | - These environment variables are needed to be set using [substitutions](https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values) 411 | - `COMMIT_SHA` 412 | - `BUILD_ID` 413 | - `PROJECT_ID` 414 | - `_PR_NUMBER` 415 | - `_REGION` 416 | - Recommended trigger events 417 | - `terraform plan`: Pull request 418 | - `terraform apply`: Push to branch 419 | 420 | ## Committers 421 | 422 | * Masaki ISHIYAMA ([@b4b4r07](https://github.com/b4b4r07)) 423 | 424 | ## Contribution 425 | 426 | Please read the CLA below carefully before submitting your contribution. 427 | 428 | https://www.mercari.com/cla/ 429 | 430 | ## License 431 | 432 | Copyright 2018 Mercari, Inc. 433 | 434 | Licensed under the MIT License. 435 | -------------------------------------------------------------------------------- /ci.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | defaultCloudBuildRegion = "global" 13 | ) 14 | 15 | var ( 16 | // https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request 17 | githubActionsPRRefRegexp = regexp.MustCompile(`refs/pull/\d+/merge`) 18 | ) 19 | 20 | // CI represents a common information obtained from all CI platforms 21 | type CI struct { 22 | PR PullRequest 23 | URL string 24 | } 25 | 26 | // PullRequest represents a GitHub pull request 27 | type PullRequest struct { 28 | Revision string 29 | Number int 30 | } 31 | 32 | func circleci() (ci CI, err error) { 33 | ci.PR.Number = 0 34 | ci.PR.Revision = os.Getenv("CIRCLE_SHA1") 35 | ci.URL = os.Getenv("CIRCLE_BUILD_URL") 36 | pr := os.Getenv("CIRCLE_PULL_REQUEST") 37 | if pr == "" { 38 | pr = os.Getenv("CI_PULL_REQUEST") 39 | } 40 | if pr == "" { 41 | pr = os.Getenv("CIRCLE_PR_NUMBER") 42 | } 43 | if pr == "" { 44 | return ci, nil 45 | } 46 | re := regexp.MustCompile(`[1-9]\d*$`) 47 | ci.PR.Number, err = strconv.Atoi(re.FindString(pr)) 48 | if err != nil { 49 | return ci, fmt.Errorf("%v: cannot get env", pr) 50 | } 51 | return ci, nil 52 | } 53 | 54 | func travisci() (ci CI, err error) { 55 | ci.URL = os.Getenv("TRAVIS_BUILD_WEB_URL") 56 | prNumber := os.Getenv("TRAVIS_PULL_REQUEST") 57 | if prNumber == "false" { 58 | ci.PR.Number = 0 59 | ci.PR.Revision = os.Getenv("TRAVIS_COMMIT") 60 | return ci, nil 61 | } 62 | ci.PR.Revision = os.Getenv("TRAVIS_PULL_REQUEST_SHA") 63 | ci.PR.Number, err = strconv.Atoi(prNumber) 64 | return ci, err 65 | } 66 | 67 | func codebuild() (ci CI, err error) { 68 | ci.PR.Number = 0 69 | ci.PR.Revision = os.Getenv("CODEBUILD_RESOLVED_SOURCE_VERSION") 70 | ci.URL = os.Getenv("CODEBUILD_BUILD_URL") 71 | sourceVersion := os.Getenv("CODEBUILD_SOURCE_VERSION") 72 | if sourceVersion == "" { 73 | return ci, nil 74 | } 75 | if !strings.HasPrefix(sourceVersion, "pr/") { 76 | return ci, nil 77 | } 78 | pr := strings.Replace(sourceVersion, "pr/", "", 1) 79 | if pr == "" { 80 | return ci, nil 81 | } 82 | ci.PR.Number, err = strconv.Atoi(pr) 83 | return ci, err 84 | } 85 | 86 | func teamcity() (ci CI, err error) { 87 | ci.PR.Revision = os.Getenv("BUILD_VCS_NUMBER") 88 | ci.PR.Number, err = strconv.Atoi(os.Getenv("BUILD_NUMBER")) 89 | return ci, err 90 | } 91 | 92 | func drone() (ci CI, err error) { 93 | ci.PR.Number = 0 94 | ci.PR.Revision = os.Getenv("DRONE_COMMIT_SHA") 95 | ci.URL = os.Getenv("DRONE_BUILD_LINK") 96 | pr := os.Getenv("DRONE_PULL_REQUEST") 97 | if pr == "" { 98 | return ci, nil 99 | } 100 | ci.PR.Number, err = strconv.Atoi(pr) 101 | return ci, err 102 | } 103 | 104 | func jenkins() (ci CI, err error) { 105 | ci.PR.Number = 0 106 | ci.PR.Revision = os.Getenv("GIT_COMMIT") 107 | if ci.PR.Revision == "" { 108 | ci.PR.Revision = os.Getenv("gitlabBefore") 109 | } 110 | ci.URL = os.Getenv("BUILD_URL") 111 | pr := os.Getenv("PULL_REQUEST_NUMBER") 112 | if pr == "" { 113 | pr = os.Getenv("gitlabMergeRequestIid") 114 | } 115 | if pr == "" { 116 | pr = os.Getenv("PULL_REQUEST_URL") 117 | } 118 | if pr == "" { 119 | return ci, nil 120 | } 121 | re := regexp.MustCompile(`[1-9]\d*$`) 122 | ci.PR.Number, err = strconv.Atoi(re.FindString(pr)) 123 | if err != nil { 124 | return ci, fmt.Errorf("%v: Invalid PullRequest number or MergeRequest ID", pr) 125 | } 126 | return ci, err 127 | } 128 | 129 | func gitlabci() (ci CI, err error) { 130 | ci.PR.Number = 0 131 | ci.PR.Revision = os.Getenv("CI_COMMIT_SHA") 132 | ci.URL = os.Getenv("CI_JOB_URL") 133 | pr := os.Getenv("CI_MERGE_REQUEST_IID") 134 | if pr == "" { 135 | refPath := os.Getenv("CI_MERGE_REQUEST_REF_PATH") 136 | rep := regexp.MustCompile(`refs/merge-requests/\d*/head`) 137 | if rep.MatchString(refPath) { 138 | strLen := strings.Split(refPath, "/") 139 | pr = strLen[2] 140 | } 141 | } 142 | if pr == "" { 143 | return ci, nil 144 | } 145 | ci.PR.Number, err = strconv.Atoi(pr) 146 | return ci, err 147 | } 148 | 149 | func githubActions() (ci CI, err error) { 150 | ci.URL = fmt.Sprintf( 151 | "https://github.com/%s/actions/runs/%s", 152 | os.Getenv("GITHUB_REPOSITORY"), 153 | os.Getenv("GITHUB_RUN_ID"), 154 | ) 155 | ci.PR.Revision = os.Getenv("GITHUB_SHA") 156 | ci.PR.Number = 0 157 | 158 | if githubActionsPRRefRegexp.MatchString(os.Getenv("GITHUB_REF")) { 159 | s := strings.Split(os.Getenv("GITHUB_REF"), "/")[2] 160 | pr, err := strconv.Atoi(s) 161 | if err != nil { 162 | return ci, err 163 | } 164 | 165 | ci.PR.Number = pr 166 | } 167 | 168 | return ci, err 169 | } 170 | 171 | func cloudbuild() (ci CI, err error) { 172 | ci.PR.Number = 0 173 | ci.PR.Revision = os.Getenv("COMMIT_SHA") 174 | 175 | region := os.Getenv("_REGION") 176 | if region == "" { 177 | region = defaultCloudBuildRegion 178 | } 179 | 180 | ci.URL = fmt.Sprintf( 181 | "https://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s", 182 | region, 183 | os.Getenv("BUILD_ID"), 184 | os.Getenv("PROJECT_ID"), 185 | ) 186 | pr := os.Getenv("_PR_NUMBER") 187 | if pr == "" { 188 | return ci, nil 189 | } 190 | ci.PR.Number, err = strconv.Atoi(pr) 191 | return ci, err 192 | } 193 | -------------------------------------------------------------------------------- /ci_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestCircleci(t *testing.T) { 10 | envs := []string{ 11 | "CIRCLE_SHA1", 12 | "CIRCLE_BUILD_URL", 13 | "CIRCLE_PULL_REQUEST", 14 | "CI_PULL_REQUEST", 15 | "CIRCLE_PR_NUMBER", 16 | } 17 | saveEnvs := make(map[string]string) 18 | for _, key := range envs { 19 | saveEnvs[key] = os.Getenv(key) 20 | os.Unsetenv(key) 21 | } 22 | defer func() { 23 | for key, value := range saveEnvs { 24 | os.Setenv(key, value) 25 | } 26 | }() 27 | 28 | testCases := []struct { 29 | fn func() 30 | ci CI 31 | ok bool 32 | }{ 33 | { 34 | fn: func() { 35 | os.Setenv("CIRCLE_SHA1", "abcdefg") 36 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 37 | os.Setenv("CIRCLE_PULL_REQUEST", "") 38 | os.Setenv("CI_PULL_REQUEST", "") 39 | os.Setenv("CIRCLE_PR_NUMBER", "") 40 | }, 41 | ci: CI{ 42 | PR: PullRequest{ 43 | Revision: "abcdefg", 44 | Number: 0, 45 | }, 46 | URL: "https://circleci.com/gh/owner/repo/1234", 47 | }, 48 | ok: true, 49 | }, 50 | { 51 | fn: func() { 52 | os.Setenv("CIRCLE_SHA1", "abcdefg") 53 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 54 | os.Setenv("CIRCLE_PULL_REQUEST", "https://github.com/owner/repo/pull/1") 55 | os.Setenv("CI_PULL_REQUEST", "") 56 | os.Setenv("CIRCLE_PR_NUMBER", "") 57 | }, 58 | ci: CI{ 59 | PR: PullRequest{ 60 | Revision: "abcdefg", 61 | Number: 1, 62 | }, 63 | URL: "https://circleci.com/gh/owner/repo/1234", 64 | }, 65 | ok: true, 66 | }, 67 | { 68 | fn: func() { 69 | os.Setenv("CIRCLE_SHA1", "abcdefg") 70 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 71 | os.Setenv("CIRCLE_PULL_REQUEST", "") 72 | os.Setenv("CI_PULL_REQUEST", "2") 73 | os.Setenv("CIRCLE_PR_NUMBER", "") 74 | }, 75 | ci: CI{ 76 | PR: PullRequest{ 77 | Revision: "abcdefg", 78 | Number: 2, 79 | }, 80 | URL: "https://circleci.com/gh/owner/repo/1234", 81 | }, 82 | ok: true, 83 | }, 84 | { 85 | fn: func() { 86 | os.Setenv("CIRCLE_SHA1", "abcdefg") 87 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 88 | os.Setenv("CIRCLE_PULL_REQUEST", "") 89 | os.Setenv("CI_PULL_REQUEST", "") 90 | os.Setenv("CIRCLE_PR_NUMBER", "3") 91 | }, 92 | ci: CI{ 93 | PR: PullRequest{ 94 | Revision: "abcdefg", 95 | Number: 3, 96 | }, 97 | URL: "https://circleci.com/gh/owner/repo/1234", 98 | }, 99 | ok: true, 100 | }, 101 | { 102 | fn: func() { 103 | os.Setenv("CIRCLE_SHA1", "") 104 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 105 | os.Setenv("CIRCLE_PULL_REQUEST", "") 106 | os.Setenv("CI_PULL_REQUEST", "") 107 | os.Setenv("CIRCLE_PR_NUMBER", "") 108 | }, 109 | ci: CI{ 110 | PR: PullRequest{ 111 | Revision: "", 112 | Number: 0, 113 | }, 114 | URL: "https://circleci.com/gh/owner/repo/1234", 115 | }, 116 | ok: true, 117 | }, 118 | { 119 | fn: func() { 120 | os.Setenv("CIRCLE_SHA1", "") 121 | os.Setenv("CIRCLE_BUILD_URL", "https://circleci.com/gh/owner/repo/1234") 122 | os.Setenv("CIRCLE_PULL_REQUEST", "abcdefg") 123 | os.Setenv("CI_PULL_REQUEST", "") 124 | os.Setenv("CIRCLE_PR_NUMBER", "") 125 | }, 126 | ci: CI{ 127 | PR: PullRequest{ 128 | Revision: "", 129 | Number: 0, 130 | }, 131 | URL: "https://circleci.com/gh/owner/repo/1234", 132 | }, 133 | ok: false, 134 | }, 135 | } 136 | 137 | for _, testCase := range testCases { 138 | testCase.fn() 139 | ci, err := circleci() 140 | if !reflect.DeepEqual(ci, testCase.ci) { 141 | t.Errorf("got %q but want %q", ci, testCase.ci) 142 | } 143 | if (err == nil) != testCase.ok { 144 | t.Errorf("got error %q", err) 145 | } 146 | } 147 | } 148 | 149 | func TestTravisCI(t *testing.T) { 150 | envs := []string{ 151 | "TRAVIS_PULL_REQUEST_SHA", 152 | "TRAVIS_PULL_REQUEST", 153 | "TRAVIS_COMMIT", 154 | } 155 | saveEnvs := make(map[string]string) 156 | for _, key := range envs { 157 | saveEnvs[key] = os.Getenv(key) 158 | os.Unsetenv(key) 159 | } 160 | defer func() { 161 | for key, value := range saveEnvs { 162 | os.Setenv(key, value) 163 | } 164 | }() 165 | 166 | // https://docs.travis-ci.com/user/environment-variables/ 167 | testCases := []struct { 168 | fn func() 169 | ci CI 170 | ok bool 171 | }{ 172 | { 173 | fn: func() { 174 | os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg") 175 | os.Setenv("TRAVIS_PULL_REQUEST", "1") 176 | os.Setenv("TRAVIS_COMMIT", "hijklmn") 177 | }, 178 | ci: CI{ 179 | PR: PullRequest{ 180 | Revision: "abcdefg", 181 | Number: 1, 182 | }, 183 | URL: "", 184 | }, 185 | ok: true, 186 | }, 187 | { 188 | fn: func() { 189 | os.Setenv("TRAVIS_PULL_REQUEST_SHA", "abcdefg") 190 | os.Setenv("TRAVIS_PULL_REQUEST", "false") 191 | os.Setenv("TRAVIS_COMMIT", "hijklmn") 192 | }, 193 | ci: CI{ 194 | PR: PullRequest{ 195 | Revision: "hijklmn", 196 | Number: 0, 197 | }, 198 | URL: "", 199 | }, 200 | ok: true, 201 | }, 202 | } 203 | 204 | for _, testCase := range testCases { 205 | testCase.fn() 206 | ci, err := travisci() 207 | if !reflect.DeepEqual(ci, testCase.ci) { 208 | t.Errorf("got %q but want %q", ci, testCase.ci) 209 | } 210 | if (err == nil) != testCase.ok { 211 | t.Errorf("got error %q", err) 212 | } 213 | } 214 | } 215 | 216 | func TestTeamCityCI(t *testing.T) { 217 | envs := []string{ 218 | "BUILD_VCS_NUMBER", 219 | "BUILD_NUMBER", 220 | } 221 | saveEnvs := make(map[string]string) 222 | for _, key := range envs { 223 | saveEnvs[key] = os.Getenv(key) 224 | os.Unsetenv(key) 225 | } 226 | defer func() { 227 | for key, value := range saveEnvs { 228 | os.Setenv(key, value) 229 | } 230 | }() 231 | 232 | // https://confluence.jetbrains.com/display/TCD18/Predefined+Build+Parameters 233 | testCases := []struct { 234 | fn func() 235 | ci CI 236 | ok bool 237 | }{ 238 | { 239 | fn: func() { 240 | os.Setenv("BUILD_VCS_NUMBER", "fafef5adb5b9c39244027c8f16f7c3aa7e352b2e") 241 | os.Setenv("BUILD_NUMBER", "123") 242 | }, 243 | ci: CI{ 244 | PR: PullRequest{ 245 | Revision: "fafef5adb5b9c39244027c8f16f7c3aa7e352b2e", 246 | Number: 123, 247 | }, 248 | URL: "", 249 | }, 250 | ok: true, 251 | }, 252 | { 253 | fn: func() { 254 | os.Setenv("BUILD_VCS_NUMBER", "abcdefg") 255 | os.Setenv("BUILD_NUMBER", "false") 256 | }, 257 | ci: CI{ 258 | PR: PullRequest{ 259 | Revision: "abcdefg", 260 | Number: 0, 261 | }, 262 | URL: "", 263 | }, 264 | ok: false, 265 | }, 266 | } 267 | 268 | for _, testCase := range testCases { 269 | testCase.fn() 270 | ci, err := teamcity() 271 | if !reflect.DeepEqual(ci, testCase.ci) { 272 | t.Errorf("got %q but want %q", ci, testCase.ci) 273 | } 274 | if (err == nil) != testCase.ok { 275 | t.Errorf("got error %q", err) 276 | } 277 | } 278 | } 279 | 280 | func TestCodeBuild(t *testing.T) { 281 | envs := []string{ 282 | "CODEBUILD_RESOLVED_SOURCE_VERSION", 283 | "CODEBUILD_SOURCE_VERSION", 284 | "CODEBUILD_BUILD_URL", 285 | } 286 | saveEnvs := make(map[string]string) 287 | for _, key := range envs { 288 | saveEnvs[key] = os.Getenv(key) 289 | os.Unsetenv(key) 290 | } 291 | defer func() { 292 | for key, value := range saveEnvs { 293 | os.Setenv(key, value) 294 | } 295 | }() 296 | 297 | // https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref.html 298 | testCases := []struct { 299 | fn func() 300 | ci CI 301 | ok bool 302 | }{ 303 | { 304 | fn: func() { 305 | os.Setenv("CODEBUILD_RESOLVED_SOURCE_VERSION", "abcdefg") 306 | os.Setenv("CODEBUILD_SOURCE_VERSION", "pr/123") 307 | os.Setenv("CODEBUILD_BUILD_URL", "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new") 308 | }, 309 | ci: CI{ 310 | PR: PullRequest{ 311 | Revision: "abcdefg", 312 | Number: 123, 313 | }, 314 | URL: "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new", 315 | }, 316 | ok: true, 317 | }, 318 | { 319 | fn: func() { 320 | os.Setenv("CODEBUILD_RESOLVED_SOURCE_VERSION", "abcdefg") 321 | os.Setenv("CODEBUILD_SOURCE_VERSION", "pr/1") 322 | os.Setenv("CODEBUILD_BUILD_URL", "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new") 323 | }, 324 | ci: CI{ 325 | PR: PullRequest{ 326 | Revision: "abcdefg", 327 | Number: 1, 328 | }, 329 | URL: "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new", 330 | }, 331 | ok: true, 332 | }, 333 | { 334 | fn: func() { 335 | os.Setenv("CODEBUILD_RESOLVED_SOURCE_VERSION", "") 336 | os.Setenv("CODEBUILD_SOURCE_VERSION", "") 337 | os.Setenv("CODEBUILD_BUILD_URL", "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new") 338 | }, 339 | ci: CI{ 340 | PR: PullRequest{ 341 | Revision: "", 342 | Number: 0, 343 | }, 344 | URL: "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new", 345 | }, 346 | ok: true, 347 | }, 348 | { 349 | fn: func() { 350 | os.Setenv("CODEBUILD_RESOLVED_SOURCE_VERSION", "") 351 | os.Setenv("CODEBUILD_SOURCE_VERSION", "pr/abc") 352 | os.Setenv("CODEBUILD_BUILD_URL", "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new") 353 | }, 354 | ci: CI{ 355 | PR: PullRequest{ 356 | Revision: "", 357 | Number: 0, 358 | }, 359 | URL: "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new", 360 | }, 361 | ok: false, 362 | }, 363 | { 364 | fn: func() { 365 | os.Setenv("CODEBUILD_RESOLVED_SOURCE_VERSION", "") 366 | os.Setenv("CODEBUILD_SOURCE_VERSION", "f3008ac30d28ac38ae2533c2b153f00041661f22") 367 | os.Setenv("CODEBUILD_BUILD_URL", "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new") 368 | }, 369 | ci: CI{ 370 | PR: PullRequest{ 371 | Revision: "", 372 | Number: 0, 373 | }, 374 | URL: "https://ap-northeast-1.console.aws.amazon.com/codebuild/home?region=ap-northeast-1#/builds/test:f2ae4314-c2d6-4db6-83c2-eacbab1517b7/view/new", 375 | }, 376 | ok: true, 377 | }, 378 | } 379 | 380 | for _, testCase := range testCases { 381 | testCase.fn() 382 | ci, err := codebuild() 383 | if !reflect.DeepEqual(ci, testCase.ci) { 384 | t.Errorf("got %q but want %q", ci, testCase.ci) 385 | } 386 | if (err == nil) != testCase.ok { 387 | t.Errorf("got error %q", err) 388 | } 389 | } 390 | } 391 | 392 | func TestDrone(t *testing.T) { 393 | envs := []string{ 394 | "DRONE_COMMIT_SHA", 395 | "DRONE_PULL_REQUEST", 396 | } 397 | saveEnvs := make(map[string]string) 398 | for _, key := range envs { 399 | saveEnvs[key] = os.Getenv(key) 400 | os.Unsetenv(key) 401 | } 402 | defer func() { 403 | for key, value := range saveEnvs { 404 | os.Setenv(key, value) 405 | } 406 | }() 407 | 408 | // https://docs.drone.io/reference/environ/ 409 | testCases := []struct { 410 | fn func() 411 | ci CI 412 | ok bool 413 | }{ 414 | { 415 | fn: func() { 416 | os.Setenv("DRONE_COMMIT_SHA", "abcdefg") 417 | os.Setenv("DRONE_PULL_REQUEST", "1") 418 | os.Setenv("DRONE_BUILD_LINK", "https://cloud.drone.io/owner/repo/1") 419 | }, 420 | ci: CI{ 421 | PR: PullRequest{ 422 | Revision: "abcdefg", 423 | Number: 1, 424 | }, 425 | URL: "https://cloud.drone.io/owner/repo/1", 426 | }, 427 | ok: true, 428 | }, 429 | { 430 | fn: func() { 431 | os.Setenv("DRONE_COMMIT_SHA", "abcdefg") 432 | os.Setenv("DRONE_PULL_REQUEST", "") 433 | os.Setenv("DRONE_BUILD_LINK", "https://cloud.drone.io/owner/repo/1") 434 | }, 435 | ci: CI{ 436 | PR: PullRequest{ 437 | Revision: "abcdefg", 438 | Number: 0, 439 | }, 440 | URL: "https://cloud.drone.io/owner/repo/1", 441 | }, 442 | ok: true, 443 | }, 444 | { 445 | fn: func() { 446 | os.Setenv("DRONE_COMMIT_SHA", "abcdefg") 447 | os.Setenv("DRONE_PULL_REQUEST", "abc") 448 | os.Setenv("DRONE_BUILD_LINK", "https://cloud.drone.io/owner/repo/1") 449 | }, 450 | ci: CI{ 451 | PR: PullRequest{ 452 | Revision: "abcdefg", 453 | Number: 0, 454 | }, 455 | URL: "https://cloud.drone.io/owner/repo/1", 456 | }, 457 | ok: false, 458 | }, 459 | } 460 | 461 | for _, testCase := range testCases { 462 | testCase.fn() 463 | ci, err := drone() 464 | if !reflect.DeepEqual(ci, testCase.ci) { 465 | t.Errorf("got %q but want %q", ci, testCase.ci) 466 | } 467 | if (err == nil) != testCase.ok { 468 | t.Errorf("got error %q", err) 469 | } 470 | } 471 | } 472 | 473 | func TestJenkins(t *testing.T) { 474 | envs := []string{ 475 | "GIT_COMMIT", 476 | "BUILD_URL", 477 | "PULL_REQUEST_NUMBER", 478 | "PULL_REQUEST_URL", 479 | } 480 | saveEnvs := make(map[string]string) 481 | for _, key := range envs { 482 | saveEnvs[key] = os.Getenv(key) 483 | os.Unsetenv(key) 484 | } 485 | defer func() { 486 | for key, value := range saveEnvs { 487 | os.Setenv(key, value) 488 | } 489 | }() 490 | 491 | // https://wiki.jenkins.io/display/JENKINS/Building+a+software+project#Buildingasoftwareproject-belowJenkinsSetEnvironmentVariables 492 | testCases := []struct { 493 | fn func() 494 | ci CI 495 | ok bool 496 | }{ 497 | { 498 | fn: func() { 499 | os.Setenv("GIT_COMMIT", "abcdefg") 500 | os.Setenv("PULL_REQUEST_NUMBER", "123") 501 | os.Setenv("BUILD_URL", "http://jenkins.example.com/jenkins/job/test-job/1") 502 | }, 503 | ci: CI{ 504 | PR: PullRequest{ 505 | Revision: "abcdefg", 506 | Number: 123, 507 | }, 508 | URL: "http://jenkins.example.com/jenkins/job/test-job/1", 509 | }, 510 | ok: true, 511 | }, 512 | { 513 | fn: func() { 514 | os.Setenv("GIT_COMMIT", "abcdefg") 515 | os.Setenv("PULL_REQUEST_NUMBER", "") 516 | os.Setenv("PULL_REQUEST_URL", "https://github.com/owner/repo/pull/1111") 517 | os.Setenv("BUILD_URL", "http://jenkins.example.com/jenkins/job/test-job/123") 518 | }, 519 | ci: CI{ 520 | PR: PullRequest{ 521 | Revision: "abcdefg", 522 | Number: 1111, 523 | }, 524 | URL: "http://jenkins.example.com/jenkins/job/test-job/123", 525 | }, 526 | ok: true, 527 | }, 528 | { 529 | fn: func() { 530 | os.Setenv("PULL_REQUEST_NUMBER", "") 531 | os.Setenv("PULL_REQUEST_URL", "") 532 | os.Setenv("GIT_COMMIT", "abcdefg") 533 | os.Setenv("BUILD_URL", "http://jenkins.example.com/jenkins/job/test-job/456") 534 | }, 535 | ci: CI{ 536 | PR: PullRequest{ 537 | Revision: "abcdefg", 538 | Number: 0, 539 | }, 540 | URL: "http://jenkins.example.com/jenkins/job/test-job/456", 541 | }, 542 | ok: true, 543 | }, 544 | } 545 | 546 | for _, testCase := range testCases { 547 | testCase.fn() 548 | ci, err := jenkins() 549 | if !reflect.DeepEqual(ci, testCase.ci) { 550 | t.Errorf("got %q but want %q", ci, testCase.ci) 551 | } 552 | if (err == nil) != testCase.ok { 553 | t.Errorf("got error %q", err) 554 | } 555 | } 556 | } 557 | 558 | func TestJenkinsGitLab(t *testing.T) { 559 | envs := []string{ 560 | "BUILD_URL", 561 | "gitlabBefore", 562 | "gitlabMergeRequestIid", 563 | } 564 | saveEnvs := make(map[string]string) 565 | for _, key := range envs { 566 | saveEnvs[key] = os.Getenv(key) 567 | os.Unsetenv(key) 568 | } 569 | defer func() { 570 | for key, value := range saveEnvs { 571 | os.Setenv(key, value) 572 | } 573 | }() 574 | 575 | // https://wiki.jenkins.io/display/JENKINS/Building+a+software+project#Buildingasoftwareproject-belowJenkinsSetEnvironmentVariables 576 | testCases := []struct { 577 | fn func() 578 | ci CI 579 | ok bool 580 | }{ 581 | { 582 | fn: func() { 583 | os.Setenv("gitlabBefore", "abcdefg") 584 | os.Setenv("gitlabMergeRequestIid", "123") 585 | os.Setenv("BUILD_URL", "http://jenkins.example.com/jenkins/job/test-job/1") 586 | }, 587 | ci: CI{ 588 | PR: PullRequest{ 589 | Revision: "abcdefg", 590 | Number: 123, 591 | }, 592 | URL: "http://jenkins.example.com/jenkins/job/test-job/1", 593 | }, 594 | ok: true, 595 | }, 596 | { 597 | fn: func() { 598 | os.Setenv("gitlabMergeRequestIid", "") 599 | os.Setenv("gitlabBefore", "abcdefg") 600 | os.Setenv("BUILD_URL", "http://jenkins.example.com/jenkins/job/test-job/456") 601 | }, 602 | ci: CI{ 603 | PR: PullRequest{ 604 | Revision: "abcdefg", 605 | Number: 0, 606 | }, 607 | URL: "http://jenkins.example.com/jenkins/job/test-job/456", 608 | }, 609 | ok: true, 610 | }, 611 | } 612 | 613 | for _, testCase := range testCases { 614 | testCase.fn() 615 | ci, err := jenkins() 616 | if !reflect.DeepEqual(ci, testCase.ci) { 617 | t.Errorf("got %q but want %q", ci, testCase.ci) 618 | } 619 | if (err == nil) != testCase.ok { 620 | t.Errorf("got error %q", err) 621 | } 622 | } 623 | } 624 | 625 | func TestGitLabCI(t *testing.T) { 626 | envs := []string{ 627 | "CI_COMMIT_SHA", 628 | "CI_JOB_URL", 629 | "CI_MERGE_REQUEST_IID", 630 | "CI_MERGE_REQUEST_REF_PATH", 631 | } 632 | saveEnvs := make(map[string]string) 633 | for _, key := range envs { 634 | saveEnvs[key] = os.Getenv(key) 635 | os.Unsetenv(key) 636 | } 637 | defer func() { 638 | for key, value := range saveEnvs { 639 | os.Setenv(key, value) 640 | } 641 | }() 642 | 643 | // https://docs.gitlab.com/ee/ci/variables/README.html 644 | testCases := []struct { 645 | fn func() 646 | ci CI 647 | ok bool 648 | }{ 649 | { 650 | fn: func() { 651 | os.Setenv("CI_COMMIT_SHA", "abcdefg") 652 | os.Setenv("CI_JOB_URL", "https://gitlab.com/owner/repo/-/jobs/111111111") 653 | os.Setenv("CI_MERGE_REQUEST_IID", "1") 654 | os.Setenv("CI_MERGE_REQUEST_REF_PATH", "refs/merge-requests/1/head") 655 | }, 656 | ci: CI{ 657 | PR: PullRequest{ 658 | Revision: "abcdefg", 659 | Number: 1, 660 | }, 661 | URL: "https://gitlab.com/owner/repo/-/jobs/111111111", 662 | }, 663 | ok: true, 664 | }, 665 | { 666 | fn: func() { 667 | os.Setenv("CI_COMMIT_SHA", "hijklmn") 668 | os.Setenv("CI_JOB_URL", "https://gitlab.com/owner/repo/-/jobs/222222222") 669 | os.Setenv("CI_MERGE_REQUEST_REF_PATH", "refs/merge-requests/123/head") 670 | os.Unsetenv("CI_MERGE_REQUEST_IID") 671 | }, 672 | ci: CI{ 673 | PR: PullRequest{ 674 | Revision: "hijklmn", 675 | Number: 123, 676 | }, 677 | URL: "https://gitlab.com/owner/repo/-/jobs/222222222", 678 | }, 679 | ok: true, 680 | }, 681 | { 682 | fn: func() { 683 | os.Setenv("CI_COMMIT_SHA", "hijklmn") 684 | os.Setenv("CI_JOB_URL", "https://gitlab.com/owner/repo/-/jobs/333333333") 685 | os.Unsetenv("CI_MERGE_REQUEST_IID") 686 | os.Unsetenv("CI_MERGE_REQUEST_REF_PATH") 687 | }, 688 | ci: CI{ 689 | PR: PullRequest{ 690 | Revision: "hijklmn", 691 | Number: 0, 692 | }, 693 | URL: "https://gitlab.com/owner/repo/-/jobs/333333333", 694 | }, 695 | ok: true, 696 | }, 697 | } 698 | 699 | for _, testCase := range testCases { 700 | testCase.fn() 701 | ci, err := gitlabci() 702 | if !reflect.DeepEqual(ci, testCase.ci) { 703 | t.Errorf("got %q but want %q", ci, testCase.ci) 704 | } 705 | if (err == nil) != testCase.ok { 706 | t.Errorf("got error %q", err) 707 | } 708 | } 709 | } 710 | 711 | func TestGitHubActions(t *testing.T) { 712 | envs := []string{ 713 | "GITHUB_SHA", 714 | "GITHUB_REPOSITORY", 715 | "GITHUB_RUN_ID", 716 | "GITHUB_REF", 717 | } 718 | saveEnvs := make(map[string]string) 719 | for _, key := range envs { 720 | saveEnvs[key] = os.Getenv(key) 721 | os.Unsetenv(key) 722 | } 723 | defer func() { 724 | for key, value := range saveEnvs { 725 | os.Setenv(key, value) 726 | } 727 | }() 728 | 729 | // https://help.github.com/ja/actions/configuring-and-managing-workflows/using-environment-variables 730 | testCases := []struct { 731 | fn func() 732 | ci CI 733 | ok bool 734 | }{ 735 | { 736 | fn: func() { 737 | os.Setenv("GITHUB_SHA", "abcdefg") 738 | os.Setenv("GITHUB_REPOSITORY", "mercari/tfnotify") 739 | os.Setenv("GITHUB_RUN_ID", "12345") 740 | }, 741 | ci: CI{ 742 | PR: PullRequest{ 743 | Revision: "abcdefg", 744 | Number: 0, 745 | }, 746 | URL: "https://github.com/mercari/tfnotify/actions/runs/12345", 747 | }, 748 | ok: true, 749 | }, 750 | { 751 | fn: func() { 752 | os.Setenv("GITHUB_SHA", "abcdefg") 753 | os.Setenv("GITHUB_REPOSITORY", "mercari/tfnotify") 754 | os.Setenv("GITHUB_RUN_ID", "12345") 755 | os.Setenv("GITHUB_REF", "refs/pull/123/merge") 756 | }, 757 | ci: CI{ 758 | PR: PullRequest{ 759 | Revision: "abcdefg", 760 | Number: 123, 761 | }, 762 | URL: "https://github.com/mercari/tfnotify/actions/runs/12345", 763 | }, 764 | ok: true, 765 | }, 766 | } 767 | 768 | for _, testCase := range testCases { 769 | testCase.fn() 770 | ci, err := githubActions() 771 | if !reflect.DeepEqual(ci, testCase.ci) { 772 | t.Errorf("got %q but want %q", ci, testCase.ci) 773 | } 774 | if (err == nil) != testCase.ok { 775 | t.Errorf("got error %q", err) 776 | } 777 | } 778 | } 779 | 780 | func TestCloudBuild(t *testing.T) { 781 | envs := []string{ 782 | "COMMIT_SHA", 783 | "BUILD_ID", 784 | "PROJECT_ID", 785 | "_PR_NUMBER", 786 | "REGION", 787 | } 788 | saveEnvs := make(map[string]string) 789 | for _, key := range envs { 790 | saveEnvs[key] = os.Getenv(key) 791 | os.Unsetenv(key) 792 | } 793 | defer func() { 794 | for key, value := range saveEnvs { 795 | os.Setenv(key, value) 796 | } 797 | }() 798 | 799 | // https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values 800 | testCases := []struct { 801 | fn func() 802 | ci CI 803 | ok bool 804 | }{ 805 | { 806 | fn: func() { 807 | os.Setenv("COMMIT_SHA", "abcdefg") 808 | os.Setenv("BUILD_ID", "build-id") 809 | os.Setenv("PROJECT_ID", "gcp-project-id") 810 | os.Setenv("_PR_NUMBER", "123") 811 | os.Setenv("_REGION", "asia-northeast1") 812 | }, 813 | ci: CI{ 814 | PR: PullRequest{ 815 | Revision: "abcdefg", 816 | Number: 123, 817 | }, 818 | URL: "https://console.cloud.google.com/cloud-build/builds;region=asia-northeast1/build-id?project=gcp-project-id", 819 | }, 820 | ok: true, 821 | }, 822 | { 823 | fn: func() { 824 | os.Setenv("COMMIT_SHA", "") 825 | os.Setenv("BUILD_ID", "build-id") 826 | os.Setenv("PROJECT_ID", "gcp-project-id") 827 | os.Setenv("_PR_NUMBER", "") 828 | os.Setenv("_REGION", "") 829 | }, 830 | ci: CI{ 831 | PR: PullRequest{ 832 | Revision: "", 833 | Number: 0, 834 | }, 835 | URL: "https://console.cloud.google.com/cloud-build/builds;region=global/build-id?project=gcp-project-id", 836 | }, 837 | ok: true, 838 | }, 839 | { 840 | fn: func() { 841 | os.Setenv("COMMIT_SHA", "") 842 | os.Setenv("BUILD_ID", "build-id") 843 | os.Setenv("PROJECT_ID", "gcp-project-id") 844 | os.Setenv("_PR_NUMBER", "abc") 845 | }, 846 | ci: CI{ 847 | PR: PullRequest{ 848 | Revision: "", 849 | Number: 0, 850 | }, 851 | URL: "https://console.cloud.google.com/cloud-build/builds;region=global/build-id?project=gcp-project-id", 852 | }, 853 | ok: false, 854 | }, 855 | } 856 | 857 | for _, testCase := range testCases { 858 | testCase.fn() 859 | ci, err := cloudbuild() 860 | if !reflect.DeepEqual(ci, testCase.ci) { 861 | t.Errorf("got %q but want %q", ci, testCase.ci) 862 | } 863 | if (err == nil) != testCase.ok { 864 | t.Errorf("got error %q", err) 865 | } 866 | } 867 | } 868 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | // Config is for tfnotify config structure 14 | type Config struct { 15 | CI string `yaml:"ci"` 16 | Notifier Notifier `yaml:"notifier"` 17 | Terraform Terraform `yaml:"terraform"` 18 | 19 | path string 20 | } 21 | 22 | // Notifier is a notification notifier 23 | type Notifier struct { 24 | Github GithubNotifier `yaml:"github"` 25 | Gitlab GitlabNotifier `yaml:"gitlab"` 26 | Slack SlackNotifier `yaml:"slack"` 27 | Typetalk TypetalkNotifier `yaml:"typetalk"` 28 | } 29 | 30 | // GithubNotifier is a notifier for GitHub 31 | type GithubNotifier struct { 32 | Token string `yaml:"token"` 33 | BaseURL string `yaml:"base_url"` 34 | Repository Repository `yaml:"repository"` 35 | } 36 | 37 | // GitlabNotifier is a notifier for GitLab 38 | type GitlabNotifier struct { 39 | Token string `yaml:"token"` 40 | BaseURL string `yaml:"base_url"` 41 | Repository Repository `yaml:"repository"` 42 | } 43 | 44 | // Repository represents a GitHub repository 45 | type Repository struct { 46 | Owner string `yaml:"owner"` 47 | Name string `yaml:"name"` 48 | } 49 | 50 | // SlackNotifier is a notifier for Slack 51 | type SlackNotifier struct { 52 | Token string `yaml:"token"` 53 | Channel string `yaml:"channel"` 54 | Bot string `yaml:"bot"` 55 | } 56 | 57 | // TypetalkNotifier is a notifier for Typetalk 58 | type TypetalkNotifier struct { 59 | Token string `yaml:"token"` 60 | TopicID string `yaml:"topic_id"` 61 | } 62 | 63 | // Terraform represents terraform configurations 64 | type Terraform struct { 65 | Default Default `yaml:"default"` 66 | Fmt Fmt `yaml:"fmt"` 67 | Plan Plan `yaml:"plan"` 68 | Apply Apply `yaml:"apply"` 69 | UseRawOutput bool `yaml:"use_raw_output,omitempty"` 70 | Validate Validate `yaml:"validate"` 71 | } 72 | 73 | // Default is a default setting for terraform commands 74 | type Default struct { 75 | Template string `yaml:"template"` 76 | } 77 | 78 | // Fmt is a terraform fmt config 79 | type Fmt struct { 80 | Template string `yaml:"template"` 81 | } 82 | 83 | // Validate is a terraform validate config 84 | type Validate struct { 85 | Template string `yaml:"template"` 86 | } 87 | 88 | // Plan is a terraform plan config 89 | type Plan struct { 90 | Template string `yaml:"template"` 91 | WhenAddOrUpdateOnly WhenAddOrUpdateOnly `yaml:"when_add_or_update_only,omitempty"` 92 | WhenDestroy WhenDestroy `yaml:"when_destroy,omitempty"` 93 | WhenNoChanges WhenNoChanges `yaml:"when_no_changes,omitempty"` 94 | WhenPlanError WhenPlanError `yaml:"when_plan_error,omitempty"` 95 | } 96 | 97 | // WhenAddOrUpdateOnly is a configuration to notify the plan result contains new or updated in place resources 98 | type WhenAddOrUpdateOnly struct { 99 | Label string `yaml:"label,omitempty"` 100 | } 101 | 102 | // WhenDestroy is a configuration to notify the plan result contains destroy operation 103 | type WhenDestroy struct { 104 | Label string `yaml:"label,omitempty"` 105 | Template string `yaml:"template,omitempty"` 106 | } 107 | 108 | // WhenNoChange is a configuration to add a label when the plan result contains no change 109 | type WhenNoChanges struct { 110 | Label string `yaml:"label,omitempty"` 111 | } 112 | 113 | // WhenPlanError is a configuration to notify the plan result returns an error 114 | type WhenPlanError struct { 115 | Label string `yaml:"label,omitempty"` 116 | } 117 | 118 | // Apply is a terraform apply config 119 | type Apply struct { 120 | Template string `yaml:"template"` 121 | } 122 | 123 | // LoadFile binds the config file to Config structure 124 | func (cfg *Config) LoadFile(path string) error { 125 | cfg.path = path 126 | _, err := os.Stat(cfg.path) 127 | if err != nil { 128 | return fmt.Errorf("%s: no config file", cfg.path) 129 | } 130 | raw, _ := ioutil.ReadFile(cfg.path) 131 | return yaml.Unmarshal(raw, cfg) 132 | } 133 | 134 | // Validation validates config file 135 | func (cfg *Config) Validation() error { 136 | switch strings.ToLower(cfg.CI) { 137 | case "": 138 | return errors.New("ci: need to be set") 139 | case "circleci", "circle-ci": 140 | // ok pattern 141 | case "gitlabci", "gitlab-ci": 142 | // ok pattern 143 | case "travis", "travisci", "travis-ci": 144 | // ok pattern 145 | case "codebuild": 146 | // ok pattern 147 | case "teamcity": 148 | // ok pattern 149 | case "drone": 150 | // ok pattern 151 | case "jenkins": 152 | // ok pattern 153 | case "github-actions": 154 | // ok pattern 155 | case "cloud-build", "cloudbuild": 156 | // ok pattern 157 | default: 158 | return fmt.Errorf("%s: not supported yet", cfg.CI) 159 | } 160 | if cfg.isDefinedGithub() { 161 | if cfg.Notifier.Github.Repository.Owner == "" { 162 | return fmt.Errorf("repository owner is missing") 163 | } 164 | if cfg.Notifier.Github.Repository.Name == "" { 165 | return fmt.Errorf("repository name is missing") 166 | } 167 | } 168 | if cfg.isDefinedGitlab() { 169 | if cfg.Notifier.Gitlab.Repository.Owner == "" { 170 | return fmt.Errorf("repository owner is missing") 171 | } 172 | if cfg.Notifier.Gitlab.Repository.Name == "" { 173 | return fmt.Errorf("repository name is missing") 174 | } 175 | } 176 | if cfg.isDefinedSlack() { 177 | if cfg.Notifier.Slack.Channel == "" { 178 | return fmt.Errorf("slack channel id is missing") 179 | } 180 | } 181 | if cfg.isDefinedTypetalk() { 182 | if cfg.Notifier.Typetalk.TopicID == "" { 183 | return fmt.Errorf("typetalk topic id is missing") 184 | } 185 | } 186 | notifier := cfg.GetNotifierType() 187 | if notifier == "" { 188 | return fmt.Errorf("notifier is missing") 189 | } 190 | return nil 191 | } 192 | 193 | func (cfg *Config) isDefinedGithub() bool { 194 | // not empty 195 | return cfg.Notifier.Github != (GithubNotifier{}) 196 | } 197 | 198 | func (cfg *Config) isDefinedGitlab() bool { 199 | // not empty 200 | return cfg.Notifier.Gitlab != (GitlabNotifier{}) 201 | } 202 | 203 | func (cfg *Config) isDefinedSlack() bool { 204 | // not empty 205 | return cfg.Notifier.Slack != (SlackNotifier{}) 206 | } 207 | 208 | func (cfg *Config) isDefinedTypetalk() bool { 209 | // not empty 210 | return cfg.Notifier.Typetalk != (TypetalkNotifier{}) 211 | } 212 | 213 | // GetNotifierType return notifier type described in Config 214 | func (cfg *Config) GetNotifierType() string { 215 | if cfg.isDefinedGithub() { 216 | return "github" 217 | } 218 | if cfg.isDefinedGitlab() { 219 | return "gitlab" 220 | } 221 | if cfg.isDefinedSlack() { 222 | return "slack" 223 | } 224 | if cfg.isDefinedTypetalk() { 225 | return "typetalk" 226 | } 227 | return "" 228 | } 229 | 230 | // Find returns config path 231 | func (cfg *Config) Find(file string) (string, error) { 232 | var files []string 233 | if file == "" { 234 | files = []string{ 235 | "tfnotify.yaml", 236 | "tfnotify.yml", 237 | ".tfnotify.yaml", 238 | ".tfnotify.yml", 239 | } 240 | } else { 241 | files = []string{file} 242 | } 243 | for _, file := range files { 244 | _, err := os.Stat(file) 245 | if err == nil { 246 | return file, nil 247 | } 248 | } 249 | return "", errors.New("config for tfnotify is not found at all") 250 | } 251 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | 8 | yaml "gopkg.in/yaml.v2" 9 | ) 10 | 11 | func helperLoadConfig(contents []byte) (*Config, error) { 12 | cfg := &Config{} 13 | err := yaml.Unmarshal(contents, cfg) 14 | return cfg, err 15 | } 16 | 17 | func TestLoadFile(t *testing.T) { 18 | testCases := []struct { 19 | file string 20 | cfg Config 21 | ok bool 22 | }{ 23 | { 24 | file: "../example.tfnotify.yaml", 25 | cfg: Config{ 26 | CI: "circleci", 27 | Notifier: Notifier{ 28 | Github: GithubNotifier{ 29 | Token: "$GITHUB_TOKEN", 30 | Repository: Repository{ 31 | Owner: "mercari", 32 | Name: "tfnotify", 33 | }, 34 | }, 35 | Slack: SlackNotifier{ 36 | Token: "", 37 | Channel: "", 38 | Bot: "", 39 | }, 40 | Typetalk: TypetalkNotifier{ 41 | Token: "", 42 | TopicID: "", 43 | }, 44 | }, 45 | Terraform: Terraform{ 46 | Default: Default{ 47 | Template: "", 48 | }, 49 | Fmt: Fmt{ 50 | Template: "", 51 | }, 52 | Validate: Validate{ 53 | Template: "", 54 | }, 55 | Plan: Plan{ 56 | Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\n
Details (Click me)\n\n
{{ .Body }}\n
\n", 57 | WhenDestroy: WhenDestroy{}, 58 | }, 59 | Apply: Apply{ 60 | Template: "", 61 | }, 62 | UseRawOutput: false, 63 | }, 64 | path: "../example.tfnotify.yaml", 65 | }, 66 | ok: true, 67 | }, 68 | { 69 | file: "../example-with-destroy-and-result-labels.tfnotify.yaml", 70 | cfg: Config{ 71 | CI: "circleci", 72 | Notifier: Notifier{ 73 | Github: GithubNotifier{ 74 | Token: "$GITHUB_TOKEN", 75 | Repository: Repository{ 76 | Owner: "mercari", 77 | Name: "tfnotify", 78 | }, 79 | }, 80 | Slack: SlackNotifier{ 81 | Token: "", 82 | Channel: "", 83 | Bot: "", 84 | }, 85 | Typetalk: TypetalkNotifier{ 86 | Token: "", 87 | TopicID: "", 88 | }, 89 | }, 90 | Terraform: Terraform{ 91 | Default: Default{ 92 | Template: "", 93 | }, 94 | Fmt: Fmt{ 95 | Template: "", 96 | }, 97 | Validate: Validate{ 98 | Template: "", 99 | }, 100 | Plan: Plan{ 101 | Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\n
Details (Click me)\n\n
{{ .Body }}\n
\n", 102 | WhenAddOrUpdateOnly: WhenAddOrUpdateOnly{ 103 | Label: "add-or-update", 104 | }, 105 | WhenDestroy: WhenDestroy{ 106 | Label: "destroy", 107 | Template: "## :warning: WARNING: Resource Deletion will happen :warning:\n\nThis plan contains **resource deletion**. Please check the plan result very carefully!\n", 108 | }, 109 | WhenPlanError: WhenPlanError{ 110 | Label: "error", 111 | }, 112 | WhenNoChanges: WhenNoChanges{ 113 | Label: "no-changes", 114 | }, 115 | }, 116 | Apply: Apply{ 117 | Template: "", 118 | }, 119 | UseRawOutput: false, 120 | }, 121 | path: "../example-with-destroy-and-result-labels.tfnotify.yaml", 122 | }, 123 | ok: true, 124 | }, 125 | { 126 | file: "no-such-config.yaml", 127 | cfg: Config{ 128 | CI: "circleci", 129 | Notifier: Notifier{ 130 | Github: GithubNotifier{ 131 | Token: "$GITHUB_TOKEN", 132 | Repository: Repository{ 133 | Owner: "mercari", 134 | Name: "tfnotify", 135 | }, 136 | }, 137 | Slack: SlackNotifier{ 138 | Token: "", 139 | Channel: "", 140 | Bot: "", 141 | }, 142 | Typetalk: TypetalkNotifier{ 143 | Token: "", 144 | TopicID: "", 145 | }, 146 | }, 147 | Terraform: Terraform{ 148 | Default: Default{ 149 | Template: "", 150 | }, 151 | Fmt: Fmt{ 152 | Template: "", 153 | }, 154 | Validate: Validate{ 155 | Template: "", 156 | }, 157 | Plan: Plan{ 158 | Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\n
Details (Click me)\n\n
{{ .Body }}\n
\n", 159 | WhenDestroy: WhenDestroy{}, 160 | }, 161 | Apply: Apply{ 162 | Template: "", 163 | }, 164 | }, 165 | path: "no-such-config.yaml", 166 | }, 167 | ok: false, 168 | }, 169 | } 170 | 171 | for _, testCase := range testCases { 172 | var cfg Config 173 | 174 | err := cfg.LoadFile(testCase.file) 175 | if err == nil { 176 | if !testCase.ok { 177 | t.Error("got no error but want error") 178 | } else { 179 | if !reflect.DeepEqual(cfg, testCase.cfg) { 180 | t.Errorf("got %#v but want: %#v", cfg, testCase.cfg) 181 | } 182 | } 183 | } else { 184 | if testCase.ok { 185 | t.Errorf("got error %q but want no error", err) 186 | } 187 | } 188 | } 189 | } 190 | 191 | func TestValidation(t *testing.T) { 192 | testCases := []struct { 193 | contents []byte 194 | expected string 195 | }{ 196 | { 197 | contents: []byte(""), 198 | expected: "ci: need to be set", 199 | }, 200 | { 201 | contents: []byte("ci: rare-ci\n"), 202 | expected: "rare-ci: not supported yet", 203 | }, 204 | { 205 | contents: []byte("ci: circleci\n"), 206 | expected: "notifier is missing", 207 | }, 208 | { 209 | contents: []byte("ci: travisci\n"), 210 | expected: "notifier is missing", 211 | }, 212 | { 213 | contents: []byte("ci: codebuild\n"), 214 | expected: "notifier is missing", 215 | }, 216 | { 217 | contents: []byte("ci: teamcity\n"), 218 | expected: "notifier is missing", 219 | }, 220 | { 221 | contents: []byte("ci: drone\n"), 222 | expected: "notifier is missing", 223 | }, 224 | { 225 | contents: []byte("ci: jenkins\n"), 226 | expected: "notifier is missing", 227 | }, 228 | { 229 | contents: []byte("ci: gitlabci\n"), 230 | expected: "notifier is missing", 231 | }, 232 | { 233 | contents: []byte("ci: cloudbuild\n"), 234 | expected: "notifier is missing", 235 | }, 236 | { 237 | contents: []byte("ci: cloud-build\n"), 238 | expected: "notifier is missing", 239 | }, 240 | { 241 | contents: []byte("ci: circleci\nnotifier:\n github:\n"), 242 | expected: "notifier is missing", 243 | }, 244 | { 245 | contents: []byte("ci: circleci\nnotifier:\n github:\n token: token\n"), 246 | expected: "repository owner is missing", 247 | }, 248 | { 249 | contents: []byte(` 250 | ci: circleci 251 | notifier: 252 | github: 253 | token: token 254 | repository: 255 | owner: owner 256 | `), 257 | expected: "repository name is missing", 258 | }, 259 | { 260 | contents: []byte(` 261 | ci: circleci 262 | notifier: 263 | github: 264 | token: token 265 | repository: 266 | owner: owner 267 | name: name 268 | `), 269 | expected: "", 270 | }, 271 | { 272 | contents: []byte(` 273 | ci: circleci 274 | notifier: 275 | slack: 276 | `), 277 | expected: "notifier is missing", 278 | }, 279 | { 280 | contents: []byte(` 281 | ci: circleci 282 | notifier: 283 | slack: 284 | token: token 285 | `), 286 | expected: "slack channel id is missing", 287 | }, 288 | { 289 | contents: []byte(` 290 | ci: circleci 291 | notifier: 292 | slack: 293 | token: token 294 | channel: channel 295 | `), 296 | expected: "", 297 | }, 298 | { 299 | contents: []byte(` 300 | ci: circleci 301 | notifier: 302 | typetalk: 303 | `), 304 | expected: "notifier is missing", 305 | }, 306 | { 307 | contents: []byte(` 308 | ci: circleci 309 | notifier: 310 | typetalk: 311 | token: token 312 | topic_id: 12345 313 | `), 314 | expected: "", 315 | }, 316 | { 317 | contents: []byte(` 318 | ci: gitlabci 319 | notifier: 320 | gitlab: 321 | token: token 322 | repository: 323 | owner: owner 324 | `), 325 | expected: "repository name is missing", 326 | }, 327 | { 328 | contents: []byte(` 329 | ci: gitlabci 330 | notifier: 331 | slack: 332 | `), 333 | expected: "notifier is missing", 334 | }, 335 | { 336 | contents: []byte(` 337 | ci: gitlabci 338 | notifier: 339 | gitlab: 340 | token: token 341 | repository: 342 | owner: owner 343 | name: name 344 | `), 345 | expected: "", 346 | }, 347 | { 348 | contents: []byte(` 349 | ci: gitlabci 350 | notifier: 351 | typetalk: 352 | token: token 353 | `), 354 | expected: "typetalk topic id is missing", 355 | }, 356 | { 357 | contents: []byte(` 358 | ci: circleci 359 | notifier: 360 | typetalk: 361 | token: token 362 | topic_id: 12345 363 | `), 364 | expected: "", 365 | }, 366 | } 367 | for _, testCase := range testCases { 368 | cfg, err := helperLoadConfig(testCase.contents) 369 | if err != nil { 370 | t.Fatal(err) 371 | } 372 | err = cfg.Validation() 373 | if err == nil { 374 | if testCase.expected != "" { 375 | t.Errorf("got no error but want %q", testCase.expected) 376 | } 377 | } else { 378 | if err.Error() != testCase.expected { 379 | t.Errorf("got %q but want %q", err.Error(), testCase.expected) 380 | } 381 | } 382 | } 383 | } 384 | 385 | func TestGetNotifierType(t *testing.T) { 386 | testCases := []struct { 387 | contents []byte 388 | expected string 389 | }{ 390 | { 391 | contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n github:\n token: token\n"), 392 | expected: "github", 393 | }, 394 | { 395 | contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n slack:\n token: token\n"), 396 | expected: "slack", 397 | }, 398 | { 399 | contents: []byte("repository:\n owner: a\n name: b\nci: circleci\nnotifier:\n typetalk:\n token: token\n"), 400 | expected: "typetalk", 401 | }, 402 | { 403 | contents: []byte("repository:\n owner: a\n name: b\nci: gitlabci\nnotifier:\n gitlab:\n token: token\n"), 404 | expected: "gitlab", 405 | }, 406 | } 407 | for _, testCase := range testCases { 408 | cfg, err := helperLoadConfig(testCase.contents) 409 | if err != nil { 410 | t.Fatal(err) 411 | } 412 | actual := cfg.GetNotifierType() 413 | if actual != testCase.expected { 414 | t.Errorf("got %q but want %q", actual, testCase.expected) 415 | } 416 | } 417 | } 418 | 419 | func createDummy(file string) { 420 | validConfig := func(file string) bool { 421 | for _, c := range []string{ 422 | "tfnotify.yaml", 423 | "tfnotify.yml", 424 | ".tfnotify.yaml", 425 | ".tfnotify.yml", 426 | } { 427 | if file == c { 428 | return true 429 | } 430 | } 431 | return false 432 | } 433 | if !validConfig(file) { 434 | return 435 | } 436 | if _, err := os.Stat(file); err == nil { 437 | return 438 | } 439 | f, err := os.OpenFile(file, os.O_RDONLY|os.O_CREATE, 0666) 440 | if err != nil { 441 | panic(err) 442 | } 443 | defer f.Close() 444 | } 445 | 446 | func removeDummy(file string) { 447 | os.Remove(file) 448 | } 449 | 450 | func TestFind(t *testing.T) { 451 | testCases := []struct { 452 | file string 453 | expect string 454 | ok bool 455 | }{ 456 | { 457 | // valid config 458 | file: ".tfnotify.yaml", 459 | expect: ".tfnotify.yaml", 460 | ok: true, 461 | }, 462 | { 463 | // valid config 464 | file: "tfnotify.yaml", 465 | expect: "tfnotify.yaml", 466 | ok: true, 467 | }, 468 | { 469 | // valid config 470 | file: ".tfnotify.yml", 471 | expect: ".tfnotify.yml", 472 | ok: true, 473 | }, 474 | { 475 | // valid config 476 | file: "tfnotify.yml", 477 | expect: "tfnotify.yml", 478 | ok: true, 479 | }, 480 | { 481 | // in case of no args passed 482 | file: "", 483 | expect: "tfnotify.yaml", 484 | ok: true, 485 | }, 486 | } 487 | var cfg Config 488 | for _, testCase := range testCases { 489 | createDummy(testCase.file) 490 | defer removeDummy(testCase.file) 491 | actual, err := cfg.Find(testCase.file) 492 | if (err == nil) != testCase.ok { 493 | t.Errorf("got error %q", err) 494 | } 495 | if actual != testCase.expect { 496 | t.Errorf("got %q but want %q", actual, testCase.expect) 497 | } 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // Exit codes are int values for the exit code that shell interpreter can interpret 9 | const ( 10 | ExitCodeOK int = 0 11 | ExitCodeError int = iota 12 | ) 13 | 14 | // ErrorFormatter is the interface for format 15 | type ErrorFormatter interface { 16 | Format(s fmt.State, verb rune) 17 | } 18 | 19 | // ExitCoder is the wrapper interface for urfave/cli 20 | type ExitCoder interface { 21 | error 22 | ExitCode() int 23 | } 24 | 25 | // ExitError is the wrapper struct for urfave/cli 26 | type ExitError struct { 27 | exitCode int 28 | err error 29 | } 30 | 31 | // NewExitError makes a new ExitError 32 | func NewExitError(exitCode int, err error) *ExitError { 33 | return &ExitError{ 34 | exitCode: exitCode, 35 | err: err, 36 | } 37 | } 38 | 39 | // Error returns the string message, fulfilling the interface required by `error` 40 | func (ee *ExitError) Error() string { 41 | if ee.err == nil { 42 | return "" 43 | } 44 | return fmt.Sprintf("%v", ee.err) 45 | } 46 | 47 | // ExitCode returns the exit code, fulfilling the interface required by `ExitCoder` 48 | func (ee *ExitError) ExitCode() int { 49 | return ee.exitCode 50 | } 51 | 52 | // HandleExit returns int value that shell interpreter can interpret as the exit code 53 | // If err has error message, it will be displayed to stderr 54 | // This function is heavily inspired by urfave/cli.HandleExitCoder 55 | func HandleExit(err error) int { 56 | if err == nil { 57 | return ExitCodeOK 58 | } 59 | 60 | if exitErr, ok := err.(ExitCoder); ok { 61 | if err.Error() != "" { 62 | if _, ok := exitErr.(ErrorFormatter); ok { 63 | fmt.Fprintf(os.Stderr, "%+v\n", err) 64 | } else { 65 | fmt.Fprintln(os.Stderr, err) 66 | } 67 | } 68 | return exitErr.ExitCode() 69 | } 70 | 71 | if _, ok := err.(error); ok { 72 | fmt.Fprintf(os.Stderr, "%v\n", err) 73 | return ExitCodeError 74 | } 75 | 76 | return ExitCodeOK 77 | } 78 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestHandleError(t *testing.T) { 9 | testCases := []struct { 10 | err error 11 | exitCode int 12 | }{ 13 | { 14 | err: NewExitError(1, errors.New("error")), 15 | exitCode: 1, 16 | }, 17 | { 18 | err: NewExitError(0, errors.New("error")), 19 | exitCode: 0, 20 | }, 21 | { 22 | err: errors.New("error"), 23 | exitCode: 1, 24 | }, 25 | { 26 | err: NewExitError(0, nil), 27 | exitCode: 0, 28 | }, 29 | { 30 | err: NewExitError(1, nil), 31 | exitCode: 1, 32 | }, 33 | { 34 | err: nil, 35 | exitCode: 0, 36 | }, 37 | } 38 | 39 | for _, testCase := range testCases { 40 | // TODO: test stderr 41 | exitCode := HandleExit(testCase.err) 42 | if exitCode != testCase.exitCode { 43 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example-use-raw-output.tfnotify.yaml: -------------------------------------------------------------------------------- 1 | ci: circleci 2 | notifier: 3 | github: 4 | token: $GITHUB_TOKEN 5 | repository: 6 | owner: "mercari" 7 | name: "tfnotify" 8 | terraform: 9 | use_raw_output: true 10 | plan: 11 | template: | 12 | {{ .Title }} 13 | {{ .Message }} 14 | {{if .Result}} 15 |
{{ .Result }}
16 |       
17 | {{end}} 18 |
Details (Click me) 19 | 20 |
{{ .Body }}
21 |       
22 | -------------------------------------------------------------------------------- /example-with-destroy-and-result-labels.tfnotify.yaml: -------------------------------------------------------------------------------- 1 | ci: circleci 2 | notifier: 3 | github: 4 | token: $GITHUB_TOKEN 5 | repository: 6 | owner: "mercari" 7 | name: "tfnotify" 8 | terraform: 9 | plan: 10 | template: | 11 | {{ .Title }} 12 | {{ .Message }} 13 | {{if .Result}} 14 |
{{ .Result }}
15 |       
16 | {{end}} 17 |
Details (Click me) 18 | 19 |
{{ .Body }}
20 |       
21 | when_add_or_update_only: 22 | label: "add-or-update" 23 | when_destroy: 24 | label: "destroy" 25 | template: | 26 | ## :warning: WARNING: Resource Deletion will happen :warning: 27 | 28 | This plan contains **resource deletion**. Please check the plan result very carefully! 29 | when_no_changes: 30 | label: "no-changes" 31 | when_plan_error: 32 | label: "error" 33 | -------------------------------------------------------------------------------- /example.tfnotify.yaml: -------------------------------------------------------------------------------- 1 | ci: circleci 2 | notifier: 3 | github: 4 | token: $GITHUB_TOKEN 5 | repository: 6 | owner: "mercari" 7 | name: "tfnotify" 8 | terraform: 9 | plan: 10 | template: | 11 | {{ .Title }} 12 | {{ .Message }} 13 | {{if .Result}} 14 |
{{ .Result }}
15 |       
16 | {{end}} 17 |
Details (Click me) 18 | 19 |
{{ .Body }}
20 |       
21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mercari/tfnotify 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 7 | github.com/google/go-github v17.0.0+incompatible 8 | github.com/kr/pretty v0.2.0 // indirect 9 | github.com/lestrrat-go/pdebug v0.0.0-20210111095411-35b07dbf089b // indirect 10 | github.com/lestrrat-go/slack v0.0.0-20190827134815-1aaae719550a 11 | github.com/mattn/go-colorable v0.1.12 12 | github.com/nulab/go-typetalk v2.1.1+incompatible 13 | github.com/pkg/errors v0.9.1 // indirect 14 | github.com/urfave/cli v1.22.9 15 | github.com/xanzy/go-gitlab v0.69.0 16 | golang.org/x/net v0.0.0-20220708220712-1185a9018129 // indirect 17 | golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 18 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect 19 | golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect 20 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 21 | gopkg.in/yaml.v2 v2.4.0 22 | ) 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/mercari/tfnotify/config" 9 | "github.com/mercari/tfnotify/notifier" 10 | "github.com/mercari/tfnotify/notifier/github" 11 | "github.com/mercari/tfnotify/notifier/gitlab" 12 | "github.com/mercari/tfnotify/notifier/slack" 13 | "github.com/mercari/tfnotify/notifier/typetalk" 14 | "github.com/mercari/tfnotify/terraform" 15 | 16 | "github.com/urfave/cli" 17 | ) 18 | 19 | const ( 20 | name = "tfnotify" 21 | description = "Notify the execution result of terraform command" 22 | 23 | version = "unset" 24 | ) 25 | 26 | type tfnotify struct { 27 | config config.Config 28 | context *cli.Context 29 | parser terraform.Parser 30 | template terraform.Template 31 | destroyWarningTemplate terraform.Template 32 | warnDestroy bool 33 | } 34 | 35 | // Run sends the notification with notifier 36 | func (t *tfnotify) Run() error { 37 | ciname := t.config.CI 38 | if t.context.GlobalString("ci") != "" { 39 | ciname = t.context.GlobalString("ci") 40 | } 41 | ciname = strings.ToLower(ciname) 42 | var ci CI 43 | var err error 44 | switch ciname { 45 | case "circleci", "circle-ci": 46 | ci, err = circleci() 47 | if err != nil { 48 | return err 49 | } 50 | case "travis", "travisci", "travis-ci": 51 | ci, err = travisci() 52 | if err != nil { 53 | return err 54 | } 55 | case "codebuild": 56 | ci, err = codebuild() 57 | if err != nil { 58 | return err 59 | } 60 | case "teamcity": 61 | ci, err = teamcity() 62 | if err != nil { 63 | return err 64 | } 65 | case "drone": 66 | ci, err = drone() 67 | if err != nil { 68 | return err 69 | } 70 | case "jenkins": 71 | ci, err = jenkins() 72 | if err != nil { 73 | return err 74 | } 75 | case "gitlabci", "gitlab-ci": 76 | ci, err = gitlabci() 77 | if err != nil { 78 | return err 79 | } 80 | case "github-actions": 81 | ci, err = githubActions() 82 | if err != nil { 83 | return err 84 | } 85 | case "cloud-build", "cloudbuild": 86 | ci, err = cloudbuild() 87 | if err != nil { 88 | return err 89 | } 90 | case "": 91 | return fmt.Errorf("CI service: required (e.g. circleci)") 92 | default: 93 | return fmt.Errorf("CI service %v: not supported yet", ci) 94 | } 95 | 96 | selectedNotifier := t.config.GetNotifierType() 97 | if t.context.GlobalString("notifier") != "" { 98 | selectedNotifier = t.context.GlobalString("notifier") 99 | } 100 | 101 | var notifier notifier.Notifier 102 | switch selectedNotifier { 103 | case "github": 104 | client, err := github.NewClient(github.Config{ 105 | Token: t.config.Notifier.Github.Token, 106 | BaseURL: t.config.Notifier.Github.BaseURL, 107 | Owner: t.config.Notifier.Github.Repository.Owner, 108 | Repo: t.config.Notifier.Github.Repository.Name, 109 | PR: github.PullRequest{ 110 | Revision: ci.PR.Revision, 111 | Number: ci.PR.Number, 112 | Title: t.context.String("title"), 113 | Message: t.context.String("message"), 114 | DestroyWarningTitle: t.context.String("destroy-warning-title"), 115 | DestroyWarningMessage: t.context.String("destroy-warning-message"), 116 | }, 117 | CI: ci.URL, 118 | Parser: t.parser, 119 | UseRawOutput: t.config.Terraform.UseRawOutput, 120 | Template: t.template, 121 | DestroyWarningTemplate: t.destroyWarningTemplate, 122 | WarnDestroy: t.warnDestroy, 123 | ResultLabels: github.ResultLabels{ 124 | AddOrUpdateLabel: t.config.Terraform.Plan.WhenAddOrUpdateOnly.Label, 125 | DestroyLabel: t.config.Terraform.Plan.WhenDestroy.Label, 126 | NoChangesLabel: t.config.Terraform.Plan.WhenNoChanges.Label, 127 | PlanErrorLabel: t.config.Terraform.Plan.WhenPlanError.Label, 128 | }, 129 | }) 130 | if err != nil { 131 | return err 132 | } 133 | notifier = client.Notify 134 | case "gitlab": 135 | client, err := gitlab.NewClient(gitlab.Config{ 136 | Token: t.config.Notifier.Gitlab.Token, 137 | BaseURL: t.config.Notifier.Gitlab.BaseURL, 138 | NameSpace: t.config.Notifier.Gitlab.Repository.Owner, 139 | Project: t.config.Notifier.Gitlab.Repository.Name, 140 | MR: gitlab.MergeRequest{ 141 | Revision: ci.PR.Revision, 142 | Number: ci.PR.Number, 143 | Title: t.context.String("title"), 144 | Message: t.context.String("message"), 145 | }, 146 | CI: ci.URL, 147 | Parser: t.parser, 148 | Template: t.template, 149 | }) 150 | if err != nil { 151 | return err 152 | } 153 | notifier = client.Notify 154 | case "slack": 155 | client, err := slack.NewClient(slack.Config{ 156 | Token: t.config.Notifier.Slack.Token, 157 | Channel: t.config.Notifier.Slack.Channel, 158 | Botname: t.config.Notifier.Slack.Bot, 159 | Title: t.context.String("title"), 160 | Message: t.context.String("message"), 161 | CI: ci.URL, 162 | Parser: t.parser, 163 | UseRawOutput: t.config.Terraform.UseRawOutput, 164 | Template: t.template, 165 | }) 166 | if err != nil { 167 | return err 168 | } 169 | notifier = client.Notify 170 | case "typetalk": 171 | client, err := typetalk.NewClient(typetalk.Config{ 172 | Token: t.config.Notifier.Typetalk.Token, 173 | TopicID: t.config.Notifier.Typetalk.TopicID, 174 | Title: t.context.String("title"), 175 | Message: t.context.String("message"), 176 | CI: ci.URL, 177 | Parser: t.parser, 178 | Template: t.template, 179 | }) 180 | if err != nil { 181 | return err 182 | } 183 | notifier = client.Notify 184 | case "": 185 | return fmt.Errorf("notifier is missing") 186 | default: 187 | return fmt.Errorf("%s: not supported notifier yet", t.context.GlobalString("notifier")) 188 | } 189 | 190 | if notifier == nil { 191 | return fmt.Errorf("no notifier specified at all") 192 | } 193 | 194 | return NewExitError(notifier.Notify(tee(os.Stdin, os.Stdout))) 195 | } 196 | 197 | func main() { 198 | app := cli.NewApp() 199 | app.Name = name 200 | app.Usage = description 201 | app.Version = version 202 | app.Flags = []cli.Flag{ 203 | cli.StringFlag{Name: "ci", Usage: "name of CI to run tfnotify"}, 204 | cli.StringFlag{Name: "config", Usage: "config path"}, 205 | cli.StringFlag{Name: "notifier", Usage: "notification destination"}, 206 | } 207 | app.Commands = []cli.Command{ 208 | { 209 | Name: "fmt", 210 | Usage: "Parse stdin as a fmt result", 211 | Action: cmdFmt, 212 | Flags: []cli.Flag{ 213 | cli.StringFlag{ 214 | Name: "title, t", 215 | Usage: "Specify the title to use for notification", 216 | }, 217 | cli.StringFlag{ 218 | Name: "message, m", 219 | Usage: "Specify the message to use for notification", 220 | }, 221 | }, 222 | }, 223 | { 224 | Name: "validate", 225 | Usage: "Parse stdin as a validate result", 226 | Action: cmdValidate, 227 | Flags: []cli.Flag{ 228 | cli.StringFlag{ 229 | Name: "title, t", 230 | Usage: "Specify the title to use for notification", 231 | }, 232 | cli.StringFlag{ 233 | Name: "message, m", 234 | Usage: "Specify the message to use for notification", 235 | }, 236 | }, 237 | }, 238 | { 239 | Name: "plan", 240 | Usage: "Parse stdin as a plan result", 241 | Action: cmdPlan, 242 | Flags: []cli.Flag{ 243 | cli.StringFlag{ 244 | Name: "title, t", 245 | Usage: "Specify the title to use for notification", 246 | }, 247 | cli.StringFlag{ 248 | Name: "message, m", 249 | Usage: "Specify the message to use for notification", 250 | }, 251 | cli.StringFlag{ 252 | Name: "destroy-warning-title", 253 | Usage: "Specify the title to use for destroy warning notification", 254 | }, 255 | cli.StringFlag{ 256 | Name: "destroy-warning-message", 257 | Usage: "Specify the message to use for destroy warning notification", 258 | }, 259 | }, 260 | }, 261 | { 262 | Name: "apply", 263 | Usage: "Parse stdin as a apply result", 264 | Action: cmdApply, 265 | Flags: []cli.Flag{ 266 | cli.StringFlag{ 267 | Name: "title, t", 268 | Usage: "Specify the title to use for notification", 269 | }, 270 | cli.StringFlag{ 271 | Name: "message, m", 272 | Usage: "Specify the message to use for notification", 273 | }, 274 | }, 275 | }, 276 | } 277 | 278 | err := app.Run(os.Args) 279 | os.Exit(HandleExit(err)) 280 | } 281 | 282 | func newConfig(ctx *cli.Context) (cfg config.Config, err error) { 283 | confPath, err := cfg.Find(ctx.GlobalString("config")) 284 | if err != nil { 285 | return cfg, err 286 | } 287 | if err := cfg.LoadFile(confPath); err != nil { 288 | return cfg, err 289 | } 290 | if err := cfg.Validation(); err != nil { 291 | return cfg, err 292 | } 293 | return cfg, nil 294 | } 295 | 296 | func cmdFmt(ctx *cli.Context) error { 297 | cfg, err := newConfig(ctx) 298 | if err != nil { 299 | return err 300 | } 301 | t := &tfnotify{ 302 | config: cfg, 303 | context: ctx, 304 | parser: terraform.NewFmtParser(), 305 | template: terraform.NewFmtTemplate(cfg.Terraform.Fmt.Template), 306 | } 307 | return t.Run() 308 | } 309 | 310 | func cmdValidate(ctx *cli.Context) error { 311 | cfg, err := newConfig(ctx) 312 | if err != nil { 313 | return err 314 | } 315 | t := &tfnotify{ 316 | config: cfg, 317 | context: ctx, 318 | parser: terraform.NewValidateParser(), 319 | template: terraform.NewValidateTemplate(cfg.Terraform.Validate.Template), 320 | } 321 | return t.Run() 322 | } 323 | 324 | func cmdPlan(ctx *cli.Context) error { 325 | cfg, err := newConfig(ctx) 326 | if err != nil { 327 | return err 328 | } 329 | 330 | // If when_destroy is not defined in configuration, tfnotify should not notify it 331 | warnDestroy := cfg.Terraform.Plan.WhenDestroy.Template != "" 332 | 333 | t := &tfnotify{ 334 | config: cfg, 335 | context: ctx, 336 | parser: terraform.NewPlanParser(), 337 | template: terraform.NewPlanTemplate(cfg.Terraform.Plan.Template), 338 | destroyWarningTemplate: terraform.NewDestroyWarningTemplate(cfg.Terraform.Plan.WhenDestroy.Template), 339 | warnDestroy: warnDestroy, 340 | } 341 | return t.Run() 342 | } 343 | 344 | func cmdApply(ctx *cli.Context) error { 345 | cfg, err := newConfig(ctx) 346 | if err != nil { 347 | return err 348 | } 349 | t := &tfnotify{ 350 | config: cfg, 351 | context: ctx, 352 | parser: terraform.NewApplyParser(), 353 | template: terraform.NewApplyTemplate(cfg.Terraform.Apply.Template), 354 | } 355 | return t.Run() 356 | } 357 | -------------------------------------------------------------------------------- /misc/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercari/tfnotify/ddb68efbd4f73aedb473b9e1acf9b648a4a0473f/misc/images/1.png -------------------------------------------------------------------------------- /misc/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercari/tfnotify/ddb68efbd4f73aedb473b9e1acf9b648a4a0473f/misc/images/2.png -------------------------------------------------------------------------------- /misc/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercari/tfnotify/ddb68efbd4f73aedb473b9e1acf9b648a4a0473f/misc/images/3.png -------------------------------------------------------------------------------- /notifier/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/mercari/tfnotify/terraform" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | // EnvBaseURL is GitHub base URL. This can be set to a domain endpoint to use with GitHub Enterprise. 14 | const EnvBaseURL = "GITHUB_BASE_URL" 15 | 16 | // Client is a API client for GitHub 17 | type Client struct { 18 | *github.Client 19 | Debug bool 20 | 21 | Config Config 22 | 23 | common service 24 | 25 | Comment *CommentService 26 | Commits *CommitsService 27 | Notify *NotifyService 28 | 29 | API API 30 | } 31 | 32 | // Config is a configuration for GitHub client 33 | type Config struct { 34 | Token string 35 | BaseURL string 36 | Owner string 37 | Repo string 38 | PR PullRequest 39 | CI string 40 | Parser terraform.Parser 41 | UseRawOutput bool 42 | WarnDestroy bool 43 | // Template is used for all Terraform command output 44 | Template terraform.Template 45 | // DestroyWarningTemplate is used only for additional warning 46 | // the plan result contains destroy operation 47 | DestroyWarningTemplate terraform.Template 48 | // ResultLabels is a set of labels to apply depending on the plan result 49 | ResultLabels ResultLabels 50 | } 51 | 52 | // PullRequest represents GitHub Pull Request metadata 53 | type PullRequest struct { 54 | Revision string 55 | Title string 56 | Message string 57 | Number int 58 | DestroyWarningTitle string 59 | DestroyWarningMessage string 60 | } 61 | 62 | type service struct { 63 | client *Client 64 | } 65 | 66 | // NewClient returns Client initialized with Config 67 | func NewClient(cfg Config) (*Client, error) { 68 | token := cfg.Token 69 | 70 | if strings.HasPrefix(token, "$") { 71 | token = os.Getenv(strings.TrimPrefix(token, "$")) 72 | } 73 | 74 | if token == "" { 75 | return &Client{}, errors.New("github token is missing") 76 | } 77 | ts := oauth2.StaticTokenSource( 78 | &oauth2.Token{AccessToken: token}, 79 | ) 80 | tc := oauth2.NewClient(oauth2.NoContext, ts) 81 | client := github.NewClient(tc) 82 | 83 | baseURL := cfg.BaseURL 84 | baseURL = strings.TrimPrefix(baseURL, "$") 85 | if baseURL == EnvBaseURL { 86 | baseURL = os.Getenv(EnvBaseURL) 87 | } 88 | if baseURL != "" { 89 | var err error 90 | client, err = github.NewEnterpriseClient(baseURL, baseURL, tc) 91 | if err != nil { 92 | return &Client{}, errors.New("failed to create a new github api client") 93 | } 94 | } 95 | 96 | c := &Client{ 97 | Config: cfg, 98 | Client: client, 99 | } 100 | c.common.client = c 101 | c.Comment = (*CommentService)(&c.common) 102 | c.Commits = (*CommitsService)(&c.common) 103 | c.Notify = (*NotifyService)(&c.common) 104 | 105 | c.API = &GitHub{ 106 | Client: client, 107 | owner: cfg.Owner, 108 | repo: cfg.Repo, 109 | } 110 | 111 | return c, nil 112 | } 113 | 114 | // IsNumber returns true if PullRequest is Pull Request build 115 | func (pr *PullRequest) IsNumber() bool { 116 | return pr.Number != 0 117 | } 118 | 119 | // ResultLabels represents the labels to add to the PR depending on the plan result 120 | type ResultLabels struct { 121 | AddOrUpdateLabel string 122 | DestroyLabel string 123 | NoChangesLabel string 124 | PlanErrorLabel string 125 | } 126 | 127 | // HasAnyLabelDefined returns true if any of the internal labels are set 128 | func (r *ResultLabels) HasAnyLabelDefined() bool { 129 | return r.AddOrUpdateLabel != "" || r.DestroyLabel != "" || r.NoChangesLabel != "" || r.PlanErrorLabel != "" 130 | } 131 | 132 | // IsResultLabel returns true if a label matches any of the internal labels 133 | func (r *ResultLabels) IsResultLabel(label string) bool { 134 | switch label { 135 | case "": 136 | return false 137 | case r.AddOrUpdateLabel, r.DestroyLabel, r.NoChangesLabel, r.PlanErrorLabel: 138 | return true 139 | default: 140 | return false 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /notifier/github/client_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestNewClient(t *testing.T) { 10 | testCases := []struct { 11 | config Config 12 | envToken string 13 | expect string 14 | }{ 15 | { 16 | // specify directly 17 | config: Config{Token: "abcdefg"}, 18 | envToken: "", 19 | expect: "", 20 | }, 21 | { 22 | // specify via env but not to be set env (part 1) 23 | config: Config{Token: "GITHUB_TOKEN"}, 24 | envToken: "", 25 | expect: "github token is missing", 26 | }, 27 | { 28 | // specify via env (part 1) 29 | config: Config{Token: "GITHUB_TOKEN"}, 30 | envToken: "abcdefg", 31 | expect: "", 32 | }, 33 | { 34 | // specify via env but not to be set env (part 2) 35 | config: Config{Token: "$GITHUB_TOKEN"}, 36 | envToken: "", 37 | expect: "github token is missing", 38 | }, 39 | { 40 | // specify via env but not to be set env (part 3) 41 | config: Config{Token: "$TFNOTIFY_GITHUB_TOKEN"}, 42 | envToken: "", 43 | expect: "github token is missing", 44 | }, 45 | { 46 | // specify via env (part 2) 47 | config: Config{Token: "$GITHUB_TOKEN"}, 48 | envToken: "abcdefg", 49 | expect: "", 50 | }, 51 | { 52 | // specify via env (part 3) 53 | config: Config{Token: "$TFNOTIFY_GITHUB_TOKEN"}, 54 | envToken: "abcdefg", 55 | expect: "", 56 | }, 57 | { 58 | // no specification (part 1) 59 | config: Config{}, 60 | envToken: "", 61 | expect: "github token is missing", 62 | }, 63 | { 64 | // no specification (part 2) 65 | config: Config{}, 66 | envToken: "abcdefg", 67 | expect: "github token is missing", 68 | }, 69 | } 70 | for _, testCase := range testCases { 71 | if strings.HasPrefix(testCase.config.Token, "$") { 72 | key := strings.TrimPrefix(testCase.config.Token, "$") 73 | os.Setenv(key, testCase.envToken) 74 | } 75 | 76 | _, err := NewClient(testCase.config) 77 | if err == nil { 78 | continue 79 | } 80 | if err.Error() != testCase.expect { 81 | t.Errorf("got %q but want %q", err.Error(), testCase.expect) 82 | } 83 | } 84 | } 85 | 86 | func TestNewClientWithBaseURL(t *testing.T) { 87 | githubBaseURL := os.Getenv(EnvBaseURL) 88 | defer func() { 89 | os.Setenv(EnvBaseURL, githubBaseURL) 90 | }() 91 | os.Setenv(EnvBaseURL, "") 92 | 93 | testCases := []struct { 94 | config Config 95 | envBaseURL string 96 | expect string 97 | }{ 98 | { 99 | // specify directly 100 | config: Config{ 101 | Token: "abcdefg", 102 | BaseURL: "https://git.example.com/api/v3/", 103 | }, 104 | envBaseURL: "", 105 | expect: "https://git.example.com/api/v3/", 106 | }, 107 | { 108 | // specify via env but not to be set env (part 1) 109 | config: Config{ 110 | Token: "abcdefg", 111 | BaseURL: "GITHUB_BASE_URL", 112 | }, 113 | envBaseURL: "", 114 | expect: "https://api.github.com/", 115 | }, 116 | { 117 | // specify via env (part 1) 118 | config: Config{ 119 | Token: "abcdefg", 120 | BaseURL: "GITHUB_BASE_URL", 121 | }, 122 | envBaseURL: "https://git.example.com/api/v3/", 123 | expect: "https://git.example.com/api/v3/", 124 | }, 125 | { 126 | // specify via env but not to be set env (part 2) 127 | config: Config{ 128 | Token: "abcdefg", 129 | BaseURL: "$GITHUB_BASE_URL", 130 | }, 131 | envBaseURL: "", 132 | expect: "https://api.github.com/", 133 | }, 134 | { 135 | // specify via env (part 2) 136 | config: Config{ 137 | Token: "abcdefg", 138 | BaseURL: "$GITHUB_BASE_URL", 139 | }, 140 | envBaseURL: "https://git.example.com/api/v3/", 141 | expect: "https://git.example.com/api/v3/", 142 | }, 143 | { 144 | // no specification (part 1) 145 | config: Config{Token: "abcdefg"}, 146 | envBaseURL: "", 147 | expect: "https://api.github.com/", 148 | }, 149 | { 150 | // no specification (part 2) 151 | config: Config{Token: "abcdefg"}, 152 | envBaseURL: "https://git.example.com/api/v3/", 153 | expect: "https://api.github.com/", 154 | }, 155 | } 156 | for _, testCase := range testCases { 157 | os.Setenv(EnvBaseURL, testCase.envBaseURL) 158 | c, err := NewClient(testCase.config) 159 | if err != nil { 160 | continue 161 | } 162 | url := c.Client.BaseURL.String() 163 | if url != testCase.expect { 164 | t.Errorf("got %q but want %q", url, testCase.expect) 165 | } 166 | } 167 | } 168 | 169 | func TestIsNumber(t *testing.T) { 170 | testCases := []struct { 171 | pr PullRequest 172 | isPR bool 173 | }{ 174 | { 175 | pr: PullRequest{ 176 | Number: 0, 177 | }, 178 | isPR: false, 179 | }, 180 | { 181 | pr: PullRequest{ 182 | Number: 123, 183 | }, 184 | isPR: true, 185 | }, 186 | } 187 | for _, testCase := range testCases { 188 | if testCase.pr.IsNumber() != testCase.isPR { 189 | t.Errorf("got %v but want %v", testCase.pr.IsNumber(), testCase.isPR) 190 | } 191 | } 192 | } 193 | 194 | func TestHasAnyLabelDefined(t *testing.T) { 195 | testCases := []struct { 196 | rl ResultLabels 197 | want bool 198 | }{ 199 | { 200 | rl: ResultLabels{ 201 | AddOrUpdateLabel: "add-or-update", 202 | DestroyLabel: "destroy", 203 | NoChangesLabel: "no-changes", 204 | PlanErrorLabel: "error", 205 | }, 206 | want: true, 207 | }, 208 | { 209 | rl: ResultLabels{ 210 | AddOrUpdateLabel: "add-or-update", 211 | DestroyLabel: "destroy", 212 | NoChangesLabel: "", 213 | PlanErrorLabel: "error", 214 | }, 215 | want: true, 216 | }, 217 | { 218 | rl: ResultLabels{ 219 | AddOrUpdateLabel: "", 220 | DestroyLabel: "", 221 | NoChangesLabel: "", 222 | PlanErrorLabel: "", 223 | }, 224 | want: false, 225 | }, 226 | { 227 | rl: ResultLabels{}, 228 | want: false, 229 | }, 230 | } 231 | for _, testCase := range testCases { 232 | if testCase.rl.HasAnyLabelDefined() != testCase.want { 233 | t.Errorf("got %v but want %v", testCase.rl.HasAnyLabelDefined(), testCase.want) 234 | } 235 | } 236 | } 237 | 238 | func TestIsResultLabels(t *testing.T) { 239 | testCases := []struct { 240 | rl ResultLabels 241 | label string 242 | want bool 243 | }{ 244 | { 245 | rl: ResultLabels{ 246 | AddOrUpdateLabel: "add-or-update", 247 | DestroyLabel: "destroy", 248 | NoChangesLabel: "no-changes", 249 | PlanErrorLabel: "error", 250 | }, 251 | label: "add-or-update", 252 | want: true, 253 | }, 254 | { 255 | rl: ResultLabels{ 256 | AddOrUpdateLabel: "add-or-update", 257 | DestroyLabel: "destroy", 258 | NoChangesLabel: "no-changes", 259 | PlanErrorLabel: "error", 260 | }, 261 | label: "my-label", 262 | want: false, 263 | }, 264 | { 265 | rl: ResultLabels{ 266 | AddOrUpdateLabel: "add-or-update", 267 | DestroyLabel: "destroy", 268 | NoChangesLabel: "no-changes", 269 | PlanErrorLabel: "error", 270 | }, 271 | label: "", 272 | want: false, 273 | }, 274 | { 275 | rl: ResultLabels{ 276 | AddOrUpdateLabel: "", 277 | DestroyLabel: "", 278 | NoChangesLabel: "no-changes", 279 | PlanErrorLabel: "", 280 | }, 281 | label: "", 282 | want: false, 283 | }, 284 | { 285 | rl: ResultLabels{}, 286 | label: "", 287 | want: false, 288 | }, 289 | } 290 | for _, testCase := range testCases { 291 | if testCase.rl.IsResultLabel(testCase.label) != testCase.want { 292 | t.Errorf("got %v but want %v", testCase.rl.IsResultLabel(testCase.label), testCase.want) 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /notifier/github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | 8 | "github.com/google/go-github/github" 9 | ) 10 | 11 | // CommentService handles communication with the comment related 12 | // methods of GitHub API 13 | type CommentService service 14 | 15 | // PostOptions specifies the optional parameters to post comments to a pull request 16 | type PostOptions struct { 17 | Number int 18 | Revision string 19 | } 20 | 21 | // Post posts comment 22 | func (g *CommentService) Post(body string, opt PostOptions) error { 23 | if opt.Number != 0 { 24 | _, _, err := g.client.API.IssuesCreateComment( 25 | context.Background(), 26 | opt.Number, 27 | &github.IssueComment{Body: &body}, 28 | ) 29 | return err 30 | } 31 | if opt.Revision != "" { 32 | _, _, err := g.client.API.RepositoriesCreateComment( 33 | context.Background(), 34 | opt.Revision, 35 | &github.RepositoryComment{Body: &body}, 36 | ) 37 | return err 38 | } 39 | return fmt.Errorf("github.comment.post: Number or Revision is required") 40 | } 41 | 42 | // List lists comments on GitHub issues/pull requests 43 | func (g *CommentService) List(number int) ([]*github.IssueComment, error) { 44 | comments, _, err := g.client.API.IssuesListComments( 45 | context.Background(), 46 | number, 47 | &github.IssueListCommentsOptions{}, 48 | ) 49 | return comments, err 50 | } 51 | 52 | // Delete deletes comment on GitHub issues/pull requests 53 | func (g *CommentService) Delete(id int) error { 54 | _, err := g.client.API.IssuesDeleteComment( 55 | context.Background(), 56 | int64(id), 57 | ) 58 | return err 59 | } 60 | 61 | // DeleteDuplicates deletes duplicate comments containing arbitrary character strings 62 | func (g *CommentService) DeleteDuplicates(title string) { 63 | var ids []int64 64 | comments := g.getDuplicates(title) 65 | for _, comment := range comments { 66 | ids = append(ids, *comment.ID) 67 | } 68 | for _, id := range ids { 69 | // don't handle error 70 | g.client.Comment.Delete(int(id)) 71 | } 72 | } 73 | 74 | func (g *CommentService) getDuplicates(title string) []*github.IssueComment { 75 | var dup []*github.IssueComment 76 | re := regexp.MustCompile(`(?m)^(\n+)?` + title + `( +.*)?\n+` + g.client.Config.PR.Message + `\n+`) 77 | 78 | comments, _ := g.client.Comment.List(g.client.Config.PR.Number) 79 | for _, comment := range comments { 80 | if re.MatchString(*comment.Body) { 81 | dup = append(dup, comment) 82 | } 83 | } 84 | 85 | return dup 86 | } 87 | -------------------------------------------------------------------------------- /notifier/github/comment_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/google/go-github/github" 9 | ) 10 | 11 | func TestCommentPost(t *testing.T) { 12 | testCases := []struct { 13 | config Config 14 | body string 15 | opt PostOptions 16 | ok bool 17 | }{ 18 | { 19 | config: newFakeConfig(), 20 | body: "", 21 | opt: PostOptions{ 22 | Number: 1, 23 | Revision: "abcd", 24 | }, 25 | ok: true, 26 | }, 27 | { 28 | config: newFakeConfig(), 29 | body: "", 30 | opt: PostOptions{ 31 | Number: 0, 32 | Revision: "abcd", 33 | }, 34 | ok: true, 35 | }, 36 | { 37 | config: newFakeConfig(), 38 | body: "", 39 | opt: PostOptions{ 40 | Number: 2, 41 | Revision: "", 42 | }, 43 | ok: true, 44 | }, 45 | { 46 | config: newFakeConfig(), 47 | body: "", 48 | opt: PostOptions{ 49 | Number: 0, 50 | Revision: "", 51 | }, 52 | ok: false, 53 | }, 54 | } 55 | 56 | for _, testCase := range testCases { 57 | client, err := NewClient(testCase.config) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | api := newFakeAPI() 62 | client.API = &api 63 | err = client.Comment.Post(testCase.body, testCase.opt) 64 | if (err == nil) != testCase.ok { 65 | t.Errorf("got error %q", err) 66 | } 67 | } 68 | } 69 | 70 | func TestCommentList(t *testing.T) { 71 | comments := []*github.IssueComment{ 72 | &github.IssueComment{ 73 | ID: github.Int64(371748792), 74 | Body: github.String("comment 1"), 75 | }, 76 | &github.IssueComment{ 77 | ID: github.Int64(371765743), 78 | Body: github.String("comment 2"), 79 | }, 80 | } 81 | testCases := []struct { 82 | config Config 83 | number int 84 | ok bool 85 | comments []*github.IssueComment 86 | }{ 87 | { 88 | config: newFakeConfig(), 89 | number: 1, 90 | ok: true, 91 | comments: comments, 92 | }, 93 | { 94 | config: newFakeConfig(), 95 | number: 12, 96 | ok: true, 97 | comments: comments, 98 | }, 99 | { 100 | config: newFakeConfig(), 101 | number: 123, 102 | ok: true, 103 | comments: comments, 104 | }, 105 | } 106 | 107 | for _, testCase := range testCases { 108 | client, err := NewClient(testCase.config) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | api := newFakeAPI() 113 | client.API = &api 114 | comments, err := client.Comment.List(testCase.number) 115 | if (err == nil) != testCase.ok { 116 | t.Errorf("got error %q", err) 117 | } 118 | if !reflect.DeepEqual(comments, testCase.comments) { 119 | t.Errorf("got %v but want %v", comments, testCase.comments) 120 | } 121 | } 122 | } 123 | 124 | func TestCommentDelete(t *testing.T) { 125 | testCases := []struct { 126 | config Config 127 | id int 128 | ok bool 129 | }{ 130 | { 131 | config: newFakeConfig(), 132 | id: 1, 133 | ok: true, 134 | }, 135 | { 136 | config: newFakeConfig(), 137 | id: 12, 138 | ok: true, 139 | }, 140 | { 141 | config: newFakeConfig(), 142 | id: 123, 143 | ok: true, 144 | }, 145 | } 146 | 147 | for _, testCase := range testCases { 148 | client, err := NewClient(testCase.config) 149 | if err != nil { 150 | t.Fatal(err) 151 | } 152 | api := newFakeAPI() 153 | client.API = &api 154 | err = client.Comment.Delete(testCase.id) 155 | if (err == nil) != testCase.ok { 156 | t.Errorf("got error %q", err) 157 | } 158 | } 159 | } 160 | 161 | func TestCommentGetDuplicates(t *testing.T) { 162 | api := newFakeAPI() 163 | api.FakeIssuesListComments = func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { 164 | var comments []*github.IssueComment 165 | comments = []*github.IssueComment{ 166 | &github.IssueComment{ 167 | ID: github.Int64(371748792), 168 | Body: github.String("## Plan result\nfoo message\n"), 169 | }, 170 | &github.IssueComment{ 171 | ID: github.Int64(371765743), 172 | Body: github.String("## Plan result\nbar message\n"), 173 | }, 174 | &github.IssueComment{ 175 | ID: github.Int64(371765744), 176 | Body: github.String("## Plan result\nbaz message\n"), 177 | }, 178 | &github.IssueComment{ 179 | ID: github.Int64(371765745), 180 | Body: github.String("## Plan result \nbaz message\n"), 181 | }, 182 | } 183 | return comments, nil, nil 184 | } 185 | 186 | testCases := []struct { 187 | title string 188 | message string 189 | comments []*github.IssueComment 190 | }{ 191 | { 192 | title: "## Plan result", 193 | message: "foo message", 194 | comments: []*github.IssueComment{ 195 | &github.IssueComment{ 196 | ID: github.Int64(371748792), 197 | Body: github.String("## Plan result\nfoo message\n"), 198 | }, 199 | }, 200 | }, 201 | { 202 | title: "## Plan result", 203 | message: "hoge message", 204 | comments: nil, 205 | }, 206 | { 207 | title: "## Plan result", 208 | message: "baz message", 209 | comments: []*github.IssueComment{ 210 | &github.IssueComment{ 211 | ID: github.Int64(371765744), 212 | Body: github.String("## Plan result\nbaz message\n"), 213 | }, 214 | &github.IssueComment{ 215 | ID: github.Int64(371765745), 216 | Body: github.String("## Plan result \nbaz message\n"), 217 | }, 218 | }, 219 | }, 220 | } 221 | 222 | for _, testCase := range testCases { 223 | cfg := newFakeConfig() 224 | cfg.PR.Message = testCase.message 225 | client, err := NewClient(cfg) 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | client.API = &api 230 | comments := client.Comment.getDuplicates(testCase.title) 231 | if !reflect.DeepEqual(comments, testCase.comments) { 232 | t.Errorf("got %q but want %q", comments, testCase.comments) 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /notifier/github/commits.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/google/go-github/github" 10 | ) 11 | 12 | // CommitsService handles communication with the commits related 13 | // methods of GitHub API 14 | type CommitsService service 15 | 16 | // List lists commits on a repository 17 | func (g *CommitsService) List(revision string) ([]string, error) { 18 | if revision == "" { 19 | return []string{}, errors.New("no revision specified") 20 | } 21 | var s []string 22 | commits, _, err := g.client.API.RepositoriesListCommits( 23 | context.Background(), 24 | &github.CommitsListOptions{SHA: revision}, 25 | ) 26 | if err != nil { 27 | return s, err 28 | } 29 | for _, commit := range commits { 30 | s = append(s, *commit.SHA) 31 | } 32 | return s, nil 33 | } 34 | 35 | // Last returns the hash of the previous commit of the given commit 36 | func (g *CommitsService) lastOne(commits []string, revision string) (string, error) { 37 | if revision == "" { 38 | return "", errors.New("no revision specified") 39 | } 40 | if len(commits) == 0 { 41 | return "", errors.New("no commits") 42 | } 43 | // e.g. 44 | // a0ce5bf 2018/04/05 20:50:01 (HEAD -> master, origin/master) 45 | // 5166cfc 2018/04/05 20:40:12 46 | // 74c4d6e 2018/04/05 20:34:31 47 | // 9260c54 2018/04/05 20:16:20 48 | return commits[1], nil 49 | } 50 | 51 | func (g *CommitsService) MergedPRNumber(revision string) (int, error) { 52 | commit, _, err := g.client.API.RepositoriesGetCommit(context.Background(), revision) 53 | if err != nil { 54 | return 0, err 55 | } 56 | 57 | message := commit.Commit.GetMessage() 58 | if !strings.HasPrefix(message, "Merge pull request #") { 59 | return 0, errors.New("not a merge commit") 60 | } 61 | 62 | message = strings.TrimPrefix(message, "Merge pull request #") 63 | i := strings.Index(message, " from") 64 | if i >= 0 { 65 | return strconv.Atoi(message[0:i]) 66 | } 67 | 68 | return 0, errors.New("not a merge commit") 69 | } 70 | -------------------------------------------------------------------------------- /notifier/github/commits_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommitsList(t *testing.T) { 8 | testCases := []struct { 9 | revision string 10 | ok bool 11 | }{ 12 | { 13 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 14 | ok: true, 15 | }, 16 | { 17 | revision: "", 18 | ok: false, 19 | }, 20 | } 21 | 22 | for _, testCase := range testCases { 23 | cfg := newFakeConfig() 24 | client, err := NewClient(cfg) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | api := newFakeAPI() 29 | client.API = &api 30 | _, err = client.Commits.List(testCase.revision) 31 | if (err == nil) != testCase.ok { 32 | t.Errorf("got error %q", err) 33 | } 34 | } 35 | } 36 | 37 | func TestCommitsLastOne(t *testing.T) { 38 | testCases := []struct { 39 | commits []string 40 | revision string 41 | lastRev string 42 | ok bool 43 | }{ 44 | { 45 | // ok 46 | commits: []string{ 47 | "04e0917e448b662c2b16330fad50e97af16ff27a", 48 | "04e0917e448b662c2b16330fad50e97af16ff27b", 49 | "04e0917e448b662c2b16330fad50e97af16ff27c", 50 | }, 51 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 52 | lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b", 53 | ok: true, 54 | }, 55 | { 56 | // no revision 57 | commits: []string{ 58 | "04e0917e448b662c2b16330fad50e97af16ff27a", 59 | "04e0917e448b662c2b16330fad50e97af16ff27b", 60 | "04e0917e448b662c2b16330fad50e97af16ff27c", 61 | }, 62 | revision: "", 63 | lastRev: "", 64 | ok: false, 65 | }, 66 | { 67 | // no commits 68 | commits: []string{}, 69 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 70 | lastRev: "", 71 | ok: false, 72 | }, 73 | } 74 | 75 | for _, testCase := range testCases { 76 | cfg := newFakeConfig() 77 | client, err := NewClient(cfg) 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | api := newFakeAPI() 82 | client.API = &api 83 | commit, err := client.Commits.lastOne(testCase.commits, testCase.revision) 84 | if (err == nil) != testCase.ok { 85 | t.Errorf("got error %q", err) 86 | } 87 | if commit != testCase.lastRev { 88 | t.Errorf("got %q but want %q", commit, testCase.lastRev) 89 | } 90 | } 91 | } 92 | 93 | func TestMergedPRNumber(t *testing.T) { 94 | testCases := []struct { 95 | prNumber int 96 | ok bool 97 | revision string 98 | }{ 99 | { 100 | prNumber: 1, 101 | ok: true, 102 | revision: "Merge pull request #1 from mercari/tfnotify", 103 | }, 104 | { 105 | prNumber: 123, 106 | ok: true, 107 | revision: "Merge pull request #123 from mercari/tfnotify", 108 | }, 109 | { 110 | prNumber: 0, 111 | ok: false, 112 | revision: "destroyed the world", 113 | }, 114 | { 115 | prNumber: 0, 116 | ok: false, 117 | revision: "Merge pull request #string from mercari/tfnotify", 118 | }, 119 | } 120 | 121 | for _, testCase := range testCases { 122 | cfg := newFakeConfig() 123 | client, err := NewClient(cfg) 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | api := newFakeAPI() 128 | client.API = &api 129 | prNumber, err := client.Commits.MergedPRNumber(testCase.revision) 130 | if (err == nil) != testCase.ok { 131 | t.Errorf("got error %q", err) 132 | } 133 | if prNumber != testCase.prNumber { 134 | t.Errorf("got %q but want %q", prNumber, testCase.prNumber) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /notifier/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/github" 7 | ) 8 | 9 | // API is GitHub API interface 10 | type API interface { 11 | IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) 12 | IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) 13 | IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) 14 | IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) 15 | IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) 16 | IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) 17 | RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) 18 | RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) 19 | RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) 20 | } 21 | 22 | // GitHub represents the attribute information necessary for requesting GitHub API 23 | type GitHub struct { 24 | *github.Client 25 | owner, repo string 26 | } 27 | 28 | // IssuesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.CreateComment 29 | func (g *GitHub) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { 30 | return g.Client.Issues.CreateComment(ctx, g.owner, g.repo, number, comment) 31 | } 32 | 33 | // IssuesDeleteComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.DeleteComment 34 | func (g *GitHub) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) { 35 | return g.Client.Issues.DeleteComment(ctx, g.owner, g.repo, int64(commentID)) 36 | } 37 | 38 | // IssuesListComments is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListComments 39 | func (g *GitHub) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { 40 | return g.Client.Issues.ListComments(ctx, g.owner, g.repo, number, opt) 41 | } 42 | 43 | // IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.AddLabelsToIssue 44 | func (g *GitHub) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 45 | return g.Client.Issues.AddLabelsToIssue(ctx, g.owner, g.repo, number, labels) 46 | } 47 | 48 | // IssuesListLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListLabelsByIssue 49 | func (g *GitHub) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) { 50 | return g.Client.Issues.ListLabelsByIssue(ctx, g.owner, g.repo, number, opt) 51 | } 52 | 53 | // IssuesRemoveLabel is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue 54 | func (g *GitHub) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) { 55 | return g.Client.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, label) 56 | } 57 | 58 | // RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment 59 | func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 60 | return g.Client.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment) 61 | } 62 | 63 | // RepositoriesListCommits is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.ListCommits 64 | func (g *GitHub) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { 65 | return g.Client.Repositories.ListCommits(ctx, g.owner, g.repo, opt) 66 | } 67 | 68 | // RepositoriesGetCommit is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.GetCommit 69 | func (g *GitHub) RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) { 70 | return g.Client.Repositories.GetCommit(ctx, g.owner, g.repo, sha) 71 | } 72 | -------------------------------------------------------------------------------- /notifier/github/github_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/github" 7 | "github.com/mercari/tfnotify/terraform" 8 | ) 9 | 10 | type fakeAPI struct { 11 | API 12 | FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) 13 | FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error) 14 | FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) 15 | FakeIssuesListLabels func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) 16 | FakeIssuesAddLabels func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) 17 | FakeIssuesRemoveLabel func(ctx context.Context, number int, label string) (*github.Response, error) 18 | FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) 19 | FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) 20 | FakeRepositoriesGetCommit func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) 21 | } 22 | 23 | func (g *fakeAPI) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { 24 | return g.FakeIssuesCreateComment(ctx, number, comment) 25 | } 26 | 27 | func (g *fakeAPI) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) { 28 | return g.FakeIssuesDeleteComment(ctx, commentID) 29 | } 30 | 31 | func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { 32 | return g.FakeIssuesListComments(ctx, number, opt) 33 | } 34 | 35 | func (g *fakeAPI) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) { 36 | return g.FakeIssuesListLabels(ctx, number, opt) 37 | } 38 | 39 | func (g *fakeAPI) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 40 | return g.FakeIssuesAddLabels(ctx, number, labels) 41 | } 42 | 43 | func (g *fakeAPI) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) { 44 | return g.FakeIssuesRemoveLabel(ctx, number, label) 45 | } 46 | 47 | func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 48 | return g.FakeRepositoriesCreateComment(ctx, sha, comment) 49 | } 50 | 51 | func (g *fakeAPI) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { 52 | return g.FakeRepositoriesListCommits(ctx, opt) 53 | } 54 | 55 | func (g *fakeAPI) RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) { 56 | return g.FakeRepositoriesGetCommit(ctx, sha) 57 | } 58 | 59 | func newFakeAPI() fakeAPI { 60 | return fakeAPI{ 61 | FakeIssuesCreateComment: func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { 62 | return &github.IssueComment{ 63 | ID: github.Int64(371748792), 64 | Body: github.String("comment 1"), 65 | }, nil, nil 66 | }, 67 | FakeIssuesDeleteComment: func(ctx context.Context, commentID int64) (*github.Response, error) { 68 | return nil, nil 69 | }, 70 | FakeIssuesListComments: func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) { 71 | var comments []*github.IssueComment 72 | comments = []*github.IssueComment{ 73 | &github.IssueComment{ 74 | ID: github.Int64(371748792), 75 | Body: github.String("comment 1"), 76 | }, 77 | &github.IssueComment{ 78 | ID: github.Int64(371765743), 79 | Body: github.String("comment 2"), 80 | }, 81 | } 82 | return comments, nil, nil 83 | }, 84 | FakeIssuesListLabels: func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { 85 | labels := []*github.Label{ 86 | &github.Label{ 87 | ID: github.Int64(371748792), 88 | Name: github.String("label 1"), 89 | }, 90 | &github.Label{ 91 | ID: github.Int64(371765743), 92 | Name: github.String("label 2"), 93 | }, 94 | } 95 | return labels, nil, nil 96 | }, 97 | FakeIssuesAddLabels: func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 98 | return nil, nil, nil 99 | }, 100 | FakeIssuesRemoveLabel: func(ctx context.Context, number int, label string) (*github.Response, error) { 101 | return nil, nil 102 | }, 103 | FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 104 | return &github.RepositoryComment{ 105 | ID: github.Int64(28427394), 106 | CommitID: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"), 107 | Body: github.String("comment 1"), 108 | }, nil, nil 109 | }, 110 | FakeRepositoriesListCommits: func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { 111 | var commits []*github.RepositoryCommit 112 | commits = []*github.RepositoryCommit{ 113 | &github.RepositoryCommit{ 114 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"), 115 | }, 116 | &github.RepositoryCommit{ 117 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27b"), 118 | }, 119 | &github.RepositoryCommit{ 120 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27c"), 121 | }, 122 | } 123 | return commits, nil, nil 124 | }, 125 | FakeRepositoriesGetCommit: func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) { 126 | return &github.RepositoryCommit{ 127 | SHA: github.String(sha), 128 | Commit: &github.Commit{ 129 | Message: github.String(sha), 130 | }, 131 | }, nil, nil 132 | }, 133 | } 134 | } 135 | 136 | func newFakeConfig() Config { 137 | return Config{ 138 | Token: "token", 139 | Owner: "owner", 140 | Repo: "repo", 141 | PR: PullRequest{ 142 | Revision: "abcd", 143 | Number: 1, 144 | Message: "message", 145 | }, 146 | Parser: terraform.NewPlanParser(), 147 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /notifier/github/notify.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "unicode/utf8" 7 | 8 | "github.com/mercari/tfnotify/terraform" 9 | ) 10 | 11 | // NotifyService handles communication with the notification related 12 | // methods of GitHub API 13 | type NotifyService service 14 | 15 | // Notify posts comment optimized for notifications 16 | func (g *NotifyService) Notify(body string) (exit int, err error) { 17 | cfg := g.client.Config 18 | parser := g.client.Config.Parser 19 | template := g.client.Config.Template 20 | 21 | result := parser.Parse(body) 22 | if result.Error != nil { 23 | return result.ExitCode, result.Error 24 | } 25 | if result.Result == "" { 26 | return result.ExitCode, result.Error 27 | } 28 | 29 | _, isPlan := parser.(*terraform.PlanParser) 30 | if isPlan { 31 | if result.HasDestroy && cfg.WarnDestroy { 32 | // Notify destroy warning as a new comment before normal plan result 33 | if err = g.notifyDestroyWarning(body, result); err != nil { 34 | return result.ExitCode, err 35 | } 36 | } 37 | if cfg.PR.IsNumber() && cfg.ResultLabels.HasAnyLabelDefined() { 38 | err = g.removeResultLabels() 39 | if err != nil { 40 | return result.ExitCode, err 41 | } 42 | var labelToAdd string 43 | 44 | if result.HasAddOrUpdateOnly { 45 | labelToAdd = cfg.ResultLabels.AddOrUpdateLabel 46 | } else if result.HasDestroy { 47 | labelToAdd = cfg.ResultLabels.DestroyLabel 48 | } else if result.HasNoChanges { 49 | labelToAdd = cfg.ResultLabels.NoChangesLabel 50 | } else if result.HasPlanError { 51 | labelToAdd = cfg.ResultLabels.PlanErrorLabel 52 | } 53 | 54 | if labelToAdd != "" { 55 | _, _, err = g.client.API.IssuesAddLabels( 56 | context.Background(), 57 | cfg.PR.Number, 58 | []string{labelToAdd}, 59 | ) 60 | if err != nil { 61 | return result.ExitCode, err 62 | } 63 | } 64 | } 65 | } 66 | 67 | template.SetValue(terraform.CommonTemplate{ 68 | Title: cfg.PR.Title, 69 | Message: cfg.PR.Message, 70 | Result: result.Result, 71 | Body: body, 72 | Link: cfg.CI, 73 | UseRawOutput: cfg.UseRawOutput, 74 | }) 75 | body, err = templateExecute(template) 76 | if err != nil { 77 | return result.ExitCode, err 78 | } 79 | 80 | value := template.GetValue() 81 | 82 | if cfg.PR.IsNumber() { 83 | g.client.Comment.DeleteDuplicates(value.Title) 84 | } 85 | 86 | _, isApply := parser.(*terraform.ApplyParser) 87 | if isApply { 88 | prNumber, err := g.client.Commits.MergedPRNumber(cfg.PR.Revision) 89 | if err == nil { 90 | cfg.PR.Number = prNumber 91 | } else if !cfg.PR.IsNumber() { 92 | commits, err := g.client.Commits.List(cfg.PR.Revision) 93 | if err != nil { 94 | return result.ExitCode, err 95 | } 96 | lastRevision, _ := g.client.Commits.lastOne(commits, cfg.PR.Revision) 97 | cfg.PR.Revision = lastRevision 98 | } 99 | } 100 | 101 | return result.ExitCode, g.client.Comment.Post(body, PostOptions{ 102 | Number: cfg.PR.Number, 103 | Revision: cfg.PR.Revision, 104 | }) 105 | } 106 | 107 | func (g *NotifyService) notifyDestroyWarning(body string, result terraform.ParseResult) error { 108 | cfg := g.client.Config 109 | destroyWarningTemplate := g.client.Config.DestroyWarningTemplate 110 | destroyWarningTemplate.SetValue(terraform.CommonTemplate{ 111 | Title: cfg.PR.DestroyWarningTitle, 112 | Message: cfg.PR.DestroyWarningMessage, 113 | Result: result.Result, 114 | Body: body, 115 | Link: cfg.CI, 116 | UseRawOutput: cfg.UseRawOutput, 117 | }) 118 | body, err := templateExecute(destroyWarningTemplate) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | return g.client.Comment.Post(body, PostOptions{ 124 | Number: cfg.PR.Number, 125 | Revision: cfg.PR.Revision, 126 | }) 127 | } 128 | 129 | func (g *NotifyService) removeResultLabels() error { 130 | cfg := g.client.Config 131 | labels, _, err := g.client.API.IssuesListLabels(context.Background(), cfg.PR.Number, nil) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | for _, l := range labels { 137 | labelText := l.GetName() 138 | if cfg.ResultLabels.IsResultLabel(labelText) { 139 | resp, err := g.client.API.IssuesRemoveLabel(context.Background(), cfg.PR.Number, labelText) 140 | // Ignore 404 errors, which are from the PR not having the label 141 | if err != nil && resp.StatusCode != http.StatusNotFound { 142 | return err 143 | } 144 | } 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func templateExecute(template terraform.Template) (string, error) { 151 | body, err := template.Execute() 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | if utf8.RuneCountInString(body) <= 65536 { 157 | return body, nil 158 | } 159 | 160 | templateValues := template.GetValue() 161 | templateValues.Body = "Body is too long. Please check the CI result." 162 | 163 | template.SetValue(templateValues) 164 | return template.Execute() 165 | } 166 | -------------------------------------------------------------------------------- /notifier/github/notify_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mercari/tfnotify/terraform" 8 | ) 9 | 10 | func TestNotifyNotify(t *testing.T) { 11 | testCases := []struct { 12 | config Config 13 | body string 14 | ok bool 15 | exitCode int 16 | }{ 17 | { 18 | // invalid body (cannot parse) 19 | config: Config{ 20 | Token: "token", 21 | Owner: "owner", 22 | Repo: "repo", 23 | PR: PullRequest{ 24 | Revision: "abcd", 25 | Number: 1, 26 | Message: "message", 27 | }, 28 | Parser: terraform.NewPlanParser(), 29 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 30 | }, 31 | body: "body", 32 | ok: false, 33 | exitCode: 1, 34 | }, 35 | { 36 | // invalid pr 37 | config: Config{ 38 | Token: "token", 39 | Owner: "owner", 40 | Repo: "repo", 41 | PR: PullRequest{ 42 | Revision: "", 43 | Number: 0, 44 | Message: "message", 45 | }, 46 | Parser: terraform.NewPlanParser(), 47 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 48 | }, 49 | body: "Plan: 1 to add", 50 | ok: false, 51 | exitCode: 0, 52 | }, 53 | { 54 | // valid, error 55 | config: Config{ 56 | Token: "token", 57 | Owner: "owner", 58 | Repo: "repo", 59 | PR: PullRequest{ 60 | Revision: "", 61 | Number: 1, 62 | Message: "message", 63 | }, 64 | Parser: terraform.NewPlanParser(), 65 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 66 | }, 67 | body: "Error: hoge", 68 | ok: true, 69 | exitCode: 1, 70 | }, 71 | { 72 | // valid, and isPR 73 | config: Config{ 74 | Token: "token", 75 | Owner: "owner", 76 | Repo: "repo", 77 | PR: PullRequest{ 78 | Revision: "", 79 | Number: 1, 80 | Message: "message", 81 | }, 82 | Parser: terraform.NewPlanParser(), 83 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 84 | }, 85 | body: "Plan: 1 to add", 86 | ok: true, 87 | exitCode: 0, 88 | }, 89 | { 90 | // valid, isPR, and cannot comment details because body is too long 91 | config: Config{ 92 | Token: "token", 93 | Owner: "owner", 94 | Repo: "repo", 95 | PR: PullRequest{ 96 | Revision: "", 97 | Number: 1, 98 | Message: "message", 99 | }, 100 | Parser: terraform.NewPlanParser(), 101 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 102 | }, 103 | body: fmt.Sprintf("Plan: 1 to add \n%065537s", "0"), 104 | ok: true, 105 | exitCode: 0, 106 | }, 107 | { 108 | // valid, and isRevision 109 | config: Config{ 110 | Token: "token", 111 | Owner: "owner", 112 | Repo: "repo", 113 | PR: PullRequest{ 114 | Revision: "revision-revision", 115 | Number: 0, 116 | Message: "message", 117 | }, 118 | Parser: terraform.NewPlanParser(), 119 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 120 | }, 121 | body: "Plan: 1 to add", 122 | ok: true, 123 | exitCode: 0, 124 | }, 125 | { 126 | // valid, and contains destroy 127 | // TODO(dtan4): check two comments were made actually 128 | config: Config{ 129 | Token: "token", 130 | Owner: "owner", 131 | Repo: "repo", 132 | PR: PullRequest{ 133 | Revision: "", 134 | Number: 1, 135 | Message: "message", 136 | }, 137 | Parser: terraform.NewPlanParser(), 138 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 139 | DestroyWarningTemplate: terraform.NewDestroyWarningTemplate(terraform.DefaultDestroyWarningTemplate), 140 | WarnDestroy: true, 141 | }, 142 | body: "Plan: 1 to add, 1 to destroy", 143 | ok: true, 144 | exitCode: 0, 145 | }, 146 | { 147 | // valid with no changes 148 | // TODO(drlau): check that the label was actually added 149 | config: Config{ 150 | Token: "token", 151 | Owner: "owner", 152 | Repo: "repo", 153 | PR: PullRequest{ 154 | Revision: "", 155 | Number: 1, 156 | Message: "message", 157 | }, 158 | Parser: terraform.NewPlanParser(), 159 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 160 | ResultLabels: ResultLabels{ 161 | AddOrUpdateLabel: "add-or-update", 162 | DestroyLabel: "destroy", 163 | NoChangesLabel: "no-changes", 164 | PlanErrorLabel: "error", 165 | }, 166 | }, 167 | body: "No changes. Infrastructure is up-to-date.", 168 | ok: true, 169 | exitCode: 0, 170 | }, 171 | { 172 | // valid, contains destroy, but not to notify 173 | config: Config{ 174 | Token: "token", 175 | Owner: "owner", 176 | Repo: "repo", 177 | PR: PullRequest{ 178 | Revision: "", 179 | Number: 1, 180 | Message: "message", 181 | }, 182 | Parser: terraform.NewPlanParser(), 183 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 184 | DestroyWarningTemplate: terraform.NewDestroyWarningTemplate(terraform.DefaultDestroyWarningTemplate), 185 | WarnDestroy: false, 186 | }, 187 | body: "Plan: 1 to add, 1 to destroy", 188 | ok: true, 189 | exitCode: 0, 190 | }, 191 | { 192 | // apply case without merge commit 193 | config: Config{ 194 | Token: "token", 195 | Owner: "owner", 196 | Repo: "repo", 197 | PR: PullRequest{ 198 | Revision: "revision", 199 | Number: 0, // For apply, it is always 0 200 | Message: "message", 201 | }, 202 | Parser: terraform.NewApplyParser(), 203 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), 204 | }, 205 | body: "Apply complete!", 206 | ok: true, 207 | exitCode: 0, 208 | }, 209 | { 210 | // apply case as merge commit 211 | // TODO(drlau): validate cfg.PR.Number = 123 212 | config: Config{ 213 | Token: "token", 214 | Owner: "owner", 215 | Repo: "repo", 216 | PR: PullRequest{ 217 | Revision: "Merge pull request #123 from mercari/tfnotify", 218 | Number: 0, // For apply, it is always 0 219 | Message: "message", 220 | }, 221 | Parser: terraform.NewApplyParser(), 222 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), 223 | }, 224 | body: "Apply complete!", 225 | ok: true, 226 | exitCode: 0, 227 | }, 228 | } 229 | 230 | for _, testCase := range testCases { 231 | client, err := NewClient(testCase.config) 232 | if err != nil { 233 | t.Fatal(err) 234 | } 235 | api := newFakeAPI() 236 | client.API = &api 237 | exitCode, err := client.Notify.Notify(testCase.body) 238 | if (err == nil) != testCase.ok { 239 | t.Errorf("got error %q", err) 240 | } 241 | if exitCode != testCase.exitCode { 242 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode) 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /notifier/gitlab/client.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/mercari/tfnotify/terraform" 9 | 10 | gitlab "github.com/xanzy/go-gitlab" 11 | ) 12 | 13 | // EnvToken is GitLab API Token 14 | const EnvToken = "GITLAB_TOKEN" 15 | 16 | // EnvBaseURL is GitLab base URL. This can be set to a domain endpoint to use with Private GitLab. 17 | const EnvBaseURL = "GITLAB_BASE_URL" 18 | 19 | // Client ... 20 | type Client struct { 21 | *gitlab.Client 22 | Debug bool 23 | Config Config 24 | 25 | common service 26 | 27 | Comment *CommentService 28 | Commits *CommitsService 29 | Notify *NotifyService 30 | 31 | API API 32 | } 33 | 34 | // Config is a configuration for GitHub client 35 | type Config struct { 36 | Token string 37 | BaseURL string 38 | NameSpace string 39 | Project string 40 | MR MergeRequest 41 | CI string 42 | Parser terraform.Parser 43 | Template terraform.Template 44 | } 45 | 46 | // MergeRequest represents GitLab Merge Request metadata 47 | type MergeRequest struct { 48 | Revision string 49 | Title string 50 | Message string 51 | Number int 52 | } 53 | 54 | type service struct { 55 | client *Client 56 | } 57 | 58 | // NewClient returns Client initialized with Config 59 | func NewClient(cfg Config) (*Client, error) { 60 | token := cfg.Token 61 | token = strings.TrimPrefix(token, "$") 62 | if token == EnvToken { 63 | token = os.Getenv(EnvToken) 64 | } 65 | if token == "" { 66 | return &Client{}, errors.New("gitlab token is missing") 67 | } 68 | 69 | baseURL := cfg.BaseURL 70 | baseURL = strings.TrimPrefix(baseURL, "$") 71 | if baseURL == EnvBaseURL { 72 | baseURL = os.Getenv(EnvBaseURL) 73 | } 74 | 75 | option := make([]gitlab.ClientOptionFunc, 0, 1) 76 | if baseURL != "" { 77 | option = append(option, gitlab.WithBaseURL(baseURL)) 78 | } 79 | 80 | client, err := gitlab.NewClient(token, option...) 81 | 82 | if err != nil { 83 | return &Client{}, err 84 | } 85 | 86 | c := &Client{ 87 | Config: cfg, 88 | Client: client, 89 | } 90 | c.common.client = c 91 | c.Comment = (*CommentService)(&c.common) 92 | c.Commits = (*CommitsService)(&c.common) 93 | c.Notify = (*NotifyService)(&c.common) 94 | 95 | c.API = &GitLab{ 96 | Client: client, 97 | namespace: cfg.NameSpace, 98 | project: cfg.Project, 99 | } 100 | 101 | return c, nil 102 | } 103 | 104 | // IsNumber returns true if MergeRequest is Merge Request build 105 | func (mr *MergeRequest) IsNumber() bool { 106 | return mr.Number != 0 107 | } 108 | -------------------------------------------------------------------------------- /notifier/gitlab/client_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewClient(t *testing.T) { 9 | gitlabToken := os.Getenv(EnvToken) 10 | defer func() { 11 | os.Setenv(EnvToken, gitlabToken) 12 | }() 13 | os.Setenv(EnvToken, "") 14 | 15 | testCases := []struct { 16 | config Config 17 | envToken string 18 | expect string 19 | }{ 20 | { 21 | // specify directly 22 | config: Config{Token: "abcdefg"}, 23 | envToken: "", 24 | expect: "", 25 | }, 26 | { 27 | // specify via env but not to be set env (part 1) 28 | config: Config{Token: "GITLAB_TOKEN"}, 29 | envToken: "", 30 | expect: "gitlab token is missing", 31 | }, 32 | { 33 | // specify via env (part 1) 34 | config: Config{Token: "GITLAB_TOKEN"}, 35 | envToken: "abcdefg", 36 | expect: "", 37 | }, 38 | { 39 | // specify via env but not to be set env (part 2) 40 | config: Config{Token: "$GITLAB_TOKEN"}, 41 | envToken: "", 42 | expect: "gitlab token is missing", 43 | }, 44 | { 45 | // specify via env (part 2) 46 | config: Config{Token: "$GITLAB_TOKEN"}, 47 | envToken: "abcdefg", 48 | expect: "", 49 | }, 50 | { 51 | // no specification (part 1) 52 | config: Config{}, 53 | envToken: "", 54 | expect: "gitlab token is missing", 55 | }, 56 | { 57 | // no specification (part 2) 58 | config: Config{}, 59 | envToken: "abcdefg", 60 | expect: "gitlab token is missing", 61 | }, 62 | } 63 | for _, testCase := range testCases { 64 | os.Setenv(EnvToken, testCase.envToken) 65 | _, err := NewClient(testCase.config) 66 | if err == nil { 67 | continue 68 | } 69 | if err.Error() != testCase.expect { 70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect) 71 | } 72 | } 73 | } 74 | 75 | func TestNewClientWithBaseURL(t *testing.T) { 76 | gitlabBaseURL := os.Getenv(EnvBaseURL) 77 | defer func() { 78 | os.Setenv(EnvBaseURL, gitlabBaseURL) 79 | }() 80 | os.Setenv(EnvBaseURL, "") 81 | 82 | testCases := []struct { 83 | config Config 84 | envBaseURL string 85 | expect string 86 | }{ 87 | { 88 | // specify directly 89 | config: Config{ 90 | Token: "abcdefg", 91 | BaseURL: "https://git.example.com/", 92 | }, 93 | envBaseURL: "", 94 | expect: "https://git.example.com/api/v4/", 95 | }, 96 | { 97 | // specify via env but not to be set env (part 1) 98 | config: Config{ 99 | Token: "abcdefg", 100 | BaseURL: "GITLAB_BASE_URL", 101 | }, 102 | envBaseURL: "", 103 | expect: "https://gitlab.com/api/v4/", 104 | }, 105 | { 106 | // specify via env (part 1) 107 | config: Config{ 108 | Token: "abcdefg", 109 | BaseURL: "GITLAB_BASE_URL", 110 | }, 111 | envBaseURL: "https://git.example.com/", 112 | expect: "https://git.example.com/api/v4/", 113 | }, 114 | { 115 | // specify via env but not to be set env (part 2) 116 | config: Config{ 117 | Token: "abcdefg", 118 | BaseURL: "$GITLAB_BASE_URL", 119 | }, 120 | envBaseURL: "", 121 | expect: "https://gitlab.com/api/v4/", 122 | }, 123 | { 124 | // specify via env (part 2) 125 | config: Config{ 126 | Token: "abcdefg", 127 | BaseURL: "$GITLAB_BASE_URL", 128 | }, 129 | envBaseURL: "https://git.example.com/", 130 | expect: "https://git.example.com/api/v4/", 131 | }, 132 | { 133 | // no specification (part 1) 134 | config: Config{Token: "abcdefg"}, 135 | envBaseURL: "", 136 | expect: "https://gitlab.com/api/v4/", 137 | }, 138 | { 139 | // no specification (part 2) 140 | config: Config{Token: "abcdefg"}, 141 | envBaseURL: "https://git.example.com/", 142 | expect: "https://gitlab.com/api/v4/", 143 | }, 144 | } 145 | for _, testCase := range testCases { 146 | os.Setenv(EnvBaseURL, testCase.envBaseURL) 147 | c, err := NewClient(testCase.config) 148 | if err != nil { 149 | continue 150 | } 151 | url := c.Client.BaseURL().String() 152 | if url != testCase.expect { 153 | t.Errorf("got %q but want %q", url, testCase.expect) 154 | } 155 | } 156 | } 157 | 158 | func TestIsNumber(t *testing.T) { 159 | testCases := []struct { 160 | mr MergeRequest 161 | isPR bool 162 | }{ 163 | { 164 | mr: MergeRequest{ 165 | Number: 0, 166 | }, 167 | isPR: false, 168 | }, 169 | { 170 | mr: MergeRequest{ 171 | Number: 123, 172 | }, 173 | isPR: true, 174 | }, 175 | } 176 | for _, testCase := range testCases { 177 | if testCase.mr.IsNumber() != testCase.isPR { 178 | t.Errorf("got %v but want %v", testCase.mr.IsNumber(), testCase.isPR) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /notifier/gitlab/comment.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | gitlab "github.com/xanzy/go-gitlab" 8 | ) 9 | 10 | // CommentService handles communication with the comment related 11 | // methods of GitLab API 12 | type CommentService service 13 | 14 | // PostOptions specifies the optional parameters to post comments to a pull request 15 | type PostOptions struct { 16 | Number int 17 | Revision string 18 | } 19 | 20 | // Post posts comment 21 | func (g *CommentService) Post(body string, opt PostOptions) error { 22 | if opt.Number != 0 { 23 | _, _, err := g.client.API.CreateMergeRequestNote( 24 | opt.Number, 25 | &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.String(body)}, 26 | ) 27 | return err 28 | } 29 | if opt.Revision != "" { 30 | _, _, err := g.client.API.PostCommitComment( 31 | opt.Revision, 32 | &gitlab.PostCommitCommentOptions{Note: gitlab.String(body)}, 33 | ) 34 | return err 35 | } 36 | return fmt.Errorf("gitlab.comment.post: Number or Revision is required") 37 | } 38 | 39 | // List lists comments on GitLab merge requests 40 | func (g *CommentService) List(number int) ([]*gitlab.Note, error) { 41 | comments, _, err := g.client.API.ListMergeRequestNotes( 42 | number, 43 | &gitlab.ListMergeRequestNotesOptions{}, 44 | ) 45 | return comments, err 46 | } 47 | 48 | // Delete deletes comment on GitLab merge requests 49 | func (g *CommentService) Delete(note int) error { 50 | _, err := g.client.API.DeleteMergeRequestNote( 51 | g.client.Config.MR.Number, 52 | note, 53 | ) 54 | return err 55 | } 56 | 57 | // DeleteDuplicates deletes duplicate comments containing arbitrary character strings 58 | func (g *CommentService) DeleteDuplicates(title string) { 59 | var ids []int 60 | comments := g.getDuplicates(title) 61 | for _, comment := range comments { 62 | ids = append(ids, comment.ID) 63 | } 64 | for _, id := range ids { 65 | // don't handle error 66 | g.client.Comment.Delete(id) 67 | } 68 | } 69 | 70 | func (g *CommentService) getDuplicates(title string) []*gitlab.Note { 71 | var dup []*gitlab.Note 72 | re := regexp.MustCompile(`(?m)^(\n+)?` + title + `( +.*)?\n+` + g.client.Config.MR.Message + `\n+`) 73 | 74 | comments, _ := g.client.Comment.List(g.client.Config.MR.Number) 75 | for _, comment := range comments { 76 | if re.MatchString(comment.Body) { 77 | dup = append(dup, comment) 78 | } 79 | } 80 | 81 | return dup 82 | } 83 | -------------------------------------------------------------------------------- /notifier/gitlab/comment_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | gitlab "github.com/xanzy/go-gitlab" 8 | ) 9 | 10 | func TestCommentPost(t *testing.T) { 11 | testCases := []struct { 12 | config Config 13 | body string 14 | opt PostOptions 15 | ok bool 16 | }{ 17 | { 18 | config: newFakeConfig(), 19 | body: "", 20 | opt: PostOptions{ 21 | Number: 1, 22 | Revision: "abcd", 23 | }, 24 | ok: true, 25 | }, 26 | { 27 | config: newFakeConfig(), 28 | body: "", 29 | opt: PostOptions{ 30 | Number: 0, 31 | Revision: "abcd", 32 | }, 33 | ok: true, 34 | }, 35 | { 36 | config: newFakeConfig(), 37 | body: "", 38 | opt: PostOptions{ 39 | Number: 2, 40 | Revision: "", 41 | }, 42 | ok: true, 43 | }, 44 | { 45 | config: newFakeConfig(), 46 | body: "", 47 | opt: PostOptions{ 48 | Number: 0, 49 | Revision: "", 50 | }, 51 | ok: false, 52 | }, 53 | } 54 | 55 | for _, testCase := range testCases { 56 | client, err := NewClient(testCase.config) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | api := newFakeAPI() 61 | client.API = &api 62 | err = client.Comment.Post(testCase.body, testCase.opt) 63 | if (err == nil) != testCase.ok { 64 | t.Errorf("got error %q", err) 65 | } 66 | } 67 | } 68 | 69 | func TestCommentList(t *testing.T) { 70 | comments := []*gitlab.Note{ 71 | &gitlab.Note{ 72 | ID: 371748792, 73 | Body: "comment 1", 74 | }, 75 | &gitlab.Note{ 76 | ID: 371765743, 77 | Body: "comment 2", 78 | }, 79 | } 80 | testCases := []struct { 81 | config Config 82 | number int 83 | ok bool 84 | comments []*gitlab.Note 85 | }{ 86 | { 87 | config: newFakeConfig(), 88 | number: 1, 89 | ok: true, 90 | comments: comments, 91 | }, 92 | { 93 | config: newFakeConfig(), 94 | number: 12, 95 | ok: true, 96 | comments: comments, 97 | }, 98 | { 99 | config: newFakeConfig(), 100 | number: 123, 101 | ok: true, 102 | comments: comments, 103 | }, 104 | } 105 | 106 | for _, testCase := range testCases { 107 | client, err := NewClient(testCase.config) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | api := newFakeAPI() 112 | client.API = &api 113 | comments, err := client.Comment.List(testCase.number) 114 | if (err == nil) != testCase.ok { 115 | t.Errorf("got error %q", err) 116 | } 117 | if !reflect.DeepEqual(comments, testCase.comments) { 118 | t.Errorf("got %v but want %v", comments, testCase.comments) 119 | } 120 | } 121 | } 122 | 123 | func TestCommentDelete(t *testing.T) { 124 | testCases := []struct { 125 | config Config 126 | id int 127 | ok bool 128 | }{ 129 | { 130 | config: newFakeConfig(), 131 | id: 1, 132 | ok: true, 133 | }, 134 | { 135 | config: newFakeConfig(), 136 | id: 12, 137 | ok: true, 138 | }, 139 | { 140 | config: newFakeConfig(), 141 | id: 123, 142 | ok: true, 143 | }, 144 | } 145 | 146 | for _, testCase := range testCases { 147 | client, err := NewClient(testCase.config) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | api := newFakeAPI() 152 | client.API = &api 153 | err = client.Comment.Delete(testCase.id) 154 | if (err == nil) != testCase.ok { 155 | t.Errorf("got error %q", err) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /notifier/gitlab/commits.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "errors" 5 | 6 | gitlab "github.com/xanzy/go-gitlab" 7 | ) 8 | 9 | // CommitsService handles communication with the commits related 10 | // methods of GitLab API 11 | type CommitsService service 12 | 13 | // List lists commits on a repository 14 | func (g *CommitsService) List(revision string) ([]string, error) { 15 | if revision == "" { 16 | return []string{}, errors.New("no revision specified") 17 | } 18 | var s []string 19 | commits, _, err := g.client.API.ListCommits( 20 | &gitlab.ListCommitsOptions{}, 21 | ) 22 | if err != nil { 23 | return s, err 24 | } 25 | for _, commit := range commits { 26 | s = append(s, commit.ID) 27 | } 28 | return s, nil 29 | } 30 | 31 | // lastOne returns the hash of the previous commit of the given commit 32 | func (g *CommitsService) lastOne(commits []string, revision string) (string, error) { 33 | if revision == "" { 34 | return "", errors.New("no revision specified") 35 | } 36 | if len(commits) == 0 { 37 | return "", errors.New("no commits") 38 | } 39 | 40 | return commits[1], nil 41 | } 42 | -------------------------------------------------------------------------------- /notifier/gitlab/commits_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommitsList(t *testing.T) { 8 | testCases := []struct { 9 | revision string 10 | ok bool 11 | }{ 12 | { 13 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 14 | ok: true, 15 | }, 16 | { 17 | revision: "", 18 | ok: false, 19 | }, 20 | } 21 | 22 | for _, testCase := range testCases { 23 | cfg := newFakeConfig() 24 | client, err := NewClient(cfg) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | api := newFakeAPI() 29 | client.API = &api 30 | _, err = client.Commits.List(testCase.revision) 31 | if (err == nil) != testCase.ok { 32 | t.Errorf("got error %q", err) 33 | } 34 | } 35 | } 36 | 37 | func TestCommitsLastOne(t *testing.T) { 38 | testCases := []struct { 39 | commits []string 40 | revision string 41 | lastRev string 42 | ok bool 43 | }{ 44 | { 45 | // ok 46 | commits: []string{ 47 | "04e0917e448b662c2b16330fad50e97af16ff27a", 48 | "04e0917e448b662c2b16330fad50e97af16ff27b", 49 | "04e0917e448b662c2b16330fad50e97af16ff27c", 50 | }, 51 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 52 | lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b", 53 | ok: true, 54 | }, 55 | { 56 | // no revision 57 | commits: []string{ 58 | "04e0917e448b662c2b16330fad50e97af16ff27a", 59 | "04e0917e448b662c2b16330fad50e97af16ff27b", 60 | "04e0917e448b662c2b16330fad50e97af16ff27c", 61 | }, 62 | revision: "", 63 | lastRev: "", 64 | ok: false, 65 | }, 66 | { 67 | // no commits 68 | commits: []string{}, 69 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a", 70 | lastRev: "", 71 | ok: false, 72 | }, 73 | } 74 | 75 | for _, testCase := range testCases { 76 | cfg := newFakeConfig() 77 | client, err := NewClient(cfg) 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | api := newFakeAPI() 82 | client.API = &api 83 | commit, err := client.Commits.lastOne(testCase.commits, testCase.revision) 84 | if (err == nil) != testCase.ok { 85 | t.Errorf("got error %q", err) 86 | } 87 | if commit != testCase.lastRev { 88 | t.Errorf("got %q but want %q", commit, testCase.lastRev) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /notifier/gitlab/gitlab.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | gitlab "github.com/xanzy/go-gitlab" 6 | ) 7 | 8 | // API is GitLab API interface 9 | type API interface { 10 | CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) 11 | DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) 12 | ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) 13 | PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) 14 | ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) 15 | } 16 | 17 | // GitLab represents the attribute information necessary for requesting GitLab API 18 | type GitLab struct { 19 | *gitlab.Client 20 | namespace, project string 21 | } 22 | 23 | // CreateMergeRequestNote is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.CreateMergeRequestNote 24 | func (g *GitLab) CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { 25 | return g.Client.Notes.CreateMergeRequestNote(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, opt, options...) 26 | } 27 | 28 | // DeleteMergeRequestNote is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.DeleteMergeRequestNote 29 | func (g *GitLab) DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { 30 | return g.Client.Notes.DeleteMergeRequestNote(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, note, options...) 31 | } 32 | 33 | // ListMergeRequestNotes is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.ListMergeRequestNotes 34 | func (g *GitLab) ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) { 35 | return g.Client.Notes.ListMergeRequestNotes(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, opt, options...) 36 | } 37 | 38 | // PostCommitComment is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#CommitsService.PostCommitComment 39 | func (g *GitLab) PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) { 40 | return g.Client.Commits.PostCommitComment(fmt.Sprintf("%s/%s", g.namespace, g.project), sha, opt, options...) 41 | } 42 | 43 | // ListCommits is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#CommitsService.ListCommits 44 | func (g *GitLab) ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) { 45 | return g.Client.Commits.ListCommits(fmt.Sprintf("%s/%s", g.namespace, g.project), opt, options...) 46 | } 47 | -------------------------------------------------------------------------------- /notifier/gitlab/gitlab_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "github.com/mercari/tfnotify/terraform" 5 | gitlab "github.com/xanzy/go-gitlab" 6 | ) 7 | 8 | type fakeAPI struct { 9 | API 10 | FakeCreateMergeRequestNote func(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) 11 | FakeDeleteMergeRequestNote func(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) 12 | FakeListMergeRequestNotes func(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) 13 | FakePostCommitComment func(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) 14 | FakeListCommits func(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) 15 | } 16 | 17 | func (g *fakeAPI) CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { 18 | return g.FakeCreateMergeRequestNote(mergeRequest, opt, options...) 19 | } 20 | 21 | func (g *fakeAPI) DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { 22 | return g.FakeDeleteMergeRequestNote(mergeRequest, note, options...) 23 | } 24 | 25 | func (g *fakeAPI) ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) { 26 | return g.FakeListMergeRequestNotes(mergeRequest, opt, options...) 27 | } 28 | 29 | func (g *fakeAPI) PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) { 30 | return g.FakePostCommitComment(sha, opt, options...) 31 | } 32 | 33 | func (g *fakeAPI) ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) { 34 | return g.FakeListCommits(opt, options...) 35 | } 36 | 37 | func newFakeAPI() fakeAPI { 38 | return fakeAPI{ 39 | FakeCreateMergeRequestNote: func(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { 40 | return &gitlab.Note{ 41 | ID: 371748792, 42 | Body: "comment 1", 43 | }, nil, nil 44 | }, 45 | FakeDeleteMergeRequestNote: func(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { 46 | return nil, nil 47 | }, 48 | FakeListMergeRequestNotes: func(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) { 49 | var comments []*gitlab.Note 50 | comments = []*gitlab.Note{ 51 | &gitlab.Note{ 52 | ID: 371748792, 53 | Body: "comment 1", 54 | }, 55 | &gitlab.Note{ 56 | ID: 371765743, 57 | Body: "comment 2", 58 | }, 59 | } 60 | return comments, nil, nil 61 | }, 62 | FakePostCommitComment: func(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) { 63 | return &gitlab.CommitComment{ 64 | Note: "comment 1", 65 | }, nil, nil 66 | }, 67 | FakeListCommits: func(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) { 68 | var commits []*gitlab.Commit 69 | commits = []*gitlab.Commit{ 70 | &gitlab.Commit{ 71 | ID: "04e0917e448b662c2b16330fad50e97af16ff27a", 72 | }, 73 | &gitlab.Commit{ 74 | ID: "04e0917e448b662c2b16330fad50e97af16ff27b", 75 | }, 76 | &gitlab.Commit{ 77 | ID: "04e0917e448b662c2b16330fad50e97af16ff27c", 78 | }, 79 | } 80 | return commits, nil, nil 81 | }, 82 | } 83 | } 84 | 85 | func newFakeConfig() Config { 86 | return Config{ 87 | Token: "token", 88 | NameSpace: "owner", 89 | Project: "repo", 90 | MR: MergeRequest{ 91 | Revision: "abcd", 92 | Number: 1, 93 | Message: "message", 94 | }, 95 | Parser: terraform.NewPlanParser(), 96 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /notifier/gitlab/notify.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "github.com/mercari/tfnotify/terraform" 5 | ) 6 | 7 | // NotifyService handles communication with the notification related 8 | // methods of GitHub API 9 | type NotifyService service 10 | 11 | // Notify posts comment optimized for notifications 12 | func (g *NotifyService) Notify(body string) (exit int, err error) { 13 | cfg := g.client.Config 14 | parser := g.client.Config.Parser 15 | template := g.client.Config.Template 16 | 17 | result := parser.Parse(body) 18 | if result.Error != nil { 19 | return result.ExitCode, result.Error 20 | } 21 | if result.Result == "" { 22 | return result.ExitCode, result.Error 23 | } 24 | 25 | template.SetValue(terraform.CommonTemplate{ 26 | Title: cfg.MR.Title, 27 | Message: cfg.MR.Message, 28 | Result: result.Result, 29 | Body: body, 30 | Link: cfg.CI, 31 | }) 32 | body, err = template.Execute() 33 | if err != nil { 34 | return result.ExitCode, err 35 | } 36 | 37 | value := template.GetValue() 38 | 39 | if cfg.MR.IsNumber() { 40 | g.client.Comment.DeleteDuplicates(value.Title) 41 | } 42 | 43 | _, isApply := parser.(*terraform.ApplyParser) 44 | if !cfg.MR.IsNumber() && isApply { 45 | commits, err := g.client.Commits.List(cfg.MR.Revision) 46 | if err != nil { 47 | return result.ExitCode, err 48 | } 49 | lastRevision, _ := g.client.Commits.lastOne(commits, cfg.MR.Revision) 50 | cfg.MR.Revision = lastRevision 51 | } 52 | 53 | return result.ExitCode, g.client.Comment.Post(body, PostOptions{ 54 | Number: cfg.MR.Number, 55 | Revision: cfg.MR.Revision, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /notifier/gitlab/notify_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mercari/tfnotify/terraform" 7 | ) 8 | 9 | func TestNotifyNotify(t *testing.T) { 10 | testCases := []struct { 11 | config Config 12 | body string 13 | ok bool 14 | exitCode int 15 | }{ 16 | { 17 | // invalid body (cannot parse) 18 | config: Config{ 19 | Token: "token", 20 | NameSpace: "namespace", 21 | Project: "project", 22 | MR: MergeRequest{ 23 | Revision: "abcd", 24 | Number: 1, 25 | Message: "message", 26 | }, 27 | Parser: terraform.NewPlanParser(), 28 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 29 | }, 30 | body: "body", 31 | ok: false, 32 | exitCode: 1, 33 | }, 34 | { 35 | // invalid pr 36 | config: Config{ 37 | Token: "token", 38 | NameSpace: "owner", 39 | Project: "repo", 40 | MR: MergeRequest{ 41 | Revision: "", 42 | Number: 0, 43 | Message: "message", 44 | }, 45 | Parser: terraform.NewPlanParser(), 46 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 47 | }, 48 | body: "Plan: 1 to add", 49 | ok: false, 50 | exitCode: 0, 51 | }, 52 | { 53 | // valid, error 54 | config: Config{ 55 | Token: "token", 56 | NameSpace: "owner", 57 | Project: "repo", 58 | MR: MergeRequest{ 59 | Revision: "", 60 | Number: 1, 61 | Message: "message", 62 | }, 63 | Parser: terraform.NewPlanParser(), 64 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 65 | }, 66 | body: "Error: hoge", 67 | ok: true, 68 | exitCode: 1, 69 | }, 70 | { 71 | // valid, and isPR 72 | config: Config{ 73 | Token: "token", 74 | NameSpace: "owner", 75 | Project: "repo", 76 | MR: MergeRequest{ 77 | Revision: "", 78 | Number: 1, 79 | Message: "message", 80 | }, 81 | Parser: terraform.NewPlanParser(), 82 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 83 | }, 84 | body: "Plan: 1 to add", 85 | ok: true, 86 | exitCode: 0, 87 | }, 88 | { 89 | // valid, and isRevision 90 | config: Config{ 91 | Token: "token", 92 | NameSpace: "owner", 93 | Project: "repo", 94 | MR: MergeRequest{ 95 | Revision: "revision-revision", 96 | Number: 0, 97 | Message: "message", 98 | }, 99 | Parser: terraform.NewPlanParser(), 100 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 101 | }, 102 | body: "Plan: 1 to add", 103 | ok: true, 104 | exitCode: 0, 105 | }, 106 | { 107 | // apply case 108 | config: Config{ 109 | Token: "token", 110 | NameSpace: "owner", 111 | Project: "repo", 112 | MR: MergeRequest{ 113 | Revision: "revision", 114 | Number: 0, // For apply, it is always 0 115 | Message: "message", 116 | }, 117 | Parser: terraform.NewApplyParser(), 118 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), 119 | }, 120 | body: "Apply complete!", 121 | ok: true, 122 | exitCode: 0, 123 | }, 124 | } 125 | 126 | for _, testCase := range testCases { 127 | client, err := NewClient(testCase.config) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | api := newFakeAPI() 132 | client.API = &api 133 | exitCode, err := client.Notify.Notify(testCase.body) 134 | if (err == nil) != testCase.ok { 135 | t.Errorf("got error %q", err) 136 | } 137 | if exitCode != testCase.exitCode { 138 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /notifier/notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | // Notifier is a notification interface 4 | type Notifier interface { 5 | Notify(body string) (exit int, err error) 6 | } 7 | -------------------------------------------------------------------------------- /notifier/notifier_test.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | -------------------------------------------------------------------------------- /notifier/slack/client.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/lestrrat-go/slack" 9 | "github.com/mercari/tfnotify/terraform" 10 | ) 11 | 12 | // EnvToken is Slack API Token 13 | const EnvToken = "SLACK_TOKEN" 14 | 15 | // EnvChannelID is Slack channel ID 16 | const EnvChannelID = "SLACK_CHANNEL_ID" 17 | 18 | // EnvBotName is Slack bot name 19 | const EnvBotName = "SLACK_BOT_NAME" 20 | 21 | // Client is a API client for Slack 22 | type Client struct { 23 | *slack.Client 24 | 25 | Config Config 26 | 27 | common service 28 | 29 | Notify *NotifyService 30 | 31 | API API 32 | } 33 | 34 | // Config is a configuration for Slack client 35 | type Config struct { 36 | Token string 37 | Channel string 38 | Botname string 39 | Title string 40 | Message string 41 | CI string 42 | Parser terraform.Parser 43 | UseRawOutput bool 44 | Template terraform.Template 45 | } 46 | 47 | type service struct { 48 | client *Client 49 | } 50 | 51 | // NewClient returns Client initialized with Config 52 | func NewClient(cfg Config) (*Client, error) { 53 | token := cfg.Token 54 | token = strings.TrimPrefix(token, "$") 55 | if token == EnvToken { 56 | token = os.Getenv(EnvToken) 57 | } 58 | if token == "" { 59 | return &Client{}, errors.New("slack token is missing") 60 | } 61 | 62 | channel := cfg.Channel 63 | channel = strings.TrimPrefix(channel, "$") 64 | if channel == EnvChannelID { 65 | channel = os.Getenv(EnvChannelID) 66 | } 67 | 68 | botname := cfg.Botname 69 | botname = strings.TrimPrefix(botname, "$") 70 | if botname == EnvBotName { 71 | botname = os.Getenv(EnvBotName) 72 | } 73 | 74 | client := slack.New(token) 75 | c := &Client{ 76 | Config: cfg, 77 | Client: client, 78 | } 79 | c.common.client = c 80 | c.Notify = (*NotifyService)(&c.common) 81 | c.API = &Slack{ 82 | Client: client, 83 | Channel: channel, 84 | Botname: botname, 85 | } 86 | return c, nil 87 | } 88 | -------------------------------------------------------------------------------- /notifier/slack/client_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewClient(t *testing.T) { 9 | slackToken := os.Getenv(EnvToken) 10 | defer func() { 11 | os.Setenv(EnvToken, slackToken) 12 | }() 13 | os.Setenv(EnvToken, "") 14 | 15 | testCases := []struct { 16 | config Config 17 | envToken string 18 | expect string 19 | }{ 20 | { 21 | // specify directly 22 | config: Config{Token: "abcdefg"}, 23 | envToken: "", 24 | expect: "", 25 | }, 26 | { 27 | // specify via env but not to be set env (part 1) 28 | config: Config{Token: "SLACK_TOKEN"}, 29 | envToken: "", 30 | expect: "slack token is missing", 31 | }, 32 | { 33 | // specify via env (part 1) 34 | config: Config{Token: "SLACK_TOKEN"}, 35 | envToken: "abcdefg", 36 | expect: "", 37 | }, 38 | { 39 | // specify via env but not to be set env (part 2) 40 | config: Config{Token: "$SLACK_TOKEN"}, 41 | envToken: "", 42 | expect: "slack token is missing", 43 | }, 44 | { 45 | // specify via env (part 2) 46 | config: Config{Token: "$SLACK_TOKEN"}, 47 | envToken: "abcdefg", 48 | expect: "", 49 | }, 50 | { 51 | // no specification (part 1) 52 | config: Config{}, 53 | envToken: "", 54 | expect: "slack token is missing", 55 | }, 56 | { 57 | // no specification (part 2) 58 | config: Config{}, 59 | envToken: "abcdefg", 60 | expect: "slack token is missing", 61 | }, 62 | } 63 | for _, testCase := range testCases { 64 | os.Setenv(EnvToken, testCase.envToken) 65 | _, err := NewClient(testCase.config) 66 | if err == nil { 67 | continue 68 | } 69 | if err.Error() != testCase.expect { 70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /notifier/slack/notify.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/lestrrat-go/slack/objects" 8 | "github.com/mercari/tfnotify/terraform" 9 | ) 10 | 11 | // NotifyService handles communication with the notification related 12 | // methods of Slack API 13 | type NotifyService service 14 | 15 | // Notify posts comment optimized for notifications 16 | func (s *NotifyService) Notify(body string) (exit int, err error) { 17 | cfg := s.client.Config 18 | parser := s.client.Config.Parser 19 | template := s.client.Config.Template 20 | 21 | if cfg.Channel == "" { 22 | return terraform.ExitFail, errors.New("channel id is required") 23 | } 24 | 25 | result := parser.Parse(body) 26 | if result.Error != nil { 27 | return result.ExitCode, result.Error 28 | } 29 | if result.Result == "" { 30 | return result.ExitCode, result.Error 31 | } 32 | 33 | color := "warning" 34 | switch result.ExitCode { 35 | case terraform.ExitPass: 36 | color = "good" 37 | case terraform.ExitFail: 38 | color = "danger" 39 | } 40 | 41 | template.SetValue(terraform.CommonTemplate{ 42 | Title: cfg.Title, 43 | Message: cfg.Message, 44 | Result: result.Result, 45 | Body: body, 46 | UseRawOutput: cfg.UseRawOutput, 47 | Link: cfg.CI, 48 | }) 49 | text, err := template.Execute() 50 | if err != nil { 51 | return result.ExitCode, err 52 | } 53 | 54 | var attachments objects.AttachmentList 55 | attachment := &objects.Attachment{ 56 | Color: color, 57 | Fallback: text, 58 | Footer: cfg.CI, 59 | Text: text, 60 | Title: template.GetValue().Title, 61 | } 62 | 63 | attachments.Append(attachment) 64 | // _, err = s.client.Chat().PostMessage(cfg.Channel).Username(cfg.Botname).SetAttachments(attachments).Do(cfg.Context) 65 | _, err = s.client.API.ChatPostMessage(context.Background(), attachments) 66 | return result.ExitCode, err 67 | } 68 | -------------------------------------------------------------------------------- /notifier/slack/notify_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/lestrrat-go/slack/objects" 8 | "github.com/mercari/tfnotify/terraform" 9 | ) 10 | 11 | func TestNotify(t *testing.T) { 12 | testCases := []struct { 13 | config Config 14 | body string 15 | exitCode int 16 | ok bool 17 | }{ 18 | { 19 | config: Config{ 20 | Token: "token", 21 | Channel: "channel", 22 | Botname: "botname", 23 | Message: "", 24 | Parser: terraform.NewPlanParser(), 25 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 26 | }, 27 | body: "Plan: 1 to add", 28 | exitCode: 0, 29 | ok: true, 30 | }, 31 | { 32 | config: Config{ 33 | Token: "token", 34 | Channel: "", 35 | Botname: "botname", 36 | Message: "", 37 | Parser: terraform.NewPlanParser(), 38 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 39 | }, 40 | body: "Plan: 1 to add", 41 | exitCode: 1, 42 | ok: false, 43 | }, 44 | } 45 | fake := fakeAPI{ 46 | FakeChatPostMessage: func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { 47 | return nil, nil 48 | }, 49 | } 50 | 51 | for _, testCase := range testCases { 52 | client, err := NewClient(testCase.config) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | client.API = &fake 57 | exitCode, err := client.Notify.Notify(testCase.body) 58 | if (err == nil) != testCase.ok { 59 | t.Errorf("got error %q", err) 60 | } 61 | if exitCode != testCase.exitCode { 62 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /notifier/slack/slack.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lestrrat-go/slack" 7 | "github.com/lestrrat-go/slack/objects" 8 | ) 9 | 10 | // API is Slack API interface 11 | type API interface { 12 | ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) 13 | } 14 | 15 | // Slack represents the attribute information necessary for requesting Slack API 16 | type Slack struct { 17 | *slack.Client 18 | Channel string 19 | Botname string 20 | } 21 | 22 | // ChatPostMessage is a wrapper of https://godoc.org/github.com/lestrrat-go/slack#ChatPostMessageCall 23 | func (s *Slack) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { 24 | return s.Client.Chat().PostMessage(s.Channel).Username(s.Botname).SetAttachments(attachments).Do(ctx) 25 | } 26 | -------------------------------------------------------------------------------- /notifier/slack/slack_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lestrrat-go/slack/objects" 7 | ) 8 | 9 | type fakeAPI struct { 10 | API 11 | FakeChatPostMessage func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) 12 | } 13 | 14 | func (g *fakeAPI) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) { 15 | return g.FakeChatPostMessage(ctx, attachments) 16 | } 17 | -------------------------------------------------------------------------------- /notifier/typetalk/client.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/mercari/tfnotify/terraform" 9 | typetalk "github.com/nulab/go-typetalk/typetalk/v1" 10 | ) 11 | 12 | // EnvToken is Typetalk API Token 13 | const EnvToken = "TYPETALK_TOKEN" 14 | 15 | // EnvTopicID is Typetalk topic ID 16 | const EnvTopicID = "TYPETALK_TOPIC_ID" 17 | 18 | // Client represents Typetalk API client. 19 | type Client struct { 20 | *typetalk.Client 21 | Config Config 22 | common service 23 | Notify *NotifyService 24 | API API 25 | } 26 | 27 | // Config is a configuration for Typetalk Client 28 | type Config struct { 29 | Token string 30 | Title string 31 | TopicID string 32 | Message string 33 | CI string 34 | Parser terraform.Parser 35 | Template terraform.Template 36 | } 37 | 38 | type service struct { 39 | client *Client 40 | } 41 | 42 | // NewClient returns Client initialized with Config 43 | func NewClient(cfg Config) (*Client, error) { 44 | token := os.ExpandEnv(cfg.Token) 45 | if token == EnvToken { 46 | token = os.Getenv(EnvToken) 47 | } 48 | if token == "" { 49 | return &Client{}, errors.New("Typetalk token is missing") 50 | } 51 | 52 | topicIDString := os.ExpandEnv(cfg.TopicID) 53 | if topicIDString == EnvTopicID { 54 | topicIDString = os.Getenv(EnvTopicID) 55 | } 56 | if topicIDString == "" { 57 | return &Client{}, errors.New("Typetalk topic ID is missing") 58 | } 59 | 60 | topicID, err := strconv.Atoi(topicIDString) 61 | if err != nil { 62 | return &Client{}, errors.New("Typetalk topic ID is not numeric value") 63 | } 64 | 65 | client := typetalk.NewClient(nil) 66 | client.SetTypetalkToken(token) 67 | c := &Client{ 68 | Config: cfg, 69 | Client: client, 70 | } 71 | c.common.client = c 72 | c.Notify = (*NotifyService)(&c.common) 73 | c.API = &Typetalk{ 74 | Client: client, 75 | TopicID: topicID, 76 | } 77 | 78 | return c, nil 79 | } 80 | -------------------------------------------------------------------------------- /notifier/typetalk/client_test.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewClient(t *testing.T) { 9 | typetalkToken := os.Getenv(EnvToken) 10 | defer func() { 11 | os.Setenv(EnvToken, typetalkToken) 12 | }() 13 | os.Setenv(EnvToken, "") 14 | 15 | testCases := []struct { 16 | config Config 17 | envToken string 18 | expect string 19 | }{ 20 | { 21 | // specify directly 22 | config: Config{Token: "abcdefg", TopicID: "12345"}, 23 | envToken: "", 24 | expect: "", 25 | }, 26 | { 27 | // specify via env but not to be set env (part 1) 28 | config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"}, 29 | envToken: "", 30 | expect: "Typetalk token is missing", 31 | }, 32 | { 33 | // specify via env (part 1) 34 | config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"}, 35 | envToken: "abcdefg", 36 | expect: "", 37 | }, 38 | { 39 | // specify via env but not to be set env (part 2) 40 | config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"}, 41 | envToken: "", 42 | expect: "Typetalk token is missing", 43 | }, 44 | { 45 | // specify via env (part 2) 46 | config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"}, 47 | envToken: "abcdefg", 48 | expect: "", 49 | }, 50 | { 51 | // no specification (part 1) 52 | config: Config{TopicID: "12345"}, 53 | envToken: "", 54 | expect: "Typetalk token is missing", 55 | }, 56 | { 57 | // no specification (part 2) 58 | config: Config{TopicID: "12345"}, 59 | envToken: "abcdefg", 60 | expect: "Typetalk token is missing", 61 | }, 62 | } 63 | for _, testCase := range testCases { 64 | os.Setenv(EnvToken, testCase.envToken) 65 | _, err := NewClient(testCase.config) 66 | if err == nil { 67 | continue 68 | } 69 | if err.Error() != testCase.expect { 70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /notifier/typetalk/notify.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/mercari/tfnotify/terraform" 8 | ) 9 | 10 | // NotifyService handles notification process. 11 | type NotifyService service 12 | 13 | // Notify posts message to Typetalk. 14 | func (s *NotifyService) Notify(body string) (exit int, err error) { 15 | cfg := s.client.Config 16 | parser := s.client.Config.Parser 17 | template := s.client.Config.Template 18 | 19 | if cfg.TopicID == "" { 20 | return terraform.ExitFail, errors.New("topic id is required") 21 | } 22 | 23 | result := parser.Parse(body) 24 | if result.Error != nil { 25 | return result.ExitCode, result.Error 26 | } 27 | if result.Result == "" { 28 | return result.ExitCode, result.Error 29 | } 30 | 31 | template.SetValue(terraform.CommonTemplate{ 32 | Title: cfg.Title, 33 | Message: cfg.Message, 34 | Result: result.Result, 35 | Body: body, 36 | }) 37 | text, err := template.Execute() 38 | if err != nil { 39 | return result.ExitCode, err 40 | } 41 | 42 | _, _, err = s.client.API.ChatPostMessage(context.Background(), text) 43 | return result.ExitCode, err 44 | } 45 | -------------------------------------------------------------------------------- /notifier/typetalk/notify_test.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/mercari/tfnotify/terraform" 8 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" 9 | typetalk "github.com/nulab/go-typetalk/typetalk/v1" 10 | ) 11 | 12 | func TestNotify(t *testing.T) { 13 | testCases := []struct { 14 | config Config 15 | body string 16 | exitCode int 17 | ok bool 18 | }{ 19 | { 20 | config: Config{ 21 | Token: "token", 22 | TopicID: "12345", 23 | Message: "", 24 | Parser: terraform.NewPlanParser(), 25 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 26 | }, 27 | body: "Plan: 1 to add", 28 | exitCode: 0, 29 | ok: true, 30 | }, 31 | { 32 | config: Config{ 33 | Token: "token", 34 | TopicID: "12345", 35 | Message: "", 36 | Parser: terraform.NewPlanParser(), 37 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 38 | }, 39 | body: "BLUR BLUR BLUR", 40 | exitCode: 1, 41 | ok: false, 42 | }, 43 | } 44 | fake := fakeAPI{ 45 | FakeChatPostMessage: func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { 46 | return nil, nil, nil 47 | }, 48 | } 49 | 50 | for _, testCase := range testCases { 51 | client, err := NewClient(testCase.config) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | client.API = &fake 56 | exitCode, err := client.Notify.Notify(testCase.body) 57 | if (err == nil) != testCase.ok { 58 | t.Errorf("got error %q", err) 59 | } 60 | if exitCode != testCase.exitCode { 61 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /notifier/typetalk/typetalk.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "context" 5 | 6 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" 7 | typetalk "github.com/nulab/go-typetalk/typetalk/v1" 8 | ) 9 | 10 | // API is Typetalk API interface 11 | type API interface { 12 | ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) 13 | } 14 | 15 | // Typetalk represents the attribute information necessary for requesting Typetalk API 16 | type Typetalk struct { 17 | *typetalk.Client 18 | TopicID int 19 | } 20 | 21 | // ChatPostMessage is wrapper for https://godoc.org/github.com/nulab/go-typetalk/typetalk/v1#MessagesService.PostMessage 22 | func (t *Typetalk) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { 23 | return t.Client.Messages.PostMessage(ctx, t.TopicID, message, nil) 24 | } 25 | -------------------------------------------------------------------------------- /notifier/typetalk/typetalk_test.go: -------------------------------------------------------------------------------- 1 | package typetalk 2 | 3 | import ( 4 | "context" 5 | 6 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared" 7 | typetalk "github.com/nulab/go-typetalk/typetalk/v1" 8 | ) 9 | 10 | type fakeAPI struct { 11 | API 12 | FakeChatPostMessage func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) 13 | } 14 | 15 | func (g *fakeAPI) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) { 16 | return g.FakeChatPostMessage(ctx, message) 17 | } 18 | -------------------------------------------------------------------------------- /tee.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | 8 | "github.com/mattn/go-colorable" 9 | ) 10 | 11 | func tee(stdin io.Reader, stdout io.Writer) string { 12 | var b1 bytes.Buffer 13 | var b2 bytes.Buffer 14 | 15 | tee := io.TeeReader(stdin, &b1) 16 | s := bufio.NewScanner(tee) 17 | s.Split(bufio.ScanBytes) 18 | for s.Scan() { 19 | stdout.Write(s.Bytes()) 20 | } 21 | 22 | uncolorize := colorable.NewNonColorable(&b2) 23 | uncolorize.Write(b1.Bytes()) 24 | 25 | return b2.String() 26 | } 27 | -------------------------------------------------------------------------------- /tee_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | ) 8 | 9 | func TestTee(t *testing.T) { 10 | testCases := []struct { 11 | stdin io.Reader 12 | stdout string 13 | body string 14 | }{ 15 | { 16 | // Regular 17 | stdin: bytes.NewBufferString("Plan: 1 to add\n"), 18 | stdout: "Plan: 1 to add\n", 19 | body: "Plan: 1 to add\n", 20 | }, 21 | { 22 | // ANSI color codes are included 23 | stdin: bytes.NewBufferString("\033[mPlan: 1 to add\033[m\n"), 24 | stdout: "\033[mPlan: 1 to add\033[m\n", 25 | body: "Plan: 1 to add\n", 26 | }, 27 | } 28 | 29 | for _, testCase := range testCases { 30 | stdout := new(bytes.Buffer) 31 | body := tee(testCase.stdin, stdout) 32 | if body != testCase.body { 33 | t.Errorf("got %q but want %q", body, testCase.body) 34 | } 35 | if stdout.String() != testCase.stdout { 36 | t.Errorf("got %q but want %q", stdout.String(), testCase.stdout) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /terraform/parser.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // Parser is an interface for parsing terraform execution result 10 | type Parser interface { 11 | Parse(body string) ParseResult 12 | } 13 | 14 | // ParseResult represents the result of parsed terraform execution 15 | type ParseResult struct { 16 | Result string 17 | HasAddOrUpdateOnly bool 18 | HasDestroy bool 19 | HasNoChanges bool 20 | HasPlanError bool 21 | ExitCode int 22 | Error error 23 | } 24 | 25 | // DefaultParser is a parser for terraform commands 26 | type DefaultParser struct { 27 | } 28 | 29 | // FmtParser is a parser for terraform fmt 30 | type FmtParser struct { 31 | Pass *regexp.Regexp 32 | Fail *regexp.Regexp 33 | } 34 | 35 | // ValidateParser is a parser for terraform Validate 36 | type ValidateParser struct { 37 | Pass *regexp.Regexp 38 | Fail *regexp.Regexp 39 | } 40 | 41 | // PlanParser is a parser for terraform plan 42 | type PlanParser struct { 43 | Pass *regexp.Regexp 44 | Fail *regexp.Regexp 45 | HasDestroy *regexp.Regexp 46 | HasNoChanges *regexp.Regexp 47 | } 48 | 49 | // ApplyParser is a parser for terraform apply 50 | type ApplyParser struct { 51 | Pass *regexp.Regexp 52 | Fail *regexp.Regexp 53 | } 54 | 55 | // NewDefaultParser is DefaultParser initializer 56 | func NewDefaultParser() *DefaultParser { 57 | return &DefaultParser{} 58 | } 59 | 60 | // NewFmtParser is FmtParser initialized with its Regexp 61 | func NewFmtParser() *FmtParser { 62 | return &FmtParser{ 63 | Fail: regexp.MustCompile(`(?m)^@@[^@]+@@`), 64 | } 65 | } 66 | 67 | // NewValidateParser is ValidateParser initialized with its Regexp 68 | func NewValidateParser() *ValidateParser { 69 | return &ValidateParser{ 70 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`), 71 | } 72 | } 73 | 74 | // NewPlanParser is PlanParser initialized with its Regexp 75 | func NewPlanParser() *PlanParser { 76 | return &PlanParser{ 77 | Pass: regexp.MustCompile(`(?m)^((Plan: \d|No changes.)|(Changes to Outputs:))`), 78 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`), 79 | // "0 to destroy" should be treated as "no destroy" 80 | HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`), 81 | HasNoChanges: regexp.MustCompile(`(?m)^(No changes. Infrastructure is up-to-date.)`), 82 | } 83 | } 84 | 85 | // NewApplyParser is ApplyParser initialized with its Regexp 86 | func NewApplyParser() *ApplyParser { 87 | return &ApplyParser{ 88 | Pass: regexp.MustCompile(`(?m)^(Apply complete!)`), 89 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`), 90 | } 91 | } 92 | 93 | // Parse returns ParseResult related with terraform commands 94 | func (p *DefaultParser) Parse(body string) ParseResult { 95 | return ParseResult{ 96 | Result: body, 97 | ExitCode: ExitPass, 98 | Error: nil, 99 | } 100 | } 101 | 102 | // Parse returns ParseResult related with terraform fmt 103 | func (p *FmtParser) Parse(body string) ParseResult { 104 | result := ParseResult{} 105 | if p.Fail.MatchString(body) { 106 | result.Result = "There is diff in your .tf file (need to be formatted)" 107 | result.ExitCode = ExitFail 108 | } 109 | return result 110 | } 111 | 112 | // Parse returns ParseResult related with terraform validate 113 | func (p *ValidateParser) Parse(body string) ParseResult { 114 | result := ParseResult{} 115 | if p.Fail.MatchString(body) { 116 | result.Result = "There is a validation error in your Terraform code" 117 | result.ExitCode = ExitFail 118 | } 119 | return result 120 | } 121 | 122 | // Parse returns ParseResult related with terraform plan 123 | func (p *PlanParser) Parse(body string) ParseResult { 124 | var exitCode int 125 | switch { 126 | case p.Pass.MatchString(body): 127 | exitCode = ExitPass 128 | case p.Fail.MatchString(body): 129 | exitCode = ExitFail 130 | default: 131 | return ParseResult{ 132 | Result: "", 133 | ExitCode: ExitFail, 134 | Error: fmt.Errorf("cannot parse plan result"), 135 | } 136 | } 137 | lines := strings.Split(body, "\n") 138 | var i int 139 | var result, line string 140 | for i, line = range lines { 141 | if p.Pass.MatchString(line) || p.Fail.MatchString(line) { 142 | break 143 | } 144 | } 145 | var hasPlanError bool 146 | switch { 147 | case p.Pass.MatchString(line): 148 | result = lines[i] 149 | case p.Fail.MatchString(line): 150 | hasPlanError = true 151 | result = strings.Join(trimLastNewline(lines[i:]), "\n") 152 | } 153 | 154 | hasDestroy := p.HasDestroy.MatchString(line) 155 | hasNoChanges := p.HasNoChanges.MatchString(line) 156 | HasAddOrUpdateOnly := !hasNoChanges && !hasDestroy && !hasPlanError 157 | 158 | return ParseResult{ 159 | Result: result, 160 | HasAddOrUpdateOnly: HasAddOrUpdateOnly, 161 | HasDestroy: hasDestroy, 162 | HasNoChanges: hasNoChanges, 163 | HasPlanError: hasPlanError, 164 | ExitCode: exitCode, 165 | Error: nil, 166 | } 167 | } 168 | 169 | // Parse returns ParseResult related with terraform apply 170 | func (p *ApplyParser) Parse(body string) ParseResult { 171 | var exitCode int 172 | switch { 173 | case p.Pass.MatchString(body): 174 | exitCode = ExitPass 175 | case p.Fail.MatchString(body): 176 | exitCode = ExitFail 177 | default: 178 | return ParseResult{ 179 | Result: "", 180 | ExitCode: ExitFail, 181 | Error: fmt.Errorf("cannot parse apply result"), 182 | } 183 | } 184 | lines := strings.Split(body, "\n") 185 | var i int 186 | var result, line string 187 | for i, line = range lines { 188 | if p.Pass.MatchString(line) || p.Fail.MatchString(line) { 189 | break 190 | } 191 | } 192 | switch { 193 | case p.Pass.MatchString(line): 194 | result = lines[i] 195 | case p.Fail.MatchString(line): 196 | result = strings.Join(trimLastNewline(lines[i:]), "\n") 197 | } 198 | return ParseResult{ 199 | Result: result, 200 | ExitCode: exitCode, 201 | Error: nil, 202 | } 203 | } 204 | 205 | func trimLastNewline(s []string) []string { 206 | if len(s) == 0 { 207 | return s 208 | } 209 | last := len(s) - 1 210 | if s[last] == "" { 211 | return s[:last] 212 | } 213 | return s 214 | } 215 | -------------------------------------------------------------------------------- /terraform/template.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "bytes" 5 | htmltemplate "html/template" 6 | texttemplate "text/template" 7 | ) 8 | 9 | const ( 10 | // DefaultDefaultTitle is a default title for terraform commands 11 | DefaultDefaultTitle = "## Terraform result" 12 | // DefaultFmtTitle is a default title for terraform fmt 13 | DefaultFmtTitle = "## Fmt result" 14 | // DefaultValidateTitle is a default title for terraform validate 15 | DefaultValidateTitle = "## Validate result" 16 | // DefaultPlanTitle is a default title for terraform plan 17 | DefaultPlanTitle = "## Plan result" 18 | // DefaultDestroyWarningTitle is a default title of destroy warning 19 | DefaultDestroyWarningTitle = "## WARNING: Resource Deletion will happen" 20 | // DefaultApplyTitle is a default title for terraform apply 21 | DefaultApplyTitle = "## Apply result" 22 | 23 | // DefaultDefaultTemplate is a default template for terraform commands 24 | DefaultDefaultTemplate = ` 25 | {{ .Title }} 26 | 27 | {{ .Message }} 28 | 29 | {{if .Result}} 30 |
{{ .Result }}
 31 | 
32 | {{end}} 33 | 34 |
Details (Click me) 35 | 36 |
{{ .Body }}
 37 | 
38 | ` 39 | 40 | // DefaultFmtTemplate is a default template for terraform fmt 41 | DefaultFmtTemplate = ` 42 | {{ .Title }} 43 | 44 | {{ .Message }} 45 | 46 | {{if .Result}} 47 |
{{ .Result }}
 48 | 
49 | {{end}} 50 | 51 |
Details (Click me) 52 | 53 |
{{ .Body }}
 54 | 
55 | ` 56 | 57 | // DefaultValidateTemplate is a default template for terraform validate 58 | DefaultValidateTemplate = ` 59 | {{ .Title }} 60 | 61 | {{ .Message }} 62 | 63 | {{if .Result}} 64 |
{{ .Result }}
 65 | 
66 | {{end}} 67 | 68 |
Details (Click me) 69 | 70 |
{{ .Body }}
 71 | 
72 | ` 73 | 74 | // DefaultPlanTemplate is a default template for terraform plan 75 | DefaultPlanTemplate = ` 76 | {{ .Title }} 77 | 78 | {{ .Message }} 79 | 80 | {{if .Result}} 81 |
{{ .Result }}
 82 | 
83 | {{end}} 84 | 85 |
Details (Click me) 86 | 87 |
{{ .Body }}
 88 | 
89 | ` 90 | 91 | // DefaultDestroyWarningTemplate is a default template for terraform plan 92 | DefaultDestroyWarningTemplate = ` 93 | {{ .Title }} 94 | 95 | This plan contains resource delete operation. Please check the plan result very carefully! 96 | 97 | {{if .Result}} 98 |
{{ .Result }}
 99 | 
100 | {{end}} 101 | ` 102 | 103 | // DefaultApplyTemplate is a default template for terraform apply 104 | DefaultApplyTemplate = ` 105 | {{ .Title }} 106 | 107 | {{ .Message }} 108 | 109 | {{if .Result}} 110 |
{{ .Result }}
111 | 
112 | {{end}} 113 | 114 |
Details (Click me) 115 | 116 |
{{ .Body }}
117 | 
118 | ` 119 | ) 120 | 121 | // Template is an template interface for parsed terraform execution result 122 | type Template interface { 123 | Execute() (resp string, err error) 124 | SetValue(template CommonTemplate) 125 | GetValue() CommonTemplate 126 | } 127 | 128 | // CommonTemplate represents template entities 129 | type CommonTemplate struct { 130 | Title string 131 | Message string 132 | Result string 133 | Body string 134 | Link string 135 | UseRawOutput bool 136 | } 137 | 138 | // DefaultTemplate is a default template for terraform commands 139 | type DefaultTemplate struct { 140 | Template string 141 | 142 | CommonTemplate 143 | } 144 | 145 | // FmtTemplate is a default template for terraform fmt 146 | type FmtTemplate struct { 147 | Template string 148 | 149 | CommonTemplate 150 | } 151 | 152 | // ValidateTemplate is a default template for terraform validate 153 | type ValidateTemplate struct { 154 | Template string 155 | 156 | CommonTemplate 157 | } 158 | 159 | // PlanTemplate is a default template for terraform plan 160 | type PlanTemplate struct { 161 | Template string 162 | 163 | CommonTemplate 164 | } 165 | 166 | // DestroyWarningTemplate is a default template for warning of destroy operation in plan 167 | type DestroyWarningTemplate struct { 168 | Template string 169 | 170 | CommonTemplate 171 | } 172 | 173 | // ApplyTemplate is a default template for terraform apply 174 | type ApplyTemplate struct { 175 | Template string 176 | 177 | CommonTemplate 178 | } 179 | 180 | // NewDefaultTemplate is DefaultTemplate initializer 181 | func NewDefaultTemplate(template string) *DefaultTemplate { 182 | if template == "" { 183 | template = DefaultDefaultTemplate 184 | } 185 | return &DefaultTemplate{ 186 | Template: template, 187 | } 188 | } 189 | 190 | // NewFmtTemplate is FmtTemplate initializer 191 | func NewFmtTemplate(template string) *FmtTemplate { 192 | if template == "" { 193 | template = DefaultFmtTemplate 194 | } 195 | return &FmtTemplate{ 196 | Template: template, 197 | } 198 | } 199 | 200 | // NewValidateTemplate is ValidateTemplate initializer 201 | func NewValidateTemplate(template string) *ValidateTemplate { 202 | if template == "" { 203 | template = DefaultValidateTemplate 204 | } 205 | return &ValidateTemplate{ 206 | Template: template, 207 | } 208 | } 209 | 210 | // NewPlanTemplate is PlanTemplate initializer 211 | func NewPlanTemplate(template string) *PlanTemplate { 212 | if template == "" { 213 | template = DefaultPlanTemplate 214 | } 215 | return &PlanTemplate{ 216 | Template: template, 217 | } 218 | } 219 | 220 | // NewDestroyWarningTemplate is DestroyWarningTemplate initializer 221 | func NewDestroyWarningTemplate(template string) *DestroyWarningTemplate { 222 | if template == "" { 223 | template = DefaultDestroyWarningTemplate 224 | } 225 | return &DestroyWarningTemplate{ 226 | Template: template, 227 | } 228 | } 229 | 230 | // NewApplyTemplate is ApplyTemplate initializer 231 | func NewApplyTemplate(template string) *ApplyTemplate { 232 | if template == "" { 233 | template = DefaultApplyTemplate 234 | } 235 | return &ApplyTemplate{ 236 | Template: template, 237 | } 238 | } 239 | 240 | func generateOutput(kind, template string, data map[string]interface{}, useRawOutput bool) (string, error) { 241 | var b bytes.Buffer 242 | 243 | if useRawOutput { 244 | tpl, err := texttemplate.New(kind).Parse(template) 245 | if err != nil { 246 | return "", err 247 | } 248 | if err := tpl.Execute(&b, data); err != nil { 249 | return "", err 250 | } 251 | } else { 252 | tpl, err := htmltemplate.New(kind).Parse(template) 253 | if err != nil { 254 | return "", err 255 | } 256 | if err := tpl.Execute(&b, data); err != nil { 257 | return "", err 258 | } 259 | } 260 | 261 | return b.String(), nil 262 | } 263 | 264 | // Execute binds the execution result of terraform command into template 265 | func (t *DefaultTemplate) Execute() (string, error) { 266 | data := map[string]interface{}{ 267 | "Title": t.Title, 268 | "Message": t.Message, 269 | "Result": "", 270 | "Body": t.Result, 271 | "Link": t.Link, 272 | } 273 | 274 | resp, err := generateOutput("default", t.Template, data, t.UseRawOutput) 275 | if err != nil { 276 | return "", err 277 | } 278 | 279 | return resp, nil 280 | } 281 | 282 | // Execute binds the execution result of terraform fmt into template 283 | func (t *FmtTemplate) Execute() (string, error) { 284 | data := map[string]interface{}{ 285 | "Title": t.Title, 286 | "Message": t.Message, 287 | "Result": t.Result, 288 | "Body": t.Body, 289 | "Link": t.Link, 290 | } 291 | 292 | resp, err := generateOutput("fmt", t.Template, data, t.UseRawOutput) 293 | if err != nil { 294 | return "", err 295 | } 296 | 297 | return resp, nil 298 | } 299 | 300 | // Execute binds the execution result of terraform validate into template 301 | func (t *ValidateTemplate) Execute() (string, error) { 302 | data := map[string]interface{}{ 303 | "Title": t.Title, 304 | "Message": t.Message, 305 | "Result": t.Result, 306 | "Body": t.Body, 307 | "Link": t.Link, 308 | } 309 | 310 | resp, err := generateOutput("validate", t.Template, data, t.UseRawOutput) 311 | if err != nil { 312 | return "", err 313 | } 314 | 315 | return resp, nil 316 | } 317 | 318 | // Execute binds the execution result of terraform plan into template 319 | func (t *PlanTemplate) Execute() (string, error) { 320 | data := map[string]interface{}{ 321 | "Title": t.Title, 322 | "Message": t.Message, 323 | "Result": t.Result, 324 | "Body": t.Body, 325 | "Link": t.Link, 326 | } 327 | 328 | resp, err := generateOutput("plan", t.Template, data, t.UseRawOutput) 329 | if err != nil { 330 | return "", err 331 | } 332 | 333 | return resp, nil 334 | } 335 | 336 | // Execute binds the execution result of terraform plan into template 337 | func (t *DestroyWarningTemplate) Execute() (string, error) { 338 | data := map[string]interface{}{ 339 | "Title": t.Title, 340 | "Message": t.Message, 341 | "Result": t.Result, 342 | "Body": t.Body, 343 | "Link": t.Link, 344 | } 345 | 346 | resp, err := generateOutput("destroy_warning", t.Template, data, t.UseRawOutput) 347 | if err != nil { 348 | return "", err 349 | } 350 | 351 | return resp, nil 352 | } 353 | 354 | // Execute binds the execution result of terraform apply into template 355 | func (t *ApplyTemplate) Execute() (string, error) { 356 | data := map[string]interface{}{ 357 | "Title": t.Title, 358 | "Message": t.Message, 359 | "Result": t.Result, 360 | "Body": t.Body, 361 | "Link": t.Link, 362 | } 363 | 364 | resp, err := generateOutput("apply", t.Template, data, t.UseRawOutput) 365 | if err != nil { 366 | return "", err 367 | } 368 | 369 | return resp, nil 370 | } 371 | 372 | // SetValue sets template entities to CommonTemplate 373 | func (t *DefaultTemplate) SetValue(ct CommonTemplate) { 374 | if ct.Title == "" { 375 | ct.Title = DefaultDefaultTitle 376 | } 377 | t.CommonTemplate = ct 378 | } 379 | 380 | // SetValue sets template entities about terraform fmt to CommonTemplate 381 | func (t *FmtTemplate) SetValue(ct CommonTemplate) { 382 | if ct.Title == "" { 383 | ct.Title = DefaultFmtTitle 384 | } 385 | t.CommonTemplate = ct 386 | } 387 | 388 | // SetValue sets template entities about terraform validate to CommonTemplate 389 | func (t *ValidateTemplate) SetValue(ct CommonTemplate) { 390 | if ct.Title == "" { 391 | ct.Title = DefaultValidateTitle 392 | } 393 | t.CommonTemplate = ct 394 | } 395 | 396 | // SetValue sets template entities about terraform plan to CommonTemplate 397 | func (t *PlanTemplate) SetValue(ct CommonTemplate) { 398 | if ct.Title == "" { 399 | ct.Title = DefaultPlanTitle 400 | } 401 | t.CommonTemplate = ct 402 | } 403 | 404 | // SetValue sets template entities about destroy warning to CommonTemplate 405 | func (t *DestroyWarningTemplate) SetValue(ct CommonTemplate) { 406 | if ct.Title == "" { 407 | ct.Title = DefaultDestroyWarningTitle 408 | } 409 | t.CommonTemplate = ct 410 | } 411 | 412 | // SetValue sets template entities about terraform apply to CommonTemplate 413 | func (t *ApplyTemplate) SetValue(ct CommonTemplate) { 414 | if ct.Title == "" { 415 | ct.Title = DefaultApplyTitle 416 | } 417 | t.CommonTemplate = ct 418 | } 419 | 420 | // GetValue gets template entities 421 | func (t *DefaultTemplate) GetValue() CommonTemplate { 422 | return t.CommonTemplate 423 | } 424 | 425 | // GetValue gets template entities 426 | func (t *FmtTemplate) GetValue() CommonTemplate { 427 | return t.CommonTemplate 428 | } 429 | 430 | // GetValue gets template entities 431 | func (t *ValidateTemplate) GetValue() CommonTemplate { 432 | return t.CommonTemplate 433 | } 434 | 435 | // GetValue gets template entities 436 | func (t *PlanTemplate) GetValue() CommonTemplate { 437 | return t.CommonTemplate 438 | } 439 | 440 | // GetValue gets template entities 441 | func (t *DestroyWarningTemplate) GetValue() CommonTemplate { 442 | return t.CommonTemplate 443 | } 444 | 445 | // GetValue gets template entities 446 | func (t *ApplyTemplate) GetValue() CommonTemplate { 447 | return t.CommonTemplate 448 | } 449 | -------------------------------------------------------------------------------- /terraform/template_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestDefaultTemplateExecute(t *testing.T) { 9 | testCases := []struct { 10 | template string 11 | value CommonTemplate 12 | resp string 13 | }{ 14 | { 15 | template: DefaultDefaultTemplate, 16 | value: CommonTemplate{}, 17 | resp: ` 18 | ## Terraform result 19 | 20 | 21 | 22 | 23 | 24 |
Details (Click me) 25 | 26 |

 27 | 
28 | `, 29 | }, 30 | { 31 | template: DefaultDefaultTemplate, 32 | value: CommonTemplate{ 33 | Message: "message", 34 | }, 35 | resp: ` 36 | ## Terraform result 37 | 38 | message 39 | 40 | 41 | 42 |
Details (Click me) 43 | 44 |

 45 | 
46 | `, 47 | }, 48 | { 49 | template: DefaultDefaultTemplate, 50 | value: CommonTemplate{ 51 | Title: "a", 52 | Message: "b", 53 | Result: "c", 54 | Body: "d", 55 | }, 56 | resp: ` 57 | a 58 | 59 | b 60 | 61 | 62 | 63 |
Details (Click me) 64 | 65 |
c
 66 | 
67 | `, 68 | }, 69 | { 70 | template: "", 71 | value: CommonTemplate{ 72 | Title: "a", 73 | Message: "b", 74 | Result: "c", 75 | Body: "d", 76 | }, 77 | resp: ` 78 | a 79 | 80 | b 81 | 82 | 83 | 84 |
Details (Click me) 85 | 86 |
c
 87 | 
88 | `, 89 | }, 90 | { 91 | template: "", 92 | value: CommonTemplate{ 93 | Title: "a", 94 | Message: "b", 95 | Result: `This is a "result".`, 96 | Body: "d", 97 | }, 98 | resp: ` 99 | a 100 | 101 | b 102 | 103 | 104 | 105 |
Details (Click me) 106 | 107 |
This is a "result".
108 | 
109 | `, 110 | }, 111 | { 112 | template: "", 113 | value: CommonTemplate{ 114 | Title: "a", 115 | Message: "b", 116 | Result: `This is a "result".`, 117 | Body: "d", 118 | UseRawOutput: true, 119 | }, 120 | resp: ` 121 | a 122 | 123 | b 124 | 125 | 126 | 127 |
Details (Click me) 128 | 129 |
This is a "result".
130 | 
131 | `, 132 | }, 133 | { 134 | template: "", 135 | value: CommonTemplate{ 136 | Title: "a", 137 | Message: "b", 138 | Result: `This is a "result".`, 139 | Body: "d", 140 | UseRawOutput: true, 141 | }, 142 | resp: ` 143 | a 144 | 145 | b 146 | 147 | 148 | 149 |
Details (Click me) 150 | 151 |
This is a "result".
152 | 
153 | `, 154 | }, 155 | { 156 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 157 | value: CommonTemplate{ 158 | Title: "a", 159 | Message: "b", 160 | Result: "should be used as body", 161 | Body: "should be empty", 162 | }, 163 | resp: `a-b--should be used as body`, 164 | }, 165 | } 166 | for _, testCase := range testCases { 167 | template := NewDefaultTemplate(testCase.template) 168 | template.SetValue(testCase.value) 169 | resp, err := template.Execute() 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | if resp != testCase.resp { 174 | t.Errorf("got %q but want %q", resp, testCase.resp) 175 | } 176 | } 177 | } 178 | 179 | func TestFmtTemplateExecute(t *testing.T) { 180 | testCases := []struct { 181 | template string 182 | value CommonTemplate 183 | resp string 184 | }{ 185 | { 186 | template: DefaultFmtTemplate, 187 | value: CommonTemplate{}, 188 | resp: ` 189 | ## Fmt result 190 | 191 | 192 | 193 | 194 | 195 |
Details (Click me) 196 | 197 |

198 | 
199 | `, 200 | }, 201 | { 202 | template: DefaultFmtTemplate, 203 | value: CommonTemplate{ 204 | Message: "message", 205 | }, 206 | resp: ` 207 | ## Fmt result 208 | 209 | message 210 | 211 | 212 | 213 |
Details (Click me) 214 | 215 |

216 | 
217 | `, 218 | }, 219 | { 220 | template: DefaultFmtTemplate, 221 | value: CommonTemplate{ 222 | Title: "a", 223 | Message: "b", 224 | Result: "c", 225 | Body: "d", 226 | }, 227 | resp: ` 228 | a 229 | 230 | b 231 | 232 | 233 |
c
234 | 
235 | 236 | 237 |
Details (Click me) 238 | 239 |
d
240 | 
241 | `, 242 | }, 243 | { 244 | template: "", 245 | value: CommonTemplate{ 246 | Title: "a", 247 | Message: "b", 248 | Result: "c", 249 | Body: "d", 250 | }, 251 | resp: ` 252 | a 253 | 254 | b 255 | 256 | 257 |
c
258 | 
259 | 260 | 261 |
Details (Click me) 262 | 263 |
d
264 | 
265 | `, 266 | }, 267 | { 268 | template: "", 269 | value: CommonTemplate{ 270 | Title: "a", 271 | Message: "b", 272 | Result: `This is a "result".`, 273 | Body: "d", 274 | }, 275 | resp: ` 276 | a 277 | 278 | b 279 | 280 | 281 |
This is a "result".
282 | 
283 | 284 | 285 |
Details (Click me) 286 | 287 |
d
288 | 
289 | `, 290 | }, 291 | { 292 | template: "", 293 | value: CommonTemplate{ 294 | Title: "a", 295 | Message: "b", 296 | Result: `This is a "result".`, 297 | Body: "d", 298 | UseRawOutput: true, 299 | }, 300 | resp: ` 301 | a 302 | 303 | b 304 | 305 | 306 |
This is a "result".
307 | 
308 | 309 | 310 |
Details (Click me) 311 | 312 |
d
313 | 
314 | `, 315 | }, 316 | { 317 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 318 | value: CommonTemplate{ 319 | Title: "a", 320 | Message: "b", 321 | Result: "c", 322 | Body: "d", 323 | }, 324 | resp: `a-b-c-d`, 325 | }, 326 | } 327 | for _, testCase := range testCases { 328 | template := NewFmtTemplate(testCase.template) 329 | template.SetValue(testCase.value) 330 | resp, err := template.Execute() 331 | if err != nil { 332 | t.Fatal(err) 333 | } 334 | if resp != testCase.resp { 335 | t.Errorf("got %q but want %q", resp, testCase.resp) 336 | } 337 | } 338 | } 339 | 340 | func TestValidateTemplateExecute(t *testing.T) { 341 | testCases := []struct { 342 | template string 343 | value CommonTemplate 344 | resp string 345 | }{ 346 | { 347 | template: DefaultValidateTemplate, 348 | value: CommonTemplate{}, 349 | resp: ` 350 | ## Validate result 351 | 352 | 353 | 354 | 355 | 356 |
Details (Click me) 357 | 358 |

359 | 
360 | `, 361 | }, 362 | { 363 | template: DefaultValidateTemplate, 364 | value: CommonTemplate{ 365 | Message: "message", 366 | }, 367 | resp: ` 368 | ## Validate result 369 | 370 | message 371 | 372 | 373 | 374 |
Details (Click me) 375 | 376 |

377 | 
378 | `, 379 | }, 380 | { 381 | template: DefaultValidateTemplate, 382 | value: CommonTemplate{ 383 | Title: "a", 384 | Message: "b", 385 | Result: "c", 386 | Body: "d", 387 | }, 388 | resp: ` 389 | a 390 | 391 | b 392 | 393 | 394 |
c
395 | 
396 | 397 | 398 |
Details (Click me) 399 | 400 |
d
401 | 
402 | `, 403 | }, 404 | { 405 | template: "", 406 | value: CommonTemplate{ 407 | Title: "a", 408 | Message: "b", 409 | Result: "c", 410 | Body: "d", 411 | }, 412 | resp: ` 413 | a 414 | 415 | b 416 | 417 | 418 |
c
419 | 
420 | 421 | 422 |
Details (Click me) 423 | 424 |
d
425 | 
426 | `, 427 | }, 428 | { 429 | template: "", 430 | value: CommonTemplate{ 431 | Title: "a", 432 | Message: "b", 433 | Result: `This is a "result".`, 434 | Body: "d", 435 | }, 436 | resp: ` 437 | a 438 | 439 | b 440 | 441 | 442 |
This is a "result".
443 | 
444 | 445 | 446 |
Details (Click me) 447 | 448 |
d
449 | 
450 | `, 451 | }, 452 | { 453 | template: "", 454 | value: CommonTemplate{ 455 | Title: "a", 456 | Message: "b", 457 | Result: `This is a "result".`, 458 | Body: "d", 459 | UseRawOutput: true, 460 | }, 461 | resp: ` 462 | a 463 | 464 | b 465 | 466 | 467 |
This is a "result".
468 | 
469 | 470 | 471 |
Details (Click me) 472 | 473 |
d
474 | 
475 | `, 476 | }, 477 | { 478 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 479 | value: CommonTemplate{ 480 | Title: "a", 481 | Message: "b", 482 | Result: "c", 483 | Body: "d", 484 | }, 485 | resp: `a-b-c-d`, 486 | }, 487 | } 488 | for _, testCase := range testCases { 489 | template := NewValidateTemplate(testCase.template) 490 | template.SetValue(testCase.value) 491 | resp, err := template.Execute() 492 | if err != nil { 493 | t.Fatal(err) 494 | } 495 | if resp != testCase.resp { 496 | t.Errorf("got %q but want %q", resp, testCase.resp) 497 | } 498 | } 499 | } 500 | 501 | func TestPlanTemplateExecute(t *testing.T) { 502 | testCases := []struct { 503 | template string 504 | value CommonTemplate 505 | resp string 506 | }{ 507 | { 508 | template: DefaultPlanTemplate, 509 | value: CommonTemplate{}, 510 | resp: ` 511 | ## Plan result 512 | 513 | 514 | 515 | 516 | 517 |
Details (Click me) 518 | 519 |

520 | 
521 | `, 522 | }, 523 | { 524 | template: DefaultPlanTemplate, 525 | value: CommonTemplate{ 526 | Title: "title", 527 | Message: "message", 528 | Result: "result", 529 | Body: "body", 530 | }, 531 | resp: ` 532 | title 533 | 534 | message 535 | 536 | 537 |
result
538 | 
539 | 540 | 541 |
Details (Click me) 542 | 543 |
body
544 | 
545 | `, 546 | }, 547 | { 548 | template: DefaultPlanTemplate, 549 | value: CommonTemplate{ 550 | Title: "title", 551 | Message: "message", 552 | Result: "", 553 | Body: "body", 554 | }, 555 | resp: ` 556 | title 557 | 558 | message 559 | 560 | 561 | 562 |
Details (Click me) 563 | 564 |
body
565 | 
566 | `, 567 | }, 568 | { 569 | template: DefaultPlanTemplate, 570 | value: CommonTemplate{ 571 | Title: "title", 572 | Message: "message", 573 | Result: "", 574 | Body: `This is a "body".`, 575 | }, 576 | resp: ` 577 | title 578 | 579 | message 580 | 581 | 582 | 583 |
Details (Click me) 584 | 585 |
This is a "body".
586 | 
587 | `, 588 | }, 589 | { 590 | template: DefaultPlanTemplate, 591 | value: CommonTemplate{ 592 | Title: "title", 593 | Message: "message", 594 | Result: "", 595 | Body: `This is a "body".`, 596 | UseRawOutput: true, 597 | }, 598 | resp: ` 599 | title 600 | 601 | message 602 | 603 | 604 | 605 |
Details (Click me) 606 | 607 |
This is a "body".
608 | 
609 | `, 610 | }, 611 | { 612 | template: "", 613 | value: CommonTemplate{ 614 | Title: "title", 615 | Message: "message", 616 | Result: "", 617 | Body: "body", 618 | }, 619 | resp: ` 620 | title 621 | 622 | message 623 | 624 | 625 | 626 |
Details (Click me) 627 | 628 |
body
629 | 
630 | `, 631 | }, 632 | { 633 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 634 | value: CommonTemplate{ 635 | Title: "a", 636 | Message: "b", 637 | Result: "c", 638 | Body: "d", 639 | }, 640 | resp: `a-b-c-d`, 641 | }, 642 | } 643 | for _, testCase := range testCases { 644 | template := NewPlanTemplate(testCase.template) 645 | template.SetValue(testCase.value) 646 | resp, err := template.Execute() 647 | if err != nil { 648 | t.Fatal(err) 649 | } 650 | if resp != testCase.resp { 651 | t.Errorf("got %q but want %q", resp, testCase.resp) 652 | } 653 | } 654 | } 655 | 656 | func TestDestroyWarningTemplateExecute(t *testing.T) { 657 | testCases := []struct { 658 | template string 659 | value CommonTemplate 660 | resp string 661 | }{ 662 | { 663 | template: DefaultDestroyWarningTemplate, 664 | value: CommonTemplate{}, 665 | resp: ` 666 | ## WARNING: Resource Deletion will happen 667 | 668 | This plan contains resource delete operation. Please check the plan result very carefully! 669 | 670 | 671 | `, 672 | }, 673 | { 674 | template: DefaultDestroyWarningTemplate, 675 | value: CommonTemplate{ 676 | Title: "title", 677 | Result: `This is a "result".`, 678 | }, 679 | resp: ` 680 | title 681 | 682 | This plan contains resource delete operation. Please check the plan result very carefully! 683 | 684 | 685 |
This is a "result".
686 | 
687 | 688 | `, 689 | }, 690 | { 691 | template: DefaultDestroyWarningTemplate, 692 | value: CommonTemplate{ 693 | Title: "title", 694 | Result: `This is a "result".`, 695 | UseRawOutput: true, 696 | }, 697 | resp: ` 698 | title 699 | 700 | This plan contains resource delete operation. Please check the plan result very carefully! 701 | 702 | 703 |
This is a "result".
704 | 
705 | 706 | `, 707 | }, 708 | { 709 | template: DefaultDestroyWarningTemplate, 710 | value: CommonTemplate{ 711 | Title: "title", 712 | Result: "", 713 | }, 714 | resp: ` 715 | title 716 | 717 | This plan contains resource delete operation. Please check the plan result very carefully! 718 | 719 | 720 | `, 721 | }, 722 | { 723 | template: "", 724 | value: CommonTemplate{ 725 | Title: "title", 726 | Result: "", 727 | }, 728 | resp: ` 729 | title 730 | 731 | This plan contains resource delete operation. Please check the plan result very carefully! 732 | 733 | 734 | `, 735 | }, 736 | { 737 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 738 | value: CommonTemplate{ 739 | Title: "a", 740 | Message: "b", 741 | Result: "c", 742 | Body: "d", 743 | }, 744 | resp: `a-b-c-d`, 745 | }, 746 | } 747 | for _, testCase := range testCases { 748 | template := NewDestroyWarningTemplate(testCase.template) 749 | template.SetValue(testCase.value) 750 | resp, err := template.Execute() 751 | if err != nil { 752 | t.Fatal(err) 753 | } 754 | if resp != testCase.resp { 755 | t.Errorf("got %q but want %q", resp, testCase.resp) 756 | } 757 | } 758 | } 759 | 760 | func TestApplyTemplateExecute(t *testing.T) { 761 | testCases := []struct { 762 | template string 763 | value CommonTemplate 764 | resp string 765 | }{ 766 | { 767 | template: DefaultApplyTemplate, 768 | value: CommonTemplate{}, 769 | resp: ` 770 | ## Apply result 771 | 772 | 773 | 774 | 775 | 776 |
Details (Click me) 777 | 778 |

779 | 
780 | `, 781 | }, 782 | { 783 | template: DefaultApplyTemplate, 784 | value: CommonTemplate{ 785 | Title: "title", 786 | Message: "message", 787 | Result: "result", 788 | Body: "body", 789 | }, 790 | resp: ` 791 | title 792 | 793 | message 794 | 795 | 796 |
result
797 | 
798 | 799 | 800 |
Details (Click me) 801 | 802 |
body
803 | 
804 | `, 805 | }, 806 | { 807 | template: DefaultApplyTemplate, 808 | value: CommonTemplate{ 809 | Title: "title", 810 | Message: "message", 811 | Result: "", 812 | Body: "body", 813 | }, 814 | resp: ` 815 | title 816 | 817 | message 818 | 819 | 820 | 821 |
Details (Click me) 822 | 823 |
body
824 | 
825 | `, 826 | }, 827 | { 828 | template: "", 829 | value: CommonTemplate{ 830 | Title: "title", 831 | Message: "message", 832 | Result: "", 833 | Body: "body", 834 | }, 835 | resp: ` 836 | title 837 | 838 | message 839 | 840 | 841 | 842 |
Details (Click me) 843 | 844 |
body
845 | 
846 | `, 847 | }, 848 | { 849 | template: "", 850 | value: CommonTemplate{ 851 | Title: "title", 852 | Message: "message", 853 | Result: "", 854 | Body: `This is a "body".`, 855 | }, 856 | resp: ` 857 | title 858 | 859 | message 860 | 861 | 862 | 863 |
Details (Click me) 864 | 865 |
This is a "body".
866 | 
867 | `, 868 | }, 869 | { 870 | template: "", 871 | value: CommonTemplate{ 872 | Title: "title", 873 | Message: "message", 874 | Result: "", 875 | Body: `This is a "body".`, 876 | UseRawOutput: true, 877 | }, 878 | resp: ` 879 | title 880 | 881 | message 882 | 883 | 884 | 885 |
Details (Click me) 886 | 887 |
This is a "body".
888 | 
889 | `, 890 | }, 891 | { 892 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`, 893 | value: CommonTemplate{ 894 | Title: "a", 895 | Message: "b", 896 | Result: "c", 897 | Body: "d", 898 | }, 899 | resp: `a-b-c-d`, 900 | }, 901 | } 902 | for _, testCase := range testCases { 903 | template := NewApplyTemplate(testCase.template) 904 | template.SetValue(testCase.value) 905 | resp, err := template.Execute() 906 | if err != nil { 907 | t.Error(err) 908 | } 909 | if resp != testCase.resp { 910 | t.Errorf("got %q but want %q", resp, testCase.resp) 911 | } 912 | } 913 | } 914 | 915 | func TestGetValue(t *testing.T) { 916 | testCases := []struct { 917 | template Template 918 | expected CommonTemplate 919 | }{ 920 | { 921 | template: NewDefaultTemplate(""), 922 | expected: CommonTemplate{}, 923 | }, 924 | { 925 | template: NewFmtTemplate(""), 926 | expected: CommonTemplate{}, 927 | }, 928 | { 929 | template: NewPlanTemplate(""), 930 | expected: CommonTemplate{}, 931 | }, 932 | { 933 | template: NewApplyTemplate(""), 934 | expected: CommonTemplate{}, 935 | }, 936 | } 937 | for _, testCase := range testCases { 938 | template := testCase.template 939 | value := template.GetValue() 940 | if !reflect.DeepEqual(value, testCase.expected) { 941 | t.Errorf("got %#v but want %#v", value, testCase.expected) 942 | } 943 | } 944 | } 945 | -------------------------------------------------------------------------------- /terraform/terraform.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | const ( 4 | // ExitPass is status code zero 5 | ExitPass int = iota 6 | 7 | // ExitFail is status code non-zero 8 | ExitFail 9 | ) 10 | -------------------------------------------------------------------------------- /terraform/terraform_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | --------------------------------------------------------------------------------