├── .github ├── DISCUSSION_TEMPLATE │ ├── deprecated-bug-report.yml │ ├── deprecated-feature-request.yml │ ├── deprecated-general.yml │ ├── deprecated-question-and-answer.yml │ └── deprecated-support-request.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ ├── feature-request.yml │ ├── general.yml │ ├── question.yml │ └── support-request.yml ├── pull_request_template.md └── workflows │ ├── actionlint.yaml │ ├── autofix.yaml │ ├── check-commit-signing.yaml │ ├── close-discussion.yaml │ ├── deploy_doc.yaml │ ├── release.yaml │ ├── test-main.yaml │ ├── test.yaml │ └── watch-star.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── aqua ├── aqua-checksums.json ├── aqua.yaml └── imports │ ├── actionlint.yaml │ ├── cmdx.yaml │ ├── cosign.yaml │ ├── ghalint.yaml │ ├── golangci-lint.yaml │ ├── goreleaser.yaml │ └── reviewdog.yaml ├── cmd ├── gen-jsonschema │ └── main.go └── tfcmt │ └── main.go ├── cmdx.yaml ├── example-use-raw-output.tfcmt.yaml ├── example-with-destroy-and-result-labels.tfcmt.yaml ├── example.tfcmt.yaml ├── examples └── getting-started │ ├── .gitignore │ ├── .terraform-version │ ├── README.md │ └── main.tf ├── go.mod ├── go.sum ├── json-schema └── tfcmt.json ├── pkg ├── apperr │ ├── error.go │ └── error_test.go ├── cli │ ├── app.go │ ├── apply.go │ ├── config.go │ ├── log.go │ ├── plan.go │ └── var.go ├── config │ ├── config.go │ └── config_test.go ├── controller │ ├── apply.go │ ├── controller.go │ └── plan.go ├── mask │ ├── parser.go │ └── writer.go ├── notifier │ ├── github │ │ ├── apply.go │ │ ├── client.go │ │ ├── client_test.go │ │ ├── comment.go │ │ ├── comment_test.go │ │ ├── commits.go │ │ ├── commits_test.go │ │ ├── github.go │ │ ├── github_test.go │ │ ├── label.go │ │ ├── notify.go │ │ ├── notify_test.go │ │ ├── plan.go │ │ └── user.go │ ├── localfile │ │ ├── apply.go │ │ ├── client.go │ │ ├── notify.go │ │ ├── output.go │ │ └── plan.go │ └── notifier.go ├── platform │ ├── ci.go │ └── google_cloud_build.go ├── template │ └── template.go └── terraform │ ├── parser.go │ ├── parser_test.go │ ├── template.go │ ├── terraform.go │ └── terraform_test.go └── renovate.json5 /.github/DISCUSSION_TEMPLATE/deprecated-bug-report.yml: -------------------------------------------------------------------------------- 1 | title: Deprecated (Bug Report) 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | # :warning: Please create an Issue instead 7 | 8 | [We don't accept new discussions anymore](https://github.com/suzuki-shunsuke/tfcmt/issues/1506). 9 | We keep this discussion category only for existing discussions. 10 | 11 | [Please create an issue](https://github.com/suzuki-shunsuke/tfcmt/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml). 12 | # At least one input item is required, so we add a dummy checkbox. 13 | - type: checkboxes 14 | attributes: 15 | label: . 16 | options: 17 | - label: .. 18 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/deprecated-feature-request.yml: -------------------------------------------------------------------------------- 1 | title: Deprecated (Feature Request) 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | # :warning: Please create an Issue instead 7 | 8 | [We don't accept new discussions anymore](https://github.com/suzuki-shunsuke/tfcmt/issues/1506). 9 | We keep this discussion category only for existing discussions. 10 | 11 | [Please create an issue](https://github.com/suzuki-shunsuke/tfcmt/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml). 12 | # At least one input item is required, so we add a dummy checkbox. 13 | - type: checkboxes 14 | attributes: 15 | label: . 16 | options: 17 | - label: .. 18 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/deprecated-general.yml: -------------------------------------------------------------------------------- 1 | title: Deprecated (General) 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | # :warning: Please create an Issue instead 7 | 8 | [We don't accept new discussions anymore](https://github.com/suzuki-shunsuke/tfcmt/issues/1506). 9 | We keep this discussion category only for existing discussions. 10 | 11 | [Please create an issue](https://github.com/suzuki-shunsuke/tfcmt/issues/new?assignees=&labels=no-fit-template&projects=&template=general.yml). 12 | # At least one input item is required, so we add a dummy checkbox. 13 | - type: checkboxes 14 | attributes: 15 | label: . 16 | options: 17 | - label: .. 18 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/deprecated-question-and-answer.yml: -------------------------------------------------------------------------------- 1 | title: Deprecated (Question And Answer) 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | # :warning: Please create an Issue instead 7 | 8 | [We don't accept new discussions anymore](https://github.com/suzuki-shunsuke/tfcmt/issues/1506). 9 | We keep this discussion category only for existing discussions. 10 | 11 | [Please create an issue](https://github.com/suzuki-shunsuke/tfcmt/issues/new?assignees=&labels=question&projects=&template=question.yml). 12 | # At least one input item is required, so we add a dummy checkbox. 13 | - type: checkboxes 14 | attributes: 15 | label: . 16 | options: 17 | - label: .. 18 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/deprecated-support-request.yml: -------------------------------------------------------------------------------- 1 | title: Deprecated (Support Request) 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | # :warning: Please create an Issue instead 7 | 8 | [We don't accept new discussions anymore](https://github.com/suzuki-shunsuke/tfcmt/issues/1506). 9 | We keep this discussion category only for existing discussions. 10 | 11 | [Please create an issue](https://github.com/suzuki-shunsuke/tfcmt/issues/new?assignees=&labels=support-request&projects=&template=support-request.yml). 12 | 13 | # At least one input item is required, so we add a dummy checkbox. 14 | - type: checkboxes 15 | attributes: 16 | label: . 17 | options: 18 | - label: .. 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 2 | github: 3 | - suzuki-shunsuke 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: | 3 | Please report the bug of tfcmt. 4 | If you're not sure if it's a bug or not, please use the template `Support Request` instead. 5 | labels: 6 | - bug 7 | body: 8 | - type: textarea 9 | id: tfcmt-version 10 | attributes: 11 | label: tfcmt version 12 | description: Please use the latest version. 13 | value: | 14 | ```console 15 | $ tfcmt -v 16 | 17 | ``` 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: environment 22 | attributes: 23 | label: Environment 24 | description: | 25 | * OS (Windows, Linux, macOS, etc) 26 | * CPU Architecture (amd64, arm64, etc) 27 | value: | 28 | * OS: 29 | * CPU Architecture: 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: overview 34 | attributes: 35 | label: Overview 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: how-to-reproduce 40 | attributes: 41 | label: How to reproduce 42 | description: | 43 | Please see [the guide](https://github.com/suzuki-shunsuke/oss-contribution-guide#write-good-how-to-reproduce) too. 44 | tfcmt.yaml should be not partial but complete configuration. 45 | Please remove unneeded configuration to reproduce the issue. 46 | value: | 47 | tfcmt.yaml 48 | 49 | ```yaml 50 | 51 | ``` 52 | 53 | Terraform Configuration 54 | 55 | ```tf 56 | 57 | ``` 58 | 59 | Executed command and output 60 | 61 | ```console 62 | $ 63 | ``` 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: expected-behaviour 68 | attributes: 69 | label: Expected behaviour 70 | validations: 71 | required: true 72 | - type: textarea 73 | id: actual-behaviour 74 | attributes: 75 | label: Actual behaviour 76 | validations: 77 | required: true 78 | - type: textarea 79 | id: note 80 | attributes: 81 | label: Note 82 | validations: 83 | required: false 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: If you want to add a feature. 3 | labels: 4 | - enhancement 5 | body: 6 | - type: textarea 7 | id: feature-overview 8 | attributes: 9 | label: Feature Overview 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: why 14 | attributes: 15 | label: Why is the feature needed? 16 | description: Please explain the problem you want to solve. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: example-code 21 | attributes: 22 | label: Example Code 23 | description: | 24 | Please explain the feature with code. For example, if you want a new subcommand, please explain the usage of the subcommand. 25 | value: | 26 | ```console 27 | $ 28 | ``` 29 | 30 | Configuration 31 | 32 | ```yaml 33 | 34 | ``` 35 | validations: 36 | required: false 37 | - type: textarea 38 | id: note 39 | attributes: 40 | label: Note 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.yml: -------------------------------------------------------------------------------- 1 | name: General 2 | description: Please use this template only when other templates don't meet your requirement 3 | labels: 4 | - no-fit-template 5 | body: 6 | - type: textarea 7 | id: what 8 | attributes: 9 | label: What 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: why 14 | attributes: 15 | label: Why 16 | description: Please explain the background and the reason of this issue 17 | validations: 18 | required: false 19 | - type: textarea 20 | id: note 21 | attributes: 22 | label: Note 23 | description: Please write any additional information 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: | 3 | Please use this template when you have any questions. 4 | Please don't hesitate to ask any questions via Issues. 5 | labels: 6 | - question 7 | body: 8 | - type: textarea 9 | id: question 10 | attributes: 11 | label: Question 12 | description: | 13 | Please explain your question in details. 14 | If example code is useful to explain your question, please write it. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: background 19 | attributes: 20 | label: Background 21 | description: Please explain the background and why you have the question 22 | validations: 23 | required: false 24 | - type: textarea 25 | id: note 26 | attributes: 27 | label: Note 28 | description: Please write any additional information 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.yml: -------------------------------------------------------------------------------- 1 | name: Support Request 2 | description: | 3 | Please use this template when you face any problem (not bug) and need our help. 4 | If you're not sure if it's a bug or not, please use this template. 5 | labels: 6 | - support-request 7 | body: 8 | - type: textarea 9 | id: tfcmt-version 10 | attributes: 11 | label: tfcmt version 12 | description: Please use the latest version. 13 | value: | 14 | ```console 15 | $ tfcmt -v 16 | 17 | ``` 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: environment 22 | attributes: 23 | label: Environment 24 | description: | 25 | * OS (Windows, Linux, macOS, etc) 26 | * CPU Architecture (amd64, arm64, etc) 27 | value: | 28 | * OS: 29 | * CPU Architecture: 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: overview 34 | attributes: 35 | label: Overview 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: how-to-reproduce 40 | attributes: 41 | label: How to reproduce 42 | description: | 43 | Please see [the guide](https://github.com/suzuki-shunsuke/oss-contribution-guide#write-good-how-to-reproduce) too. 44 | tfcmt.yaml should be not partial but complete configuration. 45 | Please remove unneeded configuration to reproduce the issue. 46 | value: | 47 | tfcmt.yaml 48 | 49 | ```yaml 50 | 51 | ``` 52 | 53 | Terraform Configuration 54 | 55 | ```tf 56 | 57 | ``` 58 | 59 | Executed command and output 60 | 61 | ```console 62 | $ 63 | ``` 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: expected-behaviour 68 | attributes: 69 | label: Expected behaviour 70 | validations: 71 | required: true 72 | - type: textarea 73 | id: actual-behaviour 74 | attributes: 75 | label: Actual behaviour 76 | validations: 77 | required: true 78 | - type: textarea 79 | id: note 80 | attributes: 81 | label: Note 82 | validations: 83 | required: false 84 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Check List 2 | 3 | 4 | 5 | - [ ] Read [OSS Contribution Guide](https://github.com/suzuki-shunsuke/oss-contribution-guide/blob/main/README.md) 6 | - [ ] [Write a GitHub Issue before creating a Pull Request](https://github.com/suzuki-shunsuke/oss-contribution-guide/blob/main/README.md#create-an-issue-before-creating-a-pull-request) 7 | - Link to the issue: 8 | - [ ] [All commits are signed](https://github.com/suzuki-shunsuke/oss-contribution-guide/blob/main/docs/commit-signing.md) 9 | - This repository enables `Require signed commits`, so all commits must be signed 10 | - [ ] [Avoid force push](https://github.com/suzuki-shunsuke/oss-contribution-guide?tab=readme-ov-file#dont-do-force-pushes-after-opening-pull-requests) 11 | - [ ] Do only one thing in one Pull Request 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: actionlint 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | actionlint: 7 | runs-on: ubuntu-24.04 8 | if: failure() 9 | timeout-minutes: 10 10 | permissions: {} 11 | needs: 12 | - main 13 | steps: 14 | - run: exit 1 15 | main: 16 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@dbe6151b36d408b24ca5c41a34291b2b6d1bff76 # v2.0.1 17 | permissions: 18 | pull-requests: write 19 | contents: read 20 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: autofix.ci 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | autofix: 7 | # This job is used for main branch's branch protection rule's status check. 8 | # If all dependent jobs succeed or are skipped this job succeeds. 9 | timeout-minutes: 10 10 | runs-on: ubuntu-24.04 11 | permissions: {} 12 | if: failure() 13 | steps: 14 | - run: exit 1 15 | needs: 16 | - fix 17 | 18 | fix: 19 | runs-on: ubuntu-24.04 20 | permissions: {} 21 | timeout-minutes: 15 22 | steps: 23 | - uses: suzuki-shunsuke/go-autofix-action@0bb6ca06b2f0d2d23c200bbbaa650897824a6cb9 # v0.1.7 24 | with: 25 | aqua_version: v2.51.2 26 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-signing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check if all commits are signed 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target 8 | cancel-in-progress: true 9 | jobs: 10 | check-commit-signing: 11 | uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | -------------------------------------------------------------------------------- /.github/workflows/close-discussion.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Close a new Discussion 3 | on: 4 | discussion: 5 | types: [created] 6 | jobs: 7 | close-new-discussion: 8 | runs-on: ubuntu-24.04 9 | timeout-minutes: 15 10 | permissions: 11 | discussions: write 12 | steps: 13 | - uses: suzuki-shunsuke/close-discussion-action@a9a5728293ab3c621c5de3cc1ba4aace06a5f027 # v0.1.0 14 | with: 15 | id: ${{github.event.discussion.node_id}} 16 | message: | 17 | This discussion is closed because we stopped using GitHub Discussions. 18 | We don't accept new discussions anymore. 19 | [Please create an issue instead.](https://github.com/suzuki-shunsuke/tfcmt/issues/new/choose) 20 | For details, please see https://github.com/suzuki-shunsuke/tfcmt/issues/1506 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy_doc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deploy GitHub Pages 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "*/30 * * * *" 7 | permissions: {} 8 | jobs: 9 | deploy-doc: 10 | timeout-minutes: 15 11 | runs-on: ubuntu-24.04 12 | permissions: 13 | contents: write 14 | issues: write 15 | steps: 16 | - uses: suzuki-shunsuke/release-doc-action@cd4982113b753b9d117d3ee019e0c1a72b56a491 # v0.0.4 17 | with: 18 | repository: suzuki-shunsuke/tfcmt-docs 19 | issue_number: 1724 20 | publish_dir: build 21 | destination_dir: docs 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: [v*] 6 | jobs: 7 | release: 8 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@4602cd60ba10f19df17a074d76c518a9b8b979bb # v4.0.1 9 | with: 10 | go-version-file: go.mod 11 | aqua_version: v2.51.2 12 | permissions: 13 | contents: write 14 | id-token: write 15 | actions: read 16 | attestations: write 17 | -------------------------------------------------------------------------------- /.github/workflows/test-main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test-main 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | test-main: 12 | timeout-minutes: 30 13 | runs-on: ubuntu-latest 14 | permissions: {} 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | with: 19 | persist-credentials: false 20 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 21 | with: 22 | go-version-file: go.mod 23 | cache: true 24 | - uses: aquaproj/aqua-installer@9ebf656952a20c45a5d66606f083ff34f58b8ce0 # v4.0.0 25 | with: 26 | aqua_version: v2.51.2 27 | env: 28 | AQUA_GITHUB_TOKEN: ${{github.token}} 29 | - run: golangci-lint run 30 | - run: go test -v ./... -race -covermode=atomic 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | test: 7 | uses: suzuki-shunsuke/go-test-full-workflow/.github/workflows/test.yaml@05399afd417ae28382877ebe5bf7c9288b023df7 # v3.2.1 8 | with: 9 | aqua_version: v2.51.2 10 | golangci-lint-timeout: 120s 11 | permissions: 12 | pull-requests: write 13 | contents: read 14 | status-check: 15 | runs-on: ubuntu-24.04 16 | if: failure() 17 | timeout-minutes: 10 18 | permissions: {} 19 | needs: 20 | - test 21 | steps: 22 | - run: exit 1 23 | -------------------------------------------------------------------------------- /.github/workflows/watch-star.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: watch-star 3 | on: 4 | watch: 5 | types: 6 | - started 7 | jobs: 8 | watch-star: 9 | timeout-minutes: 30 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | steps: 14 | - uses: suzuki-shunsuke/watch-star-action@2b3d259ce2ea06d53270dfe33a66d5642c8010ca # v0.1.1 15 | with: 16 | number: 918 # https://github.com/suzuki-shunsuke/tfcmt/issues/918 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go 2 | 3 | .terraform 4 | *.backup 5 | 6 | ### Go ### 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, build with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | 21 | # End of https://www.gitignore.io/api/go 22 | 23 | vendor 24 | dist 25 | 26 | .DS_Store 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - dupl 7 | - dupword 8 | - err113 9 | - exhaustruct 10 | - funlen 11 | - gocognit 12 | - gocyclo 13 | - godot 14 | - godox 15 | - ireturn 16 | - lll 17 | - musttag 18 | - nestif 19 | - nlreturn 20 | - tagalign 21 | - tagliatelle 22 | - testpackage 23 | - varnamelen 24 | - wrapcheck 25 | - wsl 26 | exclusions: 27 | generated: lax 28 | presets: 29 | - comments 30 | - common-false-positives 31 | - legacy 32 | - std-error-handling 33 | paths: 34 | - third_party$ 35 | - builtin$ 36 | - examples$ 37 | formatters: 38 | enable: 39 | - gci 40 | - gofmt 41 | - gofumpt 42 | - goimports 43 | exclusions: 44 | generated: lax 45 | paths: 46 | - third_party$ 47 | - builtin$ 48 | - examples$ 49 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: tfcmt 3 | 4 | env: 5 | - GO111MODULE=on 6 | 7 | before: 8 | hooks: 9 | - go mod tidy 10 | 11 | builds: 12 | - main: ./cmd/tfcmt 13 | binary: tfcmt 14 | env: 15 | - CGO_ENABLED=0 16 | goos: 17 | - windows 18 | - darwin 19 | - linux 20 | goarch: 21 | - amd64 22 | - arm64 23 | 24 | signs: 25 | - cmd: cosign 26 | artifacts: checksum 27 | signature: ${artifact}.sig 28 | certificate: ${artifact}.pem 29 | output: true 30 | args: 31 | - sign-blob 32 | - "-y" 33 | - --output-signature 34 | - ${signature} 35 | - --output-certificate 36 | - ${certificate} 37 | - --oidc-provider 38 | - github 39 | - ${artifact} 40 | 41 | archives: 42 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 43 | 44 | release: 45 | prerelease: true # we update release note manually before releasing 46 | header: | 47 | [Pull Requests](https://github.com/suzuki-shunsuke/tfcmt/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/tfcmt/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/tfcmt/compare/{{.PreviousTag}}...{{.Tag}} 48 | 49 | brews: 50 | - 51 | # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the 52 | # same kind. We will probably unify this in the next major version like it is done with scoop. 53 | 54 | # GitHub/GitLab repository to push the formula to 55 | repository: 56 | owner: suzuki-shunsuke 57 | name: homebrew-tfcmt 58 | # The project name and current git tag are used in the format string. 59 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 60 | # Your app's homepage. 61 | # Default is empty. 62 | homepage: https://github.com/suzuki-shunsuke/tfcmt 63 | 64 | # Template of your app's description. 65 | # Default is empty. 66 | description: | 67 | Fork of mercari/tfnotify. tfcmt enhances tfnotify in many ways, including Terraform >= v0.15 support and advanced formatting options 68 | license: MIT 69 | 70 | # Setting this will prevent goreleaser to actually try to commit the updated 71 | # formula - instead, the formula file will be stored on the dist folder only, 72 | # leaving the responsibility of publishing it to the user. 73 | # If set to auto, the release will not be uploaded to the homebrew tap 74 | # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 75 | # Default is false. 76 | skip_upload: true 77 | 78 | # So you can `brew test` your formula. 79 | # Default is empty. 80 | test: | 81 | system "#{bin}/tfcmt --version" 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please read the following document. 4 | 5 | - https://github.com/suzuki-shunsuke/oss-contribution-guide 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Shunsuke Suzuki 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | This project is a fork of [mercari/tfnotify](https://github.com/mercari/tfnotify). 26 | 27 | Copyright (c) 2018 Mercari, Inc. 28 | 29 | MIT License 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining 32 | a copy of this software and associated documentation files (the 33 | "Software"), to deal in the Software without restriction, including 34 | without limitation the rights to use, copy, modify, merge, publish, 35 | distribute, sublicense, and/or sell copies of the Software, and to 36 | permit persons to whom the Software is furnished to do so, subject to 37 | the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be 40 | included in all copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 43 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 44 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 45 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 46 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 47 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 48 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tfcmt 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/suzuki-shunsuke/tfcmt)](https://goreportcard.com/report/github.com/suzuki-shunsuke/tfcmt) 4 | [![GitHub last commit](https://img.shields.io/github/last-commit/suzuki-shunsuke/tfcmt.svg)](https://github.com/suzuki-shunsuke/tfcmt) 5 | [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/suzuki-shunsuke/tfcmt/master/LICENSE) 6 | 7 | Fork of [mercari/tfnotify](https://github.com/mercari/tfnotify), enhancing tfnotify in many ways including Terraform >= v0.15 support and advanced formatting options. 8 | 9 | ## Document 10 | 11 | - https://suzuki-shunsuke.github.io/tfcmt/ 12 | - https://github.com/suzuki-shunsuke/tfcmt-docs 13 | 14 | > tfcmt only supports notifications for GitHub. For GitLab, try [tfcmt-gitlab](https://github.com/hirosassa/tfcmt-gitlab) (fork of tfcmt). 15 | 16 | ## Who uses tfcmt? 17 | 18 | > [!NOTE] 19 | > If you want to add your company or organization to the list, please send a pull request! 20 | 21 | - Recruit Co., Ltd. - [StudySapuri](https://brand.studysapuri.jp/) and [Quipper](https://www.quipper.com/) product team 22 | - [Cybozu Inc / Engineering Productivity Team](https://cybozu.co.jp/) 23 | - [READYFOR INC. / Platform Team](https://corp.readyfor.jp/) 24 | - [CADDi Inc.](https://caddi.com/) 25 | - [ZOZO Inc.](https://corp.zozo.com/) 26 | - [SAKURA internet Inc.](https://www.sakura.ad.jp/) 27 | - [Unipos Inc.](https://www.unipos.co.jp/) 28 | - [paild, Inc.](https://www.paild.co.jp/) 29 | - [HMCTS](https://www.gov.uk/government/organisations/hm-courts-and-tribunals-service) 30 | - [LayerX Inc.](https://layerx.co.jp/) ([ref](https://tech.layerx.co.jp/entry/2025/03/24/113651)) 31 | - [Section-9](https://sec9.co.jp/) 32 | - [H2O Retailing Corp.](https://www.h2o-retailing.co.jp/) 33 | 34 | ## License 35 | 36 | ### License of original code 37 | 38 | This is a fork of [mercari/tfnotify v0.7.0](https://github.com/mercari/tfnotify/releases/tag/v0.7.0) which was [licensed under MIT License terms](https://github.com/mercari/tfnotify/tree/57494ec80c926a12967c8634226ef60e834b3dfd#license). 39 | 40 | > Copyright 2018 Mercari, Inc. 41 | > 42 | > Licensed under the MIT License. 43 | 44 | ### License of code which we wrote 45 | 46 | MIT 47 | -------------------------------------------------------------------------------- /aqua/aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-amd64.tar.gz", 5 | "checksum": "E091107C4CA7E283902343BA3A09D14FB56B86E071EFFD461CE9D67193EF580E", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-arm64.tar.gz", 10 | "checksum": "90783FA092A0F64A4F7B7D419F3DA1F53207E300261773BABE962957240E9EA6", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-amd64.tar.gz", 15 | "checksum": "E55E0EB515936C0FBD178BCE504798A9BD2F0B191E5E357768B18FD5415EE541", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-arm64.tar.gz", 20 | "checksum": "582EB73880F4408D7FB89F12B502D577BD7B0B63D8C681DA92BB6B9D934D4363", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-amd64.zip", 25 | "checksum": "FD7298019C76CF414AB083491F87F6C0A3E537ED6D727D6FF9135E503D6F9C32", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-arm64.zip", 30 | "checksum": "0DC38C44D8270A0ED3267BCD3FBDCD8384761D04D0FD2D53B63FC502F0F39722", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Darwin_all.tar.gz", 35 | "checksum": "82953B65C4B64E73B1077827663D97BF8E32592B4FC2CDB55C738BD484260A47", 36 | "algorithm": "sha256" 37 | }, 38 | { 39 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_arm64.tar.gz", 40 | "checksum": "574E83F5F0FC97803FF734C9342F8FD446D77E5E7CCAC53DEBF09B4A8DBDED80", 41 | "algorithm": "sha256" 42 | }, 43 | { 44 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_x86_64.tar.gz", 45 | "checksum": "A066FCD713684ABED0D750D7559F1A5D794FA2FAA8E8F1AD2EECEC8C373668A7", 46 | "algorithm": "sha256" 47 | }, 48 | { 49 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_arm64.zip", 50 | "checksum": "EA19CAE5A322FEC6794252D3E9FE77D43201CC831D939964730E556BF3C1CC2C", 51 | "algorithm": "sha256" 52 | }, 53 | { 54 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_x86_64.zip", 55 | "checksum": "F56E85F8FD52875102DFC2B01DC07FC174486CAEBBAC7E3AA9F29B4F0057D495", 56 | "algorithm": "sha256" 57 | }, 58 | { 59 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_arm64.tar.gz", 60 | "checksum": "A7FBF41913CE5B6F1872D10C136139B7A849190F4F1F0DC1ED4BF74C636F22A2", 61 | "algorithm": "sha256" 62 | }, 63 | { 64 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_x86_64.tar.gz", 65 | "checksum": "056DD0F43ECCB8651FB976B43AA91A1D34B2A0C3934F216997774A7CBC1F7EB1", 66 | "algorithm": "sha256" 67 | }, 68 | { 69 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_arm64.tar.gz", 70 | "checksum": "BD0C4045B8F367F1CA6C0E7CFD80189CCD2A8CEAA22034ECBAD4AF0ACB3A3B82", 71 | "algorithm": "sha256" 72 | }, 73 | { 74 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_x86_64.tar.gz", 75 | "checksum": "2C634DBC00BD4A86E4D4C47029D2AF9185FAB06643A9DF0AE10E7C4D644781B6", 76 | "algorithm": "sha256" 77 | }, 78 | { 79 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_arm64.tar.gz", 80 | "checksum": "2DFD2C151AFF8B7D2DFDFC44FB47706667806AEA92F4F8238932BB89A0461D4A", 81 | "algorithm": "sha256" 82 | }, 83 | { 84 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_x86_64.tar.gz", 85 | "checksum": "068726CA98BBEB5E47378AB0B630133741E17BA1FEB5654A24EC5E604446EDEF", 86 | "algorithm": "sha256" 87 | }, 88 | { 89 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_darwin_amd64.tar.gz", 90 | "checksum": "28E5DE5A05FC558474F638323D736D822FFF183D2D492F0AECB2B73CC44584F5", 91 | "algorithm": "sha256" 92 | }, 93 | { 94 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_darwin_arm64.tar.gz", 95 | "checksum": "2693315B9093AEACB4EBD91A993FEA54FC215057BF0DA2659056B4BC033873DB", 96 | "algorithm": "sha256" 97 | }, 98 | { 99 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz", 100 | "checksum": "023070A287CD8CCCD71515FEDC843F1985BF96C436B7EFFAECCE67290E7E0757", 101 | "algorithm": "sha256" 102 | }, 103 | { 104 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_linux_arm64.tar.gz", 105 | "checksum": "401942F9C24ED71E4FE71B76C7D638F66D8633575C4016EFD2977CE7C28317D0", 106 | "algorithm": "sha256" 107 | }, 108 | { 109 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_windows_amd64.zip", 110 | "checksum": "7F12F1801BCA3D480D67AAF7774F4C2A6359A3CA8EEBE382C95C10C9704AA731", 111 | "algorithm": "sha256" 112 | }, 113 | { 114 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_windows_arm64.zip", 115 | "checksum": "76E9514CFAC18E5677AA04F3A89873C981F16A2F2353BB97372A86CD09B1F5A8", 116 | "algorithm": "sha256" 117 | }, 118 | { 119 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-amd64", 120 | "checksum": "D61CC50F6F32C2B63CB81CD8A935E5DD1BE8520D639242031A1102092BEE44D4", 121 | "algorithm": "sha256" 122 | }, 123 | { 124 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-arm64", 125 | "checksum": "780DA3654D9601367B0D54686AC65CB9716578610CABE292D725C7008DE4DB85", 126 | "algorithm": "sha256" 127 | }, 128 | { 129 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-amd64", 130 | "checksum": "1F6C194DD0891EB345B436BB71FF9F996768355F5E0CE02DDE88567029AC2188", 131 | "algorithm": "sha256" 132 | }, 133 | { 134 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-arm64", 135 | "checksum": "080A998F9878F22DAFDB9AD54D5B2E2B8E7A38C53527250F9D89A6763A28D545", 136 | "algorithm": "sha256" 137 | }, 138 | { 139 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-windows-amd64.exe", 140 | "checksum": "2345667CBCF60767C1A6F678755CBB7465367761084E9D2CBB59AE0CC1A94437", 141 | "algorithm": "sha256" 142 | }, 143 | { 144 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_amd64.tar.gz", 145 | "checksum": "C7533B3D95241A4E7DE61C7240892DE19DBAAFD26EF44AD8020BC5000E24594D", 146 | "algorithm": "sha256" 147 | }, 148 | { 149 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_arm64.tar.gz", 150 | "checksum": "141AD7EB4E3410864FD1D5D3E2920BC6C6163CE5B663A872283B0F58CFEA331F", 151 | "algorithm": "sha256" 152 | }, 153 | { 154 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_amd64.tar.gz", 155 | "checksum": "8DC530324176C3703C97E4FE355AF7ED82D4E6341219063856FD0A1594C6CC4B", 156 | "algorithm": "sha256" 157 | }, 158 | { 159 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_arm64.tar.gz", 160 | "checksum": "8AA707E58144DD29CBC5A02EEE62842A0F54964F7CF6118B513A2FEAE1811C74", 161 | "algorithm": "sha256" 162 | }, 163 | { 164 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_amd64.zip", 165 | "checksum": "26BF4BAA495AF54456BACF5A16416AD5B6C756F5661ABD56E73C08CBCEAD65FE", 166 | "algorithm": "sha256" 167 | }, 168 | { 169 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_arm64.zip", 170 | "checksum": "4BB8F4F65EDCE1D3AAE86168075B2E2CD3377E9A72E5D5F51EE097BFABE5DEE2", 171 | "algorithm": "sha256" 172 | }, 173 | { 174 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_darwin_amd64.tar.gz", 175 | "checksum": "AD0D5893D9A4B38F6F8D35DC003A2BEEA63FA2EA48FF91DDD301773AB5711B21", 176 | "algorithm": "sha256" 177 | }, 178 | { 179 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_darwin_arm64.tar.gz", 180 | "checksum": "70DC52A85C207FCB40F1CDBA5F097CCEF7564C5D217E48C60541743CFC15239B", 181 | "algorithm": "sha256" 182 | }, 183 | { 184 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_linux_amd64.tar.gz", 185 | "checksum": "6A8EAA2568FA1FED64D63CCDD4538C3E329B873A7D78F49D207E9FA2FA6A65BB", 186 | "algorithm": "sha256" 187 | }, 188 | { 189 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_linux_arm64.tar.gz", 190 | "checksum": "1417F9B7CE201C69A959BD5E7DA56BFE4128D8C5333EEDB94038B731CA30A12C", 191 | "algorithm": "sha256" 192 | }, 193 | { 194 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_windows_amd64.zip", 195 | "checksum": "3C1EB280BDE931AD793A732B32C802D54C6DF418502A55393D9DC8573282259E", 196 | "algorithm": "sha256" 197 | }, 198 | { 199 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_windows_arm64.zip", 200 | "checksum": "0802008325A617634398E0D73BB240F75551B4859769D045E6A91ECC9D85B1AE", 201 | "algorithm": "sha256" 202 | }, 203 | { 204 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.374.0/registry.yaml", 205 | "checksum": "619BDA08E2B9259FEFE5DF052EBB1FEDABE96C58CCC41444938FFA3F3EEB5828456A80CF1859695E37B4F74CD1A45C5AC516C82DFE5752D010AADE352EB222E0", 206 | "algorithm": "sha512" 207 | } 208 | ] 209 | } 210 | -------------------------------------------------------------------------------- /aqua/aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # aqua - Declarative CLI Version Manager 3 | # https://aquaproj.github.io/ 4 | checksum: 5 | # Enable Checksum Verification 6 | # https://aquaproj.github.io/docs/tutorial-extras/checksum/ 7 | enabled: true 8 | require_checksum: true 9 | registries: 10 | - type: standard 11 | ref: v4.374.0 # renovate: depName=aquaproj/aqua-registry 12 | import_dir: imports 13 | -------------------------------------------------------------------------------- /aqua/imports/actionlint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: rhysd/actionlint@v1.7.7 3 | -------------------------------------------------------------------------------- /aqua/imports/cmdx.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/cmdx@v2.0.1 3 | -------------------------------------------------------------------------------- /aqua/imports/cosign.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: sigstore/cosign@v2.5.0 3 | -------------------------------------------------------------------------------- /aqua/imports/ghalint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/ghalint@v1.4.1 3 | -------------------------------------------------------------------------------- /aqua/imports/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: golangci/golangci-lint@v2.1.6 3 | -------------------------------------------------------------------------------- /aqua/imports/goreleaser.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: goreleaser/goreleaser@v2.9.0 3 | -------------------------------------------------------------------------------- /aqua/imports/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: reviewdog/reviewdog@v0.20.3 3 | -------------------------------------------------------------------------------- /cmd/gen-jsonschema/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/suzuki-shunsuke/gen-go-jsonschema/jsonschema" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 9 | ) 10 | 11 | func main() { 12 | if err := core(); err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | 17 | func core() error { 18 | if err := jsonschema.Write(&config.Config{}, "json-schema/tfcmt.json"); err != nil { 19 | return fmt.Errorf("create or update a JSON Schema: %w", err) 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/tfcmt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/apperr" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/cli" 10 | ) 11 | 12 | var ( 13 | version = "" 14 | commit = "" //nolint:gochecknoglobals 15 | date = "" //nolint:gochecknoglobals 16 | ) 17 | 18 | func main() { 19 | os.Exit(core()) 20 | } 21 | 22 | func core() int { 23 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 24 | defer stop() 25 | app := cli.New(&cli.LDFlags{ 26 | Version: version, 27 | Commit: commit, 28 | Date: date, 29 | }) 30 | return apperr.HandleExit(app.Run(ctx, os.Args)) 31 | } 32 | -------------------------------------------------------------------------------- /cmdx.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # cmdx - task runner 3 | # https://github.com/suzuki-shunsuke/cmdx 4 | tasks: 5 | - name: test 6 | short: t 7 | description: test 8 | usage: test 9 | script: go test ./... -race -covermode=atomic 10 | - name: vet 11 | short: v 12 | description: go vet 13 | usage: go vet 14 | script: go vet ./... 15 | - name: lint 16 | short: l 17 | description: lint the go code 18 | usage: lint the go code 19 | script: golangci-lint run 20 | - name: install 21 | short: i 22 | description: go install 23 | usage: go install 24 | script: | 25 | sha="" 26 | if git diff --quiet; then 27 | sha=$(git rev-parse HEAD) 28 | fi 29 | go install \ 30 | -ldflags "-X main.version=v1.0.0-local -X main.commit=$sha -X main.date=$(date +"%Y-%m-%dT%H:%M:%SZ%:z" | tr -d '+')" \ 31 | ./cmd/tfcmt 32 | - name: js 33 | description: Generate JSON Schema 34 | usage: Generate JSON Schema 35 | script: "go run ./cmd/gen-jsonschema" 36 | -------------------------------------------------------------------------------- /example-use-raw-output.tfcmt.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | owner: "suzuki-shunsuke" 3 | repo: "tfcmt" 4 | terraform: 5 | use_raw_output: true 6 | plan: 7 | template: | 8 | ## Plan Result 9 | {{if .Result}} 10 |
{{ .Result }}
11 |       
12 | {{end}} 13 |
Details (Click me) 14 | 15 |
{{ .CombinedOutput }}
16 |       
17 | -------------------------------------------------------------------------------- /example-with-destroy-and-result-labels.tfcmt.yaml: -------------------------------------------------------------------------------- 1 | terraform: 2 | plan: 3 | template: | 4 | {{if .HasDestroy}} 5 | ## :warning: WARNING: Resource Deletion will happen 6 | 7 | This plan contains **resource deletion**. Please check the plan result very carefully! 8 | {{else}} 9 | ## Plan Result 10 | {{if .Result}} 11 |
{{ .Result }}
12 |       
13 | {{end}} 14 |
Details (Click me) 15 | 16 |
{{ .CombinedOutput }}
17 |       
18 | {{end}} 19 | when_add_or_update_only: 20 | label: "add-or-update" 21 | when_destroy: 22 | label: "destroy" 23 | when_no_changes: 24 | label: "no-changes" 25 | when_plan_error: 26 | label: "error" 27 | -------------------------------------------------------------------------------- /example.tfcmt.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | owner: 3 | - type: envsubst 4 | value: suzuki-shunsuke 5 | repo: 6 | - type: envsubst 7 | value: tfcmt 8 | terraform: 9 | plan: 10 | template: | 11 | ## Plan Result 12 | {{if .Result}} 13 |
{{ .Result }}
14 |       
15 | {{end}} 16 |
Details (Click me) 17 | 18 |
{{ .CombinedOutput }}
19 |       
20 | -------------------------------------------------------------------------------- /examples/getting-started/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | terraform.tfstate* 3 | .terraform.lock.hcl 4 | -------------------------------------------------------------------------------- /examples/getting-started/.terraform-version: -------------------------------------------------------------------------------- 1 | 1.0.7 2 | -------------------------------------------------------------------------------- /examples/getting-started/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | https://suzuki-shunsuke.github.io/tfcmt/getting-started 4 | -------------------------------------------------------------------------------- /examples/getting-started/main.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "foo" {} 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suzuki-shunsuke/tfcmt/v4 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.3.0 7 | github.com/google/go-cmp v0.7.0 8 | github.com/google/go-github/v72 v72.0.0 9 | github.com/mattn/go-colorable v0.1.14 10 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064 11 | github.com/sirupsen/logrus v1.9.3 12 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 13 | github.com/suzuki-shunsuke/github-comment-metadata v0.1.0 14 | github.com/suzuki-shunsuke/go-ci-env/v3 v3.1.0 15 | github.com/suzuki-shunsuke/go-findconfig v1.2.0 16 | github.com/suzuki-shunsuke/logrus-error v0.1.4 17 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 18 | github.com/urfave/cli/v3 v3.3.3 19 | golang.org/x/oauth2 v0.30.0 20 | gopkg.in/yaml.v3 v3.0.1 21 | ) 22 | 23 | require ( 24 | dario.cat/mergo v1.0.1 // indirect 25 | github.com/Masterminds/goutils v1.1.1 // indirect 26 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 27 | github.com/bahlo/generic-list-go v0.2.0 // indirect 28 | github.com/buger/jsonparser v1.1.1 // indirect 29 | github.com/google/go-querystring v1.1.0 // indirect 30 | github.com/google/uuid v1.6.0 // indirect 31 | github.com/huandu/xstrings v1.5.0 // indirect 32 | github.com/invopop/jsonschema v0.12.0 // indirect 33 | github.com/mailru/easyjson v0.7.7 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/mitchellh/copystructure v1.2.0 // indirect 36 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 37 | github.com/shopspring/decimal v1.4.0 // indirect 38 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect 39 | github.com/spf13/cast v1.7.0 // indirect 40 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 41 | golang.org/x/crypto v0.37.0 // indirect 42 | golang.org/x/net v0.39.0 // indirect 43 | golang.org/x/sys v0.32.0 // indirect 44 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= 6 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 8 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 9 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 10 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 11 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 12 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 17 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 18 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 20 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 21 | github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= 22 | github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 23 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 24 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 25 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 26 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 27 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 28 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 29 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 30 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 31 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 32 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 37 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 38 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 39 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 40 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 41 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 42 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 43 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 44 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 45 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 49 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 50 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 51 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 52 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064 h1:RCQBSFx5JrsbHltqTtJ+kN3U0Y3a/N/GlVdmRSoxzyE= 53 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 54 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= 55 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= 56 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 57 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 58 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 59 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 60 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 61 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 62 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 63 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 64 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 h1:g7askc+nskCkKRWTVOdsAT8nMhwiaVT6Dmlnh6uvITM= 65 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0/go.mod h1:yFO7h5wwFejxi6jbtazqmk7b/JSBxHcit8DGwb1bhg0= 66 | github.com/suzuki-shunsuke/github-comment-metadata v0.1.0 h1:89uGvBINoWZ4p2dGj7S695bm/L1H175eMp/D47ltSPs= 67 | github.com/suzuki-shunsuke/github-comment-metadata v0.1.0/go.mod h1:GNDhEmWAJ6Bbk9rIds0mAMF4noyPV3EqwqLetnEoNLg= 68 | github.com/suzuki-shunsuke/go-ci-env/v3 v3.1.0 h1:WHVT7TZTVITrKiIvW7i5+4vr8ebxD6oXGRffixIXNJU= 69 | github.com/suzuki-shunsuke/go-ci-env/v3 v3.1.0/go.mod h1:qnHYP5fJLLNKqOwxCfix7XVzQGfbF72vbGgLhl8X2vA= 70 | github.com/suzuki-shunsuke/go-findconfig v1.2.0 h1:PWHIyKZEsVmZVh6+K+rHVw0/XjTFmQEYfa8ZIzIJd0c= 71 | github.com/suzuki-shunsuke/go-findconfig v1.2.0/go.mod h1:lXzJUZQXrgsMmpHxXMVrWUAQpE4EopgDEJbwslvKbzs= 72 | github.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE= 73 | github.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM= 74 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 h1:ETCRtbqi2D0NTwGHBPTc1IRIa8mFPLt936J2FA1iy10= 75 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5/go.mod h1:XzvaaJ7L21jH7CqwZj4FlYCRJYqiEUHMqiFp54je7/M= 76 | github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 77 | github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 78 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 79 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 80 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 81 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 82 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 83 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 84 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 85 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 86 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 89 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 90 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 93 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 96 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | -------------------------------------------------------------------------------- /json-schema/tfcmt.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/suzuki-shunsuke/tfcmt/v4/pkg/config/config", 4 | "$ref": "#/$defs/Config", 5 | "$defs": { 6 | "Apply": { 7 | "properties": { 8 | "template": { 9 | "type": "string" 10 | }, 11 | "when_parse_error": { 12 | "$ref": "#/$defs/WhenParseError" 13 | } 14 | }, 15 | "additionalProperties": false, 16 | "type": "object" 17 | }, 18 | "Config": { 19 | "properties": { 20 | "terraform": { 21 | "$ref": "#/$defs/Terraform" 22 | }, 23 | "embedded_var_names": { 24 | "items": { 25 | "type": "string" 26 | }, 27 | "type": "array" 28 | }, 29 | "templates": { 30 | "additionalProperties": { 31 | "type": "string" 32 | }, 33 | "type": "object" 34 | }, 35 | "log": { 36 | "$ref": "#/$defs/Log" 37 | }, 38 | "ghe_base_url": { 39 | "type": "string" 40 | }, 41 | "ghe_graphql_endpoint": { 42 | "type": "string" 43 | }, 44 | "plan_patch": { 45 | "type": "boolean" 46 | }, 47 | "repo_owner": { 48 | "type": "string" 49 | }, 50 | "repo_name": { 51 | "type": "string" 52 | } 53 | }, 54 | "additionalProperties": false, 55 | "type": "object" 56 | }, 57 | "Log": { 58 | "properties": { 59 | "level": { 60 | "type": "string" 61 | } 62 | }, 63 | "additionalProperties": false, 64 | "type": "object" 65 | }, 66 | "Plan": { 67 | "properties": { 68 | "template": { 69 | "type": "string" 70 | }, 71 | "when_add_or_update_only": { 72 | "$ref": "#/$defs/WhenAddOrUpdateOnly" 73 | }, 74 | "when_destroy": { 75 | "$ref": "#/$defs/WhenDestroy" 76 | }, 77 | "when_no_changes": { 78 | "$ref": "#/$defs/WhenNoChanges" 79 | }, 80 | "when_plan_error": { 81 | "$ref": "#/$defs/WhenPlanError" 82 | }, 83 | "when_parse_error": { 84 | "$ref": "#/$defs/WhenParseError" 85 | }, 86 | "disable_label": { 87 | "type": "boolean" 88 | }, 89 | "ignore_warning": { 90 | "type": "boolean" 91 | } 92 | }, 93 | "additionalProperties": false, 94 | "type": "object" 95 | }, 96 | "Terraform": { 97 | "properties": { 98 | "plan": { 99 | "$ref": "#/$defs/Plan" 100 | }, 101 | "apply": { 102 | "$ref": "#/$defs/Apply" 103 | }, 104 | "use_raw_output": { 105 | "type": "boolean" 106 | } 107 | }, 108 | "additionalProperties": false, 109 | "type": "object" 110 | }, 111 | "WhenAddOrUpdateOnly": { 112 | "properties": { 113 | "label": { 114 | "type": "string" 115 | }, 116 | "label_color": { 117 | "type": "string" 118 | }, 119 | "disable_label": { 120 | "type": "boolean" 121 | } 122 | }, 123 | "additionalProperties": false, 124 | "type": "object" 125 | }, 126 | "WhenDestroy": { 127 | "properties": { 128 | "label": { 129 | "type": "string" 130 | }, 131 | "label_color": { 132 | "type": "string" 133 | }, 134 | "disable_label": { 135 | "type": "boolean" 136 | } 137 | }, 138 | "additionalProperties": false, 139 | "type": "object" 140 | }, 141 | "WhenNoChanges": { 142 | "properties": { 143 | "label": { 144 | "type": "string" 145 | }, 146 | "label_color": { 147 | "type": "string" 148 | }, 149 | "disable_label": { 150 | "type": "boolean" 151 | }, 152 | "DisableComment": { 153 | "type": "boolean" 154 | } 155 | }, 156 | "additionalProperties": false, 157 | "type": "object", 158 | "required": [ 159 | "DisableComment" 160 | ] 161 | }, 162 | "WhenParseError": { 163 | "properties": { 164 | "template": { 165 | "type": "string" 166 | } 167 | }, 168 | "additionalProperties": false, 169 | "type": "object" 170 | }, 171 | "WhenPlanError": { 172 | "properties": { 173 | "label": { 174 | "type": "string" 175 | }, 176 | "label_color": { 177 | "type": "string" 178 | }, 179 | "disable_label": { 180 | "type": "boolean" 181 | } 182 | }, 183 | "additionalProperties": false, 184 | "type": "object" 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /pkg/apperr/error.go: -------------------------------------------------------------------------------- 1 | package apperr 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/logrus-error/logerr" 8 | ) 9 | 10 | // Exit codes are int values for the exit code that shell interpreter can interpret 11 | const ( 12 | ExitCodeOK int = 0 13 | ExitCodeError int = iota 14 | ) 15 | 16 | // ErrorFormatter is the interface for format 17 | type ErrorFormatter interface { 18 | Format(s fmt.State, verb rune) 19 | } 20 | 21 | // ExitCoder is the wrapper interface for urfave/cli 22 | type ExitCoder interface { 23 | error 24 | ExitCode() int 25 | } 26 | 27 | // ExitError is the wrapper struct for urfave/cli 28 | type ExitError struct { 29 | exitCode int 30 | err error 31 | } 32 | 33 | // NewExitError makes a new ExitError 34 | func NewExitError(exitCode int, err error) *ExitError { 35 | return &ExitError{ 36 | exitCode: exitCode, 37 | err: err, 38 | } 39 | } 40 | 41 | // Error returns the string message, fulfilling the interface required by `error` 42 | func (ee *ExitError) Error() string { 43 | if ee.err == nil { 44 | return "" 45 | } 46 | return ee.err.Error() 47 | } 48 | 49 | // ExitCode returns the exit code, fulfilling the interface required by `ExitCoder` 50 | func (ee *ExitError) ExitCode() int { 51 | return ee.exitCode 52 | } 53 | 54 | // HandleExit returns int value that shell interpreter can interpret as the exit code 55 | // If err has error message, it will be displayed to stderr 56 | // This function is heavily inspired by urfave/cli.HandleExitCoder 57 | func HandleExit(err error) int { 58 | if err == nil { 59 | return ExitCodeOK 60 | } 61 | 62 | logE := logrus.NewEntry(logrus.New()) 63 | 64 | if exitErr, ok := err.(ExitCoder); ok { //nolint:errorlint 65 | errMsg := err.Error() 66 | if errMsg != "" { 67 | if _, ok := exitErr.(ErrorFormatter); ok { 68 | logrus.Errorf("%+v", err) 69 | } else { 70 | logerr.WithError(logE, err).Error("tfcmt failed") 71 | } 72 | } 73 | if code := exitErr.ExitCode(); code != 0 { 74 | return code 75 | } 76 | if errMsg == "" { 77 | return ExitCodeOK 78 | } 79 | return ExitCodeError 80 | } 81 | 82 | logerr.WithError(logE, err).Error("tfcmt failed") 83 | return ExitCodeError 84 | } 85 | -------------------------------------------------------------------------------- /pkg/apperr/error_test.go: -------------------------------------------------------------------------------- 1 | package apperr 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestHandleError(t *testing.T) { 9 | t.Parallel() 10 | testCases := []struct { 11 | name string 12 | err error 13 | exitCode int 14 | }{ 15 | { 16 | name: "case 0", 17 | err: NewExitError(1, errors.New("error")), 18 | exitCode: 1, 19 | }, 20 | { 21 | name: "case 1", 22 | err: NewExitError(0, errors.New("error")), 23 | exitCode: 1, 24 | }, 25 | { 26 | name: "case 2", 27 | err: errors.New("error"), 28 | exitCode: 1, 29 | }, 30 | { 31 | name: "case 3", 32 | err: NewExitError(0, nil), 33 | exitCode: 0, 34 | }, 35 | { 36 | name: "case 4", 37 | err: NewExitError(1, nil), 38 | exitCode: 1, 39 | }, 40 | { 41 | name: "case 5", 42 | err: nil, 43 | exitCode: 0, 44 | }, 45 | } 46 | 47 | for _, testCase := range testCases { 48 | t.Run(testCase.name, func(t *testing.T) { 49 | t.Parallel() 50 | // TODO: test stderr 51 | exitCode := HandleExit(testCase.err) 52 | if exitCode != testCase.exitCode { 53 | t.Errorf("got %d but want %d", exitCode, testCase.exitCode) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/cli/app.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/helpall" 7 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/vcmd" 8 | "github.com/urfave/cli/v3" 9 | ) 10 | 11 | type LDFlags struct { 12 | Version string 13 | Commit string 14 | Date string 15 | } 16 | 17 | func New(flags *LDFlags) *cli.Command { 18 | return helpall.With(vcmd.With(&cli.Command{ 19 | Name: "tfcmt", 20 | Usage: "Notify the execution result of terraform command", 21 | Version: flags.Version, 22 | ExitErrHandler: func(context.Context, *cli.Command, error) {}, 23 | Flags: []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "owner", 26 | Usage: "GitHub Repository owner name", 27 | Sources: cli.EnvVars("TFCMT_REPO_OWNER"), 28 | }, 29 | &cli.StringFlag{ 30 | Name: "repo", 31 | Usage: "GitHub Repository name", 32 | Sources: cli.EnvVars("TFCMT_REPO_NAME"), 33 | }, 34 | &cli.StringFlag{ 35 | Name: "sha", 36 | Usage: "commit SHA (revision)", 37 | Sources: cli.EnvVars("TFCMT_SHA"), 38 | }, 39 | &cli.StringFlag{ 40 | Name: "build-url", 41 | Usage: "build url", 42 | }, 43 | &cli.StringFlag{ 44 | Name: "log-level", 45 | Usage: "log level", 46 | }, 47 | &cli.IntFlag{ 48 | Name: "pr", 49 | Usage: "pull request number", 50 | Sources: cli.EnvVars("TFCMT_PR_NUMBER"), 51 | }, 52 | &cli.StringFlag{ 53 | Name: "config", 54 | Usage: "config path", 55 | Sources: cli.EnvVars("TFCMT_CONFIG"), 56 | }, 57 | &cli.StringSliceFlag{ 58 | Name: "var", 59 | Usage: "template variables. The format of value is ':'. You can refer to the variable in the comment and label template using {{.Vars.}}.", 60 | }, 61 | &cli.StringFlag{ 62 | Name: "output", 63 | Usage: "specify file to output result instead of posting a comment", 64 | }, 65 | }, 66 | Commands: []*cli.Command{ 67 | { 68 | Name: "plan", 69 | ArgsUsage: " ...", 70 | Usage: "Run terraform plan and post a comment to GitHub commit, pull request, or issue", 71 | Description: `Run terraform plan and post a comment to GitHub commit, pull request, or issue. 72 | 73 | $ tfcmt [] plan [-patch] [-skip-no-changes] -- terraform plan []`, 74 | Action: cmdPlan, 75 | Flags: []cli.Flag{ 76 | &cli.BoolFlag{ 77 | Name: "patch", 78 | Usage: "update an existing comment instead of creating a new comment. If there is no existing comment, a new comment is created.", 79 | Sources: cli.EnvVars("TFCMT_PLAN_PATCH"), 80 | }, 81 | &cli.BoolFlag{ 82 | Name: "skip-no-changes", 83 | Usage: "If there is no change tfcmt updates a label but doesn't post a comment", 84 | Sources: cli.EnvVars("TFCMT_SKIP_NO_CHANGES"), 85 | }, 86 | &cli.BoolFlag{ 87 | Name: "ignore-warning", 88 | Usage: "If skip-no-changes is enabled, comment is posted even if there is a warning. If skip-no-changes is disabled, warning is removed from the comment.", 89 | Sources: cli.EnvVars("TFCMT_IGNORE_WARNING"), 90 | }, 91 | &cli.BoolFlag{ 92 | Name: "disable-label", 93 | Usage: "Disable to add or update a label", 94 | Sources: cli.EnvVars("TFCMT_DISABLE_LABEL"), 95 | }, 96 | }, 97 | }, 98 | { 99 | Name: "apply", 100 | ArgsUsage: " ...", 101 | Usage: "Run terraform apply and post a comment to GitHub commit, pull request, or issue", 102 | Description: `Run terraform apply and post a comment to GitHub commit, pull request, or issue. 103 | 104 | $ tfcmt [] apply -- terraform apply []`, 105 | Action: cmdApply, 106 | }, 107 | vcmd.New(&vcmd.Command{ 108 | Name: "tfcmt", 109 | Version: flags.Version, 110 | SHA: flags.Commit, 111 | }), 112 | }, 113 | }, flags.Commit), nil) 114 | } 115 | -------------------------------------------------------------------------------- /pkg/cli/apply.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/controller" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | func cmdApply(ctx context.Context, cmd *cli.Command) error { 13 | logLevel := cmd.String("log-level") 14 | setLogLevel(logLevel) 15 | 16 | cfg, err := newConfig(cmd) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | if logLevel == "" { 22 | logLevel = cfg.Log.Level 23 | setLogLevel(logLevel) 24 | } 25 | 26 | if err := parseOpts(cmd, &cfg, os.Environ()); err != nil { 27 | return err 28 | } 29 | 30 | t := &controller.Controller{ 31 | Config: cfg, 32 | Parser: terraform.NewApplyParser(), 33 | Template: terraform.NewApplyTemplate(cfg.Terraform.Apply.Template), 34 | ParseErrorTemplate: terraform.NewApplyParseErrorTemplate(cfg.Terraform.Apply.WhenParseError.Template), 35 | } 36 | 37 | args := cmd.Args() 38 | 39 | return t.Apply(ctx, controller.Command{ 40 | Cmd: args.First(), 41 | Args: args.Tail(), 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/cli/config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 5 | "github.com/urfave/cli/v3" 6 | ) 7 | 8 | func newConfig(cmd *cli.Command) (config.Config, error) { 9 | cfg := config.Config{} 10 | confPath, err := cfg.Find(cmd.String("config")) 11 | if err != nil { 12 | return cfg, err 13 | } 14 | if confPath != "" { 15 | if err := cfg.LoadFile(confPath); err != nil { 16 | return cfg, err 17 | } 18 | } 19 | return cfg, nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/cli/log.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | func setLogLevel(logLevel string) { 6 | if logLevel == "" { 7 | return 8 | } 9 | lvl, err := logrus.ParseLevel(logLevel) 10 | if err != nil { 11 | logrus.WithFields(logrus.Fields{ 12 | "log_level": logLevel, 13 | }).WithError(err).Error("the log level is invalid") 14 | } 15 | logrus.SetLevel(lvl) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/cli/plan.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/controller" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | func cmdPlan(ctx context.Context, cmd *cli.Command) error { 13 | logLevel := cmd.String("log-level") 14 | setLogLevel(logLevel) 15 | 16 | cfg, err := newConfig(cmd) 17 | if err != nil { 18 | return err 19 | } 20 | if logLevel == "" { 21 | logLevel = cfg.Log.Level 22 | setLogLevel(logLevel) 23 | } 24 | 25 | if err := parseOpts(cmd, &cfg, os.Environ()); err != nil { 26 | return err 27 | } 28 | 29 | t := &controller.Controller{ 30 | Config: cfg, 31 | Parser: terraform.NewPlanParser(), 32 | Template: terraform.NewPlanTemplate(cfg.Terraform.Plan.Template), 33 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(cfg.Terraform.Plan.WhenParseError.Template), 34 | } 35 | args := cmd.Args() 36 | 37 | return t.Plan(ctx, controller.Command{ 38 | Cmd: args.First(), 39 | Args: args.Tail(), 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cli/var.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 10 | "github.com/urfave/cli/v3" 11 | ) 12 | 13 | func parseVars(vars []string, envs []string, varsM map[string]string) error { 14 | parseVarEnvs(envs, varsM) 15 | return parseVarOpts(vars, varsM) 16 | } 17 | 18 | func parseVarOpts(vars []string, varsM map[string]string) error { 19 | for _, v := range vars { 20 | a := strings.Index(v, ":") 21 | if a == -1 { 22 | return errors.New("the value of var option is invalid. the format should be ':': " + v) 23 | } 24 | varsM[v[:a]] = v[a+1:] 25 | } 26 | return nil 27 | } 28 | 29 | func parseVarEnvs(envs []string, m map[string]string) { 30 | for _, kv := range envs { 31 | k, v, _ := strings.Cut(kv, "=") 32 | if a := strings.TrimPrefix(k, "TFCMT_VAR_"); k != a { 33 | m[a] = v 34 | } 35 | } 36 | } 37 | 38 | func parseOpts(cmd *cli.Command, cfg *config.Config, envs []string) error { //nolint:cyclop 39 | if owner := cmd.String("owner"); owner != "" { 40 | cfg.CI.Owner = owner 41 | } 42 | 43 | if repo := cmd.String("repo"); repo != "" { 44 | cfg.CI.Repo = repo 45 | } 46 | 47 | if sha := cmd.String("sha"); sha != "" { 48 | cfg.CI.SHA = sha 49 | } 50 | 51 | if pr := cmd.Int("pr"); pr != 0 { 52 | cfg.CI.PRNumber = pr 53 | } 54 | 55 | if cmd.IsSet("patch") { 56 | cfg.PlanPatch = cmd.Bool("patch") 57 | } 58 | 59 | if buildURL := cmd.String("build-url"); buildURL != "" { 60 | cfg.CI.Link = buildURL 61 | } 62 | 63 | if output := cmd.String("output"); output != "" { 64 | cfg.Output = output 65 | } 66 | 67 | if cmd.IsSet("skip-no-changes") { 68 | cfg.Terraform.Plan.WhenNoChanges.DisableComment = cmd.Bool("skip-no-changes") 69 | } 70 | 71 | if cmd.IsSet("ignore-warning") { 72 | cfg.Terraform.Plan.IgnoreWarning = cmd.Bool("ignore-warning") 73 | } 74 | 75 | vars := cmd.StringSlice("var") 76 | vm := make(map[string]string, len(vars)) 77 | if err := parseVars(vars, envs, vm); err != nil { 78 | return err 79 | } 80 | cfg.Vars = vm 81 | 82 | // Mask https://github.com/suzuki-shunsuke/tfcmt/discussions/1083 83 | masks, err := mask.ParseMasksFromEnv() 84 | if err != nil { 85 | return err 86 | } 87 | cfg.Masks = masks 88 | 89 | if cmd.IsSet("disable-label") { 90 | cfg.Terraform.Plan.DisableLabel = cmd.Bool("disable-label") 91 | } 92 | 93 | if cfg.GHEBaseURL == "" { 94 | cfg.GHEBaseURL = os.Getenv("GITHUB_API_URL") 95 | } 96 | if cfg.GHEGraphQLEndpoint == "" { 97 | cfg.GHEGraphQLEndpoint = os.Getenv("GITHUB_GRAPHQL_URL") 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | 9 | "github.com/suzuki-shunsuke/go-findconfig/findconfig" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // Config is for tfcmt config structure 14 | type Config struct { 15 | CI CI `json:"-" yaml:"-"` 16 | Terraform Terraform `json:"terraform,omitempty"` 17 | Vars map[string]string `json:"-" yaml:"-"` 18 | EmbeddedVarNames []string `json:"embedded_var_names,omitempty" yaml:"embedded_var_names"` 19 | Templates map[string]string `json:"templates,omitempty"` 20 | Log Log `json:"log,omitempty"` 21 | GHEBaseURL string `json:"ghe_base_url,omitempty" yaml:"ghe_base_url"` 22 | GHEGraphQLEndpoint string `json:"ghe_graphql_endpoint,omitempty" yaml:"ghe_graphql_endpoint"` 23 | PlanPatch bool `json:"plan_patch,omitempty" yaml:"plan_patch"` 24 | RepoOwner string `json:"repo_owner,omitempty" yaml:"repo_owner"` 25 | RepoName string `json:"repo_name,omitempty" yaml:"repo_name"` 26 | Output string `json:"-" yaml:"-"` 27 | Masks []*Mask `json:"-" yaml:"-"` 28 | } 29 | 30 | type Mask struct { 31 | Type string 32 | Value string 33 | Regexp *regexp.Regexp 34 | } 35 | 36 | type CI struct { 37 | Name string 38 | Owner string 39 | Repo string 40 | SHA string 41 | Link string 42 | PRNumber int 43 | } 44 | 45 | type Log struct { 46 | Level string `json:"level,omitempty"` 47 | // Format string 48 | } 49 | 50 | // Terraform represents terraform configurations 51 | type Terraform struct { 52 | Plan Plan `json:"plan,omitempty"` 53 | Apply Apply `json:"apply,omitempty"` 54 | UseRawOutput bool `json:"use_raw_output,omitempty" yaml:"use_raw_output"` 55 | } 56 | 57 | // Plan is a terraform plan config 58 | type Plan struct { 59 | Template string `json:"template,omitempty"` 60 | WhenAddOrUpdateOnly WhenAddOrUpdateOnly `json:"when_add_or_update_only,omitempty" yaml:"when_add_or_update_only"` 61 | WhenDestroy WhenDestroy `json:"when_destroy,omitempty" yaml:"when_destroy"` 62 | WhenNoChanges WhenNoChanges `json:"when_no_changes,omitempty" yaml:"when_no_changes"` 63 | WhenPlanError WhenPlanError `json:"when_plan_error,omitempty" yaml:"when_plan_error"` 64 | WhenParseError WhenParseError `json:"when_parse_error,omitempty" yaml:"when_parse_error"` 65 | DisableLabel bool `json:"disable_label,omitempty" yaml:"disable_label"` 66 | IgnoreWarning bool `json:"ignore_warning,omitempty" yaml:"ignore_warning"` 67 | } 68 | 69 | // WhenAddOrUpdateOnly is a configuration to notify the plan result contains new or updated in place resources 70 | type WhenAddOrUpdateOnly struct { 71 | Label string `json:"label,omitempty"` 72 | Color string `json:"label_color,omitempty" yaml:"label_color"` 73 | DisableLabel bool `json:"disable_label,omitempty" yaml:"disable_label"` 74 | } 75 | 76 | // WhenDestroy is a configuration to notify the plan result contains destroy operation 77 | type WhenDestroy struct { 78 | Label string `json:"label,omitempty"` 79 | Color string `json:"label_color,omitempty" yaml:"label_color"` 80 | DisableLabel bool `json:"disable_label,omitempty" yaml:"disable_label"` 81 | } 82 | 83 | // WhenNoChanges is a configuration to add a label when the plan result contains no change 84 | type WhenNoChanges struct { 85 | Label string `json:"label,omitempty"` 86 | Color string `json:"label_color,omitempty" yaml:"label_color"` 87 | DisableLabel bool `json:"disable_label,omitempty" yaml:"disable_label"` 88 | DisableComment bool `yaml:"disable_comment"` 89 | } 90 | 91 | // WhenPlanError is a configuration to notify the plan result returns an error 92 | type WhenPlanError struct { 93 | Label string `json:"label,omitempty"` 94 | Color string `json:"label_color,omitempty" yaml:"label_color"` 95 | DisableLabel bool `json:"disable_label,omitempty" yaml:"disable_label"` 96 | } 97 | 98 | // WhenParseError is a configuration to notify the plan result returns an error 99 | type WhenParseError struct { 100 | Template string `json:"template,omitempty"` 101 | } 102 | 103 | // Apply is a terraform apply config 104 | type Apply struct { 105 | Template string `json:"template,omitempty"` 106 | WhenParseError WhenParseError `json:"when_parse_error,omitempty" yaml:"when_parse_error"` 107 | } 108 | 109 | // LoadFile binds the config file to Config structure 110 | func (c *Config) LoadFile(path string) error { 111 | if _, err := os.Stat(path); err != nil { 112 | return fmt.Errorf("%s: no config file", path) 113 | } 114 | raw, _ := os.ReadFile(path) 115 | return yaml.Unmarshal(raw, c) 116 | } 117 | 118 | // Validate validates config file 119 | func (c *Config) Validate() error { 120 | if c.Output != "" { 121 | return nil 122 | } 123 | if c.CI.Owner == "" { 124 | return errors.New("repository owner is missing") 125 | } 126 | 127 | if c.CI.Repo == "" { 128 | return errors.New("repository name is missing") 129 | } 130 | 131 | if c.CI.SHA == "" && c.CI.PRNumber <= 0 { 132 | return errors.New("pull request number or SHA (revision) is needed") 133 | } 134 | return nil 135 | } 136 | 137 | // Find returns config path 138 | func (c *Config) Find(file string) (string, error) { 139 | if file != "" { 140 | if _, err := os.Stat(file); err == nil { 141 | return file, nil 142 | } 143 | return "", errors.New("config for tfcmt is not found at all") 144 | } 145 | wd, err := os.Getwd() 146 | if err != nil { 147 | return "", fmt.Errorf("get a current directory path: %w", err) 148 | } 149 | if p := findconfig.Find(wd, findconfig.Exist, "tfcmt.yaml", "tfcmt.yml", ".tfcmt.yaml", ".tfcmt.yml"); p != "" { 150 | return p, nil 151 | } 152 | return "", nil 153 | } 154 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestLoadFile(t *testing.T) { 12 | t.Parallel() 13 | testCases := []struct { 14 | file string 15 | cfg Config 16 | ok bool 17 | }{ 18 | { 19 | file: "../../example.tfcmt.yaml", 20 | cfg: Config{ 21 | Terraform: Terraform{ 22 | Plan: Plan{ 23 | Template: "## Plan Result\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\n
Details (Click me)\n\n
{{ .CombinedOutput }}\n
\n", 24 | WhenDestroy: WhenDestroy{}, 25 | }, 26 | Apply: Apply{ 27 | Template: "", 28 | }, 29 | UseRawOutput: false, 30 | }, 31 | }, 32 | ok: true, 33 | }, 34 | { 35 | file: "../../example-with-destroy-and-result-labels.tfcmt.yaml", 36 | cfg: Config{ 37 | Terraform: Terraform{ 38 | Plan: Plan{ 39 | Template: `{{if .HasDestroy}} 40 | ## :warning: WARNING: Resource Deletion will happen 41 | 42 | This plan contains **resource deletion**. Please check the plan result very carefully! 43 | {{else}} 44 | ## Plan Result 45 | {{if .Result}} 46 |
{{ .Result }}
 47 | 
48 | {{end}} 49 |
Details (Click me) 50 | 51 |
{{ .CombinedOutput }}
 52 | 
53 | {{end}} 54 | `, 55 | WhenAddOrUpdateOnly: WhenAddOrUpdateOnly{ 56 | Label: "add-or-update", 57 | }, 58 | WhenDestroy: WhenDestroy{ 59 | Label: "destroy", 60 | }, 61 | WhenPlanError: WhenPlanError{ 62 | Label: "error", 63 | }, 64 | WhenNoChanges: WhenNoChanges{ 65 | Label: "no-changes", 66 | }, 67 | }, 68 | Apply: Apply{ 69 | Template: "", 70 | }, 71 | UseRawOutput: false, 72 | }, 73 | }, 74 | ok: true, 75 | }, 76 | { 77 | file: "no-such-config.yaml", 78 | cfg: Config{ 79 | Terraform: Terraform{ 80 | Plan: Plan{ 81 | Template: "## Plan Result\n{{if .Result}}\n
{{ .Result }}\n
\n{{end}}\n
Details (Click me)\n\n
{{ .CombinedOutput }}\n
\n", 82 | WhenDestroy: WhenDestroy{}, 83 | }, 84 | Apply: Apply{ 85 | Template: "", 86 | }, 87 | }, 88 | }, 89 | ok: false, 90 | }, 91 | } 92 | 93 | for _, testCase := range testCases { 94 | t.Run(testCase.file, func(t *testing.T) { 95 | t.Parallel() 96 | var cfg Config 97 | 98 | if err := cfg.LoadFile(testCase.file); err == nil { 99 | if !testCase.ok { 100 | t.Error("got no error but want error") 101 | } else if diff := cmp.Diff(cfg, testCase.cfg); diff != "" { 102 | t.Error(diff) 103 | } 104 | } else { 105 | if testCase.ok { 106 | t.Errorf("got error %v but want no error", err) 107 | } 108 | } 109 | }) 110 | } 111 | } 112 | 113 | func createDummy(file string) { 114 | validConfig := func(file string) bool { 115 | for _, c := range []string{ 116 | "tfcmt.yaml", 117 | "tfcmt.yml", 118 | ".tfcmt.yaml", 119 | ".tfcmt.yml", 120 | } { 121 | if file == c { 122 | return true 123 | } 124 | } 125 | return false 126 | } 127 | if !validConfig(file) { 128 | return 129 | } 130 | if _, err := os.Stat(file); err == nil { 131 | return 132 | } 133 | f, err := os.OpenFile(file, os.O_RDONLY|os.O_CREATE, 0o666) 134 | if err != nil { 135 | panic(err) 136 | } 137 | defer f.Close() 138 | } 139 | 140 | func removeDummy(file string) { 141 | os.Remove(file) 142 | } 143 | 144 | func TestFind(t *testing.T) { //nolint:paralleltest 145 | wd, err := os.Getwd() 146 | if err != nil { 147 | t.Fatal(err) 148 | } 149 | testCases := []struct { 150 | file string 151 | expect string 152 | ok bool 153 | }{ 154 | { 155 | // valid config 156 | file: ".tfcmt.yaml", 157 | expect: ".tfcmt.yaml", 158 | ok: true, 159 | }, 160 | { 161 | // valid config 162 | file: "tfcmt.yaml", 163 | expect: "tfcmt.yaml", 164 | ok: true, 165 | }, 166 | { 167 | // valid config 168 | file: ".tfcmt.yml", 169 | expect: ".tfcmt.yml", 170 | ok: true, 171 | }, 172 | { 173 | // valid config 174 | file: "tfcmt.yml", 175 | expect: "tfcmt.yml", 176 | ok: true, 177 | }, 178 | { 179 | // invalid config 180 | file: "codecov.yml", 181 | expect: "", 182 | ok: false, 183 | }, 184 | { 185 | // in case of no args passed 186 | file: "", 187 | expect: filepath.Join(wd, "tfcmt.yaml"), 188 | ok: true, 189 | }, 190 | } 191 | var cfg Config 192 | for _, testCase := range testCases { //nolint:paralleltest 193 | t.Run(testCase.file, func(t *testing.T) { 194 | createDummy(testCase.file) 195 | actual, err := cfg.Find(testCase.file) 196 | if (err == nil) != testCase.ok { 197 | t.Errorf("got error %q", err) 198 | } 199 | if actual != testCase.expect { 200 | t.Errorf("got %q but want %q", actual, testCase.expect) 201 | } 202 | }) 203 | defer removeDummy(testCase.file) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /pkg/controller/apply.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "os" 9 | "os/exec" 10 | 11 | "github.com/mattn/go-colorable" 12 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/apperr" 13 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 14 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 15 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/platform" 16 | ) 17 | 18 | // Apply sends the notification with notifier 19 | func (c *Controller) Apply(ctx context.Context, command Command) error { 20 | if command.Cmd == "" { 21 | return errors.New("no command specified") 22 | } 23 | if err := platform.Complement(&c.Config); err != nil { 24 | return err 25 | } 26 | 27 | if err := c.Config.Validate(); err != nil { 28 | return err 29 | } 30 | 31 | ntf, err := c.getApplyNotifier(ctx) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if ntf == nil { 37 | return errors.New("no notifier specified at all") 38 | } 39 | 40 | cmd := exec.CommandContext(ctx, command.Cmd, command.Args...) //nolint:gosec 41 | cmd.Stdin = os.Stdin 42 | stdout := &bytes.Buffer{} 43 | stderr := &bytes.Buffer{} 44 | combinedOutput := &bytes.Buffer{} 45 | uncolorizedStdout := colorable.NewNonColorable(stdout) 46 | uncolorizedStderr := colorable.NewNonColorable(stderr) 47 | uncolorizedCombinedOutput := colorable.NewNonColorable(combinedOutput) 48 | cmd.Stdout = io.MultiWriter(mask.NewWriter(os.Stdout, c.Config.Masks), uncolorizedStdout, uncolorizedCombinedOutput) 49 | cmd.Stderr = io.MultiWriter(mask.NewWriter(os.Stderr, c.Config.Masks), uncolorizedStderr, uncolorizedCombinedOutput) 50 | setCancel(cmd) 51 | _ = cmd.Run() 52 | 53 | return apperr.NewExitError(cmd.ProcessState.ExitCode(), ntf.Apply(ctx, ¬ifier.ParamExec{ 54 | Stdout: stdout.String(), 55 | Stderr: stderr.String(), 56 | CombinedOutput: combinedOutput.String(), 57 | CIName: c.Config.CI.Name, 58 | ExitCode: cmd.ProcessState.ExitCode(), 59 | })) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "text/template" 8 | 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 10 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 11 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier/github" 12 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier/localfile" 13 | tmpl "github.com/suzuki-shunsuke/tfcmt/v4/pkg/template" 14 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 15 | ) 16 | 17 | type Controller struct { 18 | Config config.Config 19 | Parser terraform.Parser 20 | Template *terraform.Template 21 | ParseErrorTemplate *terraform.Template 22 | } 23 | 24 | type Command struct { 25 | Cmd string 26 | Args []string 27 | } 28 | 29 | func (c *Controller) renderTemplate(tpl string) (string, error) { 30 | tmpl, err := template.New("_").Funcs(tmpl.TxtFuncMap()).Parse(tpl) 31 | if err != nil { 32 | return "", err 33 | } 34 | buf := &bytes.Buffer{} 35 | if err := tmpl.Execute(buf, map[string]any{ 36 | "Vars": c.Config.Vars, 37 | }); err != nil { 38 | return "", fmt.Errorf("render a label template: %w", err) 39 | } 40 | return buf.String(), nil 41 | } 42 | 43 | func (c *Controller) renderGitHubLabels() (github.ResultLabels, error) { //nolint:cyclop 44 | labels := github.ResultLabels{ 45 | AddOrUpdateLabelColor: c.Config.Terraform.Plan.WhenAddOrUpdateOnly.Color, 46 | DestroyLabelColor: c.Config.Terraform.Plan.WhenDestroy.Color, 47 | NoChangesLabelColor: c.Config.Terraform.Plan.WhenNoChanges.Color, 48 | PlanErrorLabelColor: c.Config.Terraform.Plan.WhenPlanError.Color, 49 | } 50 | 51 | target, ok := c.Config.Vars["target"] 52 | if !ok { 53 | target = "" 54 | } 55 | 56 | if labels.AddOrUpdateLabelColor == "" { 57 | labels.AddOrUpdateLabelColor = "1d76db" // blue 58 | } 59 | if labels.DestroyLabelColor == "" { 60 | labels.DestroyLabelColor = "d93f0b" // red 61 | } 62 | if labels.NoChangesLabelColor == "" { 63 | labels.NoChangesLabelColor = "0e8a16" // green 64 | } 65 | 66 | if !c.Config.Terraform.Plan.WhenAddOrUpdateOnly.DisableLabel { 67 | if c.Config.Terraform.Plan.WhenAddOrUpdateOnly.Label == "" { 68 | if target == "" { 69 | labels.AddOrUpdateLabel = "add-or-update" 70 | } else { 71 | labels.AddOrUpdateLabel = target + "/add-or-update" 72 | } 73 | } else { 74 | addOrUpdateLabel, err := c.renderTemplate(c.Config.Terraform.Plan.WhenAddOrUpdateOnly.Label) 75 | if err != nil { 76 | return labels, err 77 | } 78 | labels.AddOrUpdateLabel = addOrUpdateLabel 79 | } 80 | } 81 | 82 | if !c.Config.Terraform.Plan.WhenDestroy.DisableLabel { 83 | if c.Config.Terraform.Plan.WhenDestroy.Label == "" { 84 | if target == "" { 85 | labels.DestroyLabel = "destroy" 86 | } else { 87 | labels.DestroyLabel = target + "/destroy" 88 | } 89 | } else { 90 | destroyLabel, err := c.renderTemplate(c.Config.Terraform.Plan.WhenDestroy.Label) 91 | if err != nil { 92 | return labels, err 93 | } 94 | labels.DestroyLabel = destroyLabel 95 | } 96 | } 97 | 98 | if !c.Config.Terraform.Plan.WhenNoChanges.DisableLabel { 99 | if c.Config.Terraform.Plan.WhenNoChanges.Label == "" { 100 | if target == "" { 101 | labels.NoChangesLabel = "no-changes" 102 | } else { 103 | labels.NoChangesLabel = target + "/no-changes" 104 | } 105 | } else { 106 | nochangesLabel, err := c.renderTemplate(c.Config.Terraform.Plan.WhenNoChanges.Label) 107 | if err != nil { 108 | return labels, err 109 | } 110 | labels.NoChangesLabel = nochangesLabel 111 | } 112 | } 113 | 114 | if !c.Config.Terraform.Plan.WhenPlanError.DisableLabel { 115 | planErrorLabel, err := c.renderTemplate(c.Config.Terraform.Plan.WhenPlanError.Label) 116 | if err != nil { 117 | return labels, err 118 | } 119 | labels.PlanErrorLabel = planErrorLabel 120 | } 121 | 122 | return labels, nil 123 | } 124 | 125 | func (c *Controller) getPlanNotifier(ctx context.Context) (notifier.Notifier, error) { 126 | labels := github.ResultLabels{} 127 | if !c.Config.Terraform.Plan.DisableLabel { 128 | a, err := c.renderGitHubLabels() 129 | if err != nil { 130 | return nil, err 131 | } 132 | labels = a 133 | } 134 | var gh *github.NotifyService 135 | if !c.Config.Terraform.Plan.DisableLabel || c.Config.Output == "" { 136 | client, err := github.NewClient(ctx, &github.Config{ 137 | BaseURL: c.Config.GHEBaseURL, 138 | GraphQLEndpoint: c.Config.GHEGraphQLEndpoint, 139 | Owner: c.Config.CI.Owner, 140 | Repo: c.Config.CI.Repo, 141 | PR: github.PullRequest{ 142 | Revision: c.Config.CI.SHA, 143 | Number: c.Config.CI.PRNumber, 144 | }, 145 | CI: c.Config.CI.Link, 146 | Parser: c.Parser, 147 | UseRawOutput: c.Config.Terraform.UseRawOutput, 148 | Template: c.Template, 149 | ParseErrorTemplate: c.ParseErrorTemplate, 150 | ResultLabels: labels, 151 | Vars: c.Config.Vars, 152 | EmbeddedVarNames: c.Config.EmbeddedVarNames, 153 | Templates: c.Config.Templates, 154 | Patch: c.Config.PlanPatch, 155 | SkipNoChanges: c.Config.Terraform.Plan.WhenNoChanges.DisableComment, 156 | IgnoreWarning: c.Config.Terraform.Plan.IgnoreWarning, 157 | Masks: c.Config.Masks, 158 | }) 159 | if err != nil { 160 | return nil, err 161 | } 162 | gh = client.Notify 163 | } 164 | if c.Config.Output == "" { 165 | return gh, nil 166 | } 167 | // Write output to file instead of github comment 168 | client, err := localfile.NewClient(&localfile.Config{ 169 | OutputFile: c.Config.Output, 170 | Parser: c.Parser, 171 | UseRawOutput: c.Config.Terraform.UseRawOutput, 172 | CI: c.Config.CI.Link, 173 | Template: c.Template, 174 | ParseErrorTemplate: c.ParseErrorTemplate, 175 | Vars: c.Config.Vars, 176 | EmbeddedVarNames: c.Config.EmbeddedVarNames, 177 | Templates: c.Config.Templates, 178 | Masks: c.Config.Masks, 179 | DisableLabel: c.Config.Terraform.Plan.DisableLabel, 180 | }, gh) 181 | if err != nil { 182 | return nil, err 183 | } 184 | return client.Notify, nil 185 | } 186 | 187 | func (c *Controller) getApplyNotifier(ctx context.Context) (notifier.Notifier, error) { 188 | if c.Config.Output != "" { 189 | // Write output to file instead of github comment 190 | client, err := localfile.NewClient(&localfile.Config{ 191 | OutputFile: c.Config.Output, 192 | Parser: c.Parser, 193 | UseRawOutput: c.Config.Terraform.UseRawOutput, 194 | CI: c.Config.CI.Link, 195 | Template: c.Template, 196 | ParseErrorTemplate: c.ParseErrorTemplate, 197 | Vars: c.Config.Vars, 198 | EmbeddedVarNames: c.Config.EmbeddedVarNames, 199 | Templates: c.Config.Templates, 200 | Masks: c.Config.Masks, 201 | DisableLabel: c.Config.Terraform.Plan.DisableLabel, 202 | }, nil) 203 | if err != nil { 204 | return nil, err 205 | } 206 | return client.Notify, nil 207 | } 208 | client, err := github.NewClient(ctx, &github.Config{ 209 | BaseURL: c.Config.GHEBaseURL, 210 | GraphQLEndpoint: c.Config.GHEGraphQLEndpoint, 211 | Owner: c.Config.CI.Owner, 212 | Repo: c.Config.CI.Repo, 213 | PR: github.PullRequest{ 214 | Revision: c.Config.CI.SHA, 215 | Number: c.Config.CI.PRNumber, 216 | }, 217 | CI: c.Config.CI.Link, 218 | Parser: c.Parser, 219 | UseRawOutput: c.Config.Terraform.UseRawOutput, 220 | Template: c.Template, 221 | ParseErrorTemplate: c.ParseErrorTemplate, 222 | Vars: c.Config.Vars, 223 | EmbeddedVarNames: c.Config.EmbeddedVarNames, 224 | Templates: c.Config.Templates, 225 | Patch: c.Config.PlanPatch, 226 | SkipNoChanges: c.Config.Terraform.Plan.WhenNoChanges.DisableComment, 227 | IgnoreWarning: c.Config.Terraform.Plan.IgnoreWarning, 228 | Masks: c.Config.Masks, 229 | }) 230 | if err != nil { 231 | return nil, err 232 | } 233 | return client.Notify, nil 234 | } 235 | -------------------------------------------------------------------------------- /pkg/controller/plan.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/mattn/go-colorable" 13 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/apperr" 14 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 15 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 16 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/platform" 17 | ) 18 | 19 | // Plan sends the notification with notifier 20 | func (c *Controller) Plan(ctx context.Context, command Command) error { 21 | if command.Cmd == "" { 22 | return errors.New("no command specified") 23 | } 24 | if err := platform.Complement(&c.Config); err != nil { 25 | return err 26 | } 27 | 28 | if err := c.Config.Validate(); err != nil { 29 | return err 30 | } 31 | 32 | ntf, err := c.getPlanNotifier(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if ntf == nil { 38 | return errors.New("no notifier specified at all") 39 | } 40 | 41 | cmd := exec.CommandContext(ctx, command.Cmd, command.Args...) //nolint:gosec 42 | cmd.Stdin = os.Stdin 43 | stdout := &bytes.Buffer{} 44 | stderr := &bytes.Buffer{} 45 | combinedOutput := &bytes.Buffer{} 46 | uncolorizedStdout := colorable.NewNonColorable(stdout) 47 | uncolorizedStderr := colorable.NewNonColorable(stderr) 48 | uncolorizedCombinedOutput := colorable.NewNonColorable(combinedOutput) 49 | cmd.Stdout = io.MultiWriter(mask.NewWriter(os.Stdout, c.Config.Masks), uncolorizedStdout, uncolorizedCombinedOutput) 50 | cmd.Stderr = io.MultiWriter(mask.NewWriter(os.Stderr, c.Config.Masks), uncolorizedStderr, uncolorizedCombinedOutput) 51 | setCancel(cmd) 52 | _ = cmd.Run() 53 | 54 | return apperr.NewExitError(cmd.ProcessState.ExitCode(), ntf.Plan(ctx, ¬ifier.ParamExec{ 55 | Stdout: stdout.String(), 56 | Stderr: stderr.String(), 57 | CombinedOutput: combinedOutput.String(), 58 | CIName: c.Config.CI.Name, 59 | ExitCode: cmd.ProcessState.ExitCode(), 60 | })) 61 | } 62 | 63 | const waitDelay = 1000 * time.Hour 64 | 65 | func setCancel(cmd *exec.Cmd) { 66 | cmd.Cancel = func() error { 67 | return cmd.Process.Signal(os.Interrupt) //nolint:wrapcheck 68 | } 69 | cmd.WaitDelay = waitDelay 70 | } 71 | -------------------------------------------------------------------------------- /pkg/mask/parser.go: -------------------------------------------------------------------------------- 1 | package mask 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/suzuki-shunsuke/logrus-error/logerr" 12 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 13 | ) 14 | 15 | func ParseMasksFromEnv() ([]*config.Mask, error) { 16 | return ParseMasks(os.Getenv("TFCMT_MASKS"), os.Getenv("TFCMT_MASKS_SEPARATOR")) 17 | } 18 | 19 | func ParseMasks(maskStr, maskSep string) ([]*config.Mask, error) { 20 | if maskStr == "" { 21 | return nil, nil 22 | } 23 | if maskSep == "" { 24 | maskSep = "," // default separator 25 | } 26 | maskStrs := strings.Split(maskStr, maskSep) 27 | masks := make([]*config.Mask, 0, len(maskStrs)) 28 | for _, maskStr := range maskStrs { 29 | mask, err := parseMask(maskStr) 30 | if err != nil { 31 | return nil, fmt.Errorf("parse a mask: %w", logerr.WithFields(err, logrus.Fields{ 32 | "mask": maskStr, 33 | })) 34 | } 35 | if mask == nil { 36 | continue 37 | } 38 | masks = append(masks, mask) 39 | } 40 | return masks, nil 41 | } 42 | 43 | func parseMask(maskStr string) (*config.Mask, error) { 44 | typ, value, ok := strings.Cut(maskStr, ":") 45 | if !ok { 46 | return nil, errors.New("the mask is invalid. ':' is missing") 47 | } 48 | switch typ { 49 | case "env": 50 | if e := os.Getenv(value); e != "" { 51 | return &config.Mask{ 52 | Type: "equal", 53 | Value: e, 54 | }, nil 55 | } 56 | // the environment variable is missing 57 | return nil, nil //nolint:nilnil 58 | case "regexp": 59 | p, err := regexp.Compile(value) 60 | if err != nil { 61 | return nil, fmt.Errorf("the regular expression is invalid: %w", err) 62 | } 63 | return &config.Mask{ 64 | Type: "regexp", 65 | Value: value, 66 | Regexp: p, 67 | }, nil 68 | default: 69 | return nil, errors.New("the mask type is invalid") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/mask/writer.go: -------------------------------------------------------------------------------- 1 | package mask 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | 7 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 8 | ) 9 | 10 | const ( 11 | typeEqual = "equal" 12 | typeRegexp = "regexp" 13 | ) 14 | 15 | type Writer struct { 16 | patterns []*config.Mask 17 | w io.Writer 18 | } 19 | 20 | func NewWriter(w io.Writer, patterns []*config.Mask) *Writer { 21 | return &Writer{ 22 | w: w, 23 | patterns: patterns, 24 | } 25 | } 26 | 27 | func (w *Writer) Write(p []byte) (int, error) { 28 | a := p 29 | for _, pattern := range w.patterns { 30 | switch pattern.Type { 31 | case typeEqual: 32 | a = []byte(strings.ReplaceAll(string(a), pattern.Value, "***")) 33 | case typeRegexp: 34 | a = pattern.Regexp.ReplaceAll(a, []byte("***")) 35 | } 36 | } 37 | _, err := w.w.Write(a) 38 | return len(p), err 39 | } 40 | 41 | func Mask(s string, patterns []*config.Mask) string { 42 | a := s 43 | for _, pattern := range patterns { 44 | switch pattern.Type { 45 | case typeEqual: 46 | a = strings.ReplaceAll(a, pattern.Value, "***") 47 | case typeRegexp: 48 | a = pattern.Regexp.ReplaceAllString(a, "***") 49 | } 50 | } 51 | return a 52 | } 53 | -------------------------------------------------------------------------------- /pkg/notifier/github/apply.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 10 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 11 | ) 12 | 13 | // Apply posts comment optimized for notifications 14 | func (g *NotifyService) Apply(ctx context.Context, param *notifier.ParamExec) error { 15 | cfg := g.client.Config 16 | parser := g.client.Config.Parser 17 | template := g.client.Config.Template 18 | var errMsgs []string 19 | 20 | if cfg.PR.Number == 0 { 21 | if prNumber, err := g.client.Commits.PRNumber(ctx, cfg.PR.Revision); err == nil { 22 | cfg.PR.Number = prNumber 23 | } 24 | } 25 | 26 | result := parser.Parse(param.CombinedOutput) 27 | if result.HasParseError { 28 | template = g.client.Config.ParseErrorTemplate 29 | } else { 30 | if result.Error != nil { 31 | return result.Error 32 | } 33 | if result.Result == "" { 34 | return result.Error 35 | } 36 | } 37 | 38 | template.SetValue(terraform.CommonTemplate{ 39 | Result: result.Result, 40 | ChangedResult: result.ChangedResult, 41 | ChangeOutsideTerraform: result.OutsideTerraform, 42 | Warning: result.Warning, 43 | HasDestroy: result.HasDestroy, 44 | HasError: result.HasError, 45 | Link: cfg.CI, 46 | UseRawOutput: cfg.UseRawOutput, 47 | Vars: cfg.Vars, 48 | Templates: cfg.Templates, 49 | Stdout: param.Stdout, 50 | Stderr: param.Stderr, 51 | CombinedOutput: param.CombinedOutput, 52 | ExitCode: param.ExitCode, 53 | ErrorMessages: errMsgs, 54 | CreatedResources: result.CreatedResources, 55 | UpdatedResources: result.UpdatedResources, 56 | DeletedResources: result.DeletedResources, 57 | ReplacedResources: result.ReplacedResources, 58 | }) 59 | body, err := template.Execute() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | logE := logrus.WithFields(logrus.Fields{ 65 | "program": "tfcmt", 66 | }) 67 | 68 | embeddedComment, err := getEmbeddedComment(cfg, param.CIName, false) 69 | if err != nil { 70 | return err 71 | } 72 | logE.WithFields(logrus.Fields{ 73 | "comment": embeddedComment, 74 | }).Debug("embedded HTML comment") 75 | // embed HTML tag to hide old comments 76 | body += embeddedComment 77 | 78 | body = mask.Mask(body, g.client.Config.Masks) 79 | 80 | logE.Debug("create a comment") 81 | if err := g.client.Comment.Post(ctx, body, &PostOptions{ 82 | Number: cfg.PR.Number, 83 | Revision: cfg.PR.Revision, 84 | }); err != nil { 85 | return fmt.Errorf("post a comment: %w", err) 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/notifier/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "strings" 8 | 9 | "github.com/google/go-github/v72/github" 10 | "github.com/shurcooL/githubv4" 11 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 12 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | // EnvBaseURL is GitHub base URL. This can be set to a domain endpoint to use with GitHub Enterprise. 17 | const EnvBaseURL = "GITHUB_BASE_URL" 18 | 19 | // Client is a API client for GitHub 20 | type Client struct { 21 | *github.Client 22 | Debug bool 23 | 24 | Config *Config 25 | 26 | common service 27 | 28 | Comment *CommentService 29 | Commits *CommitsService 30 | Notify *NotifyService 31 | User *UserService 32 | v4Client *githubv4.Client 33 | 34 | API API 35 | } 36 | 37 | // Config is a configuration for GitHub client 38 | type Config struct { 39 | BaseURL string 40 | GraphQLEndpoint string 41 | Owner string 42 | Repo string 43 | PR PullRequest 44 | CI string 45 | Parser terraform.Parser 46 | // Template is used for all Terraform command output 47 | Template *terraform.Template 48 | ParseErrorTemplate *terraform.Template 49 | // ResultLabels is a set of labels to apply depending on the plan result 50 | ResultLabels ResultLabels 51 | Vars map[string]string 52 | EmbeddedVarNames []string 53 | Templates map[string]string 54 | UseRawOutput bool 55 | Patch bool 56 | SkipNoChanges bool 57 | IgnoreWarning bool 58 | Masks []*config.Mask 59 | } 60 | 61 | // PullRequest represents GitHub Pull Request metadata 62 | type PullRequest struct { 63 | Revision string 64 | Number int 65 | } 66 | 67 | type service struct { 68 | client *Client 69 | } 70 | 71 | func getToken() (string, error) { 72 | if token := os.Getenv("TFCMT_GITHUB_TOKEN"); token != "" { 73 | return token, nil 74 | } 75 | if token := os.Getenv("GITHUB_TOKEN"); token != "" { 76 | return token, nil 77 | } 78 | return "", errors.New("github token is missing") 79 | } 80 | 81 | // NewClient returns Client initialized with Config 82 | func NewClient(ctx context.Context, cfg *Config) (*Client, error) { 83 | token, err := getToken() 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | tc := oauth2.NewClient(ctx, oauth2.StaticTokenSource( 89 | &oauth2.Token{AccessToken: token}, 90 | )) 91 | client := github.NewClient(tc) 92 | 93 | baseURL := cfg.BaseURL 94 | baseURL = strings.TrimPrefix(baseURL, "$") 95 | if baseURL == EnvBaseURL { 96 | baseURL = os.Getenv(EnvBaseURL) 97 | } 98 | if baseURL != "" { 99 | var err error 100 | client, err = github.NewClient(tc).WithEnterpriseURLs(baseURL, baseURL) 101 | if err != nil { 102 | return &Client{}, errors.New("failed to create a new github api client") 103 | } 104 | } 105 | 106 | c := &Client{ 107 | Config: cfg, 108 | Client: client, 109 | } 110 | if cfg.GraphQLEndpoint == "" { 111 | c.v4Client = githubv4.NewClient(tc) 112 | } else { 113 | c.v4Client = githubv4.NewEnterpriseClient(cfg.GraphQLEndpoint, tc) 114 | } 115 | 116 | c.common.client = c 117 | c.Comment = (*CommentService)(&c.common) 118 | c.Commits = (*CommitsService)(&c.common) 119 | c.Notify = (*NotifyService)(&c.common) 120 | c.User = (*UserService)(&c.common) 121 | 122 | c.API = &GitHub{ 123 | Client: client, 124 | owner: cfg.Owner, 125 | repo: cfg.Repo, 126 | } 127 | 128 | return c, nil 129 | } 130 | 131 | // IsNumber returns true if PullRequest is Pull Request build 132 | func (pr *PullRequest) IsNumber() bool { 133 | return pr.Number != 0 134 | } 135 | 136 | // ResultLabels represents the labels to add to the PR depending on the plan result 137 | type ResultLabels struct { 138 | AddOrUpdateLabel string 139 | DestroyLabel string 140 | NoChangesLabel string 141 | PlanErrorLabel string 142 | AddOrUpdateLabelColor string 143 | DestroyLabelColor string 144 | NoChangesLabelColor string 145 | PlanErrorLabelColor string 146 | } 147 | 148 | // HasAnyLabelDefined returns true if any of the internal labels are set 149 | func (r *ResultLabels) HasAnyLabelDefined() bool { 150 | return r.AddOrUpdateLabel != "" || r.DestroyLabel != "" || r.NoChangesLabel != "" || r.PlanErrorLabel != "" 151 | } 152 | 153 | // IsResultLabel returns true if a label matches any of the internal labels 154 | func (r *ResultLabels) IsResultLabel(label string) bool { 155 | switch label { 156 | case "": 157 | return false 158 | case r.AddOrUpdateLabel, r.DestroyLabel, r.NoChangesLabel, r.PlanErrorLabel: 159 | return true 160 | default: 161 | return false 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /pkg/notifier/github/client_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewClient(t *testing.T) { 8 | t.Setenv("GITHUB_TOKEN", "") 9 | 10 | testCases := []struct { 11 | name string 12 | config Config 13 | envToken string 14 | expect string 15 | }{ 16 | { 17 | name: "specify via env but not to be set env (part 1)", 18 | config: Config{}, 19 | envToken: "", 20 | expect: "github token is missing", 21 | }, 22 | { 23 | name: "specify via env (part 1)", 24 | config: Config{}, 25 | envToken: "abcdefg", 26 | expect: "", 27 | }, 28 | { 29 | name: "specify via env but not to be set env (part 2)", 30 | config: Config{}, 31 | envToken: "", 32 | expect: "github token is missing", 33 | }, 34 | { 35 | name: "specify via env (part 2)", 36 | config: Config{}, 37 | envToken: "abcdefg", 38 | expect: "", 39 | }, 40 | { 41 | name: "no specification (part 1)", 42 | config: Config{}, 43 | envToken: "", 44 | expect: "github token is missing", 45 | }, 46 | { 47 | name: "no specification (part 2)", 48 | config: Config{}, 49 | envToken: "abcdefg", 50 | expect: "github token is missing", 51 | }, 52 | } 53 | for _, testCase := range testCases { 54 | t.Run(testCase.name, func(t *testing.T) { 55 | t.Setenv("GITHUB_TOKEN", testCase.envToken) 56 | cfg := testCase.config 57 | _, err := NewClient(t.Context(), &cfg) 58 | if err == nil { 59 | return 60 | } 61 | if err.Error() != testCase.expect { 62 | t.Errorf("got %q but want %q", err.Error(), testCase.expect) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestNewClientWithBaseURL(t *testing.T) { 69 | t.Setenv("GITHUB_TOKEN", "xxx") 70 | t.Setenv(EnvBaseURL, "") 71 | 72 | testCases := []struct { 73 | name string 74 | config Config 75 | envBaseURL string 76 | expect string 77 | }{ 78 | { 79 | name: "specify directly", 80 | config: Config{ 81 | BaseURL: "https://git.example.com/api/v3/", 82 | }, 83 | envBaseURL: "", 84 | expect: "https://git.example.com/api/v3/", 85 | }, 86 | { 87 | name: "specify via env but not to be set env (part 1)", 88 | config: Config{ 89 | BaseURL: "GITHUB_BASE_URL", 90 | }, 91 | envBaseURL: "", 92 | expect: "https://api.github.com/", 93 | }, 94 | { 95 | name: "specify via env (part 1)", 96 | config: Config{ 97 | BaseURL: "GITHUB_BASE_URL", 98 | }, 99 | envBaseURL: "https://git.example.com/api/v3/", 100 | expect: "https://git.example.com/api/v3/", 101 | }, 102 | { 103 | name: "specify via env but not to be set env (part 2)", 104 | config: Config{ 105 | BaseURL: "$GITHUB_BASE_URL", 106 | }, 107 | envBaseURL: "", 108 | expect: "https://api.github.com/", 109 | }, 110 | { 111 | name: "specify via env (part 2)", 112 | config: Config{ 113 | BaseURL: "$GITHUB_BASE_URL", 114 | }, 115 | envBaseURL: "https://git.example.com/api/v3/", 116 | expect: "https://git.example.com/api/v3/", 117 | }, 118 | { 119 | name: "no specification (part 1)", 120 | config: Config{}, 121 | envBaseURL: "", 122 | expect: "https://api.github.com/", 123 | }, 124 | { 125 | name: "no specification (part 2)", 126 | config: Config{}, 127 | envBaseURL: "https://git.example.com/api/v3/", 128 | expect: "https://api.github.com/", 129 | }, 130 | } 131 | for _, testCase := range testCases { 132 | t.Run(testCase.name, func(t *testing.T) { 133 | t.Setenv(EnvBaseURL, testCase.envBaseURL) 134 | cfg := testCase.config 135 | c, err := NewClient(t.Context(), &cfg) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | url := c.BaseURL.String() 140 | if url != testCase.expect { 141 | t.Errorf("got %q but want %q", url, testCase.expect) 142 | } 143 | }) 144 | } 145 | } 146 | 147 | func TestIsNumber(t *testing.T) { 148 | t.Parallel() 149 | testCases := []struct { 150 | pr PullRequest 151 | isPR bool 152 | }{ 153 | { 154 | pr: PullRequest{ 155 | Number: 0, 156 | }, 157 | isPR: false, 158 | }, 159 | { 160 | pr: PullRequest{ 161 | Number: 123, 162 | }, 163 | isPR: true, 164 | }, 165 | } 166 | for _, testCase := range testCases { 167 | if testCase.pr.IsNumber() != testCase.isPR { 168 | t.Errorf("got %v but want %v", testCase.pr.IsNumber(), testCase.isPR) 169 | } 170 | } 171 | } 172 | 173 | func TestHasAnyLabelDefined(t *testing.T) { 174 | t.Parallel() 175 | testCases := []struct { 176 | rl ResultLabels 177 | want bool 178 | }{ 179 | { 180 | rl: ResultLabels{ 181 | AddOrUpdateLabel: "add-or-update", 182 | DestroyLabel: "destroy", 183 | NoChangesLabel: "no-changes", 184 | PlanErrorLabel: "error", 185 | }, 186 | want: true, 187 | }, 188 | { 189 | rl: ResultLabels{ 190 | AddOrUpdateLabel: "add-or-update", 191 | DestroyLabel: "destroy", 192 | NoChangesLabel: "", 193 | PlanErrorLabel: "error", 194 | }, 195 | want: true, 196 | }, 197 | { 198 | rl: ResultLabels{ 199 | AddOrUpdateLabel: "", 200 | DestroyLabel: "", 201 | NoChangesLabel: "", 202 | PlanErrorLabel: "", 203 | }, 204 | want: false, 205 | }, 206 | { 207 | rl: ResultLabels{}, 208 | want: false, 209 | }, 210 | } 211 | for _, testCase := range testCases { 212 | if testCase.rl.HasAnyLabelDefined() != testCase.want { 213 | t.Errorf("got %v but want %v", testCase.rl.HasAnyLabelDefined(), testCase.want) 214 | } 215 | } 216 | } 217 | 218 | func TestIsResultLabels(t *testing.T) { 219 | t.Parallel() 220 | testCases := []struct { 221 | rl ResultLabels 222 | label string 223 | want bool 224 | }{ 225 | { 226 | rl: ResultLabels{ 227 | AddOrUpdateLabel: "add-or-update", 228 | DestroyLabel: "destroy", 229 | NoChangesLabel: "no-changes", 230 | PlanErrorLabel: "error", 231 | }, 232 | label: "add-or-update", 233 | want: true, 234 | }, 235 | { 236 | rl: ResultLabels{ 237 | AddOrUpdateLabel: "add-or-update", 238 | DestroyLabel: "destroy", 239 | NoChangesLabel: "no-changes", 240 | PlanErrorLabel: "error", 241 | }, 242 | label: "my-label", 243 | want: false, 244 | }, 245 | { 246 | rl: ResultLabels{ 247 | AddOrUpdateLabel: "add-or-update", 248 | DestroyLabel: "destroy", 249 | NoChangesLabel: "no-changes", 250 | PlanErrorLabel: "error", 251 | }, 252 | label: "", 253 | want: false, 254 | }, 255 | { 256 | rl: ResultLabels{ 257 | AddOrUpdateLabel: "", 258 | DestroyLabel: "", 259 | NoChangesLabel: "no-changes", 260 | PlanErrorLabel: "", 261 | }, 262 | label: "", 263 | want: false, 264 | }, 265 | { 266 | rl: ResultLabels{}, 267 | label: "", 268 | want: false, 269 | }, 270 | } 271 | for _, testCase := range testCases { 272 | if testCase.rl.IsResultLabel(testCase.label) != testCase.want { 273 | t.Errorf("got %v but want %v", testCase.rl.IsResultLabel(testCase.label), testCase.want) 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /pkg/notifier/github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/google/go-github/v72/github" 9 | "github.com/shurcooL/githubv4" 10 | ) 11 | 12 | // CommentService handles communication with the comment related 13 | // methods of GitHub API 14 | type CommentService service 15 | 16 | // PostOptions specifies the optional parameters to post comments to a pull request 17 | type PostOptions struct { 18 | Number int 19 | Revision string 20 | } 21 | 22 | // Post posts comment 23 | func (g *CommentService) Post(ctx context.Context, body string, opt *PostOptions) error { 24 | if opt.Number != 0 { 25 | _, _, err := g.client.API.IssuesCreateComment( 26 | ctx, 27 | opt.Number, 28 | &github.IssueComment{Body: &body}, 29 | ) 30 | return err 31 | } 32 | if opt.Revision != "" { 33 | _, _, err := g.client.API.RepositoriesCreateComment( 34 | ctx, 35 | opt.Revision, 36 | &github.RepositoryComment{Body: &body}, 37 | ) 38 | return err 39 | } 40 | return errors.New("github.comment.post: Number or Revision is required") 41 | } 42 | 43 | func (g *CommentService) Patch(ctx context.Context, body string, commentID int64) error { 44 | _, _, err := g.client.API.IssuesEditComment( 45 | ctx, 46 | commentID, 47 | &github.IssueComment{Body: &body}, 48 | ) 49 | return err 50 | } 51 | 52 | type IssueComment struct { 53 | DatabaseID int 54 | Body string 55 | IsMinimized bool 56 | } 57 | 58 | func (g *CommentService) List(ctx context.Context, owner, repo string, number int) ([]*IssueComment, error) { 59 | cmts, prErr := g.listPRComment(ctx, owner, repo, number) 60 | if prErr == nil { 61 | return cmts, nil 62 | } 63 | cmts, err := g.listIssueComment(ctx, owner, repo, number) 64 | if err == nil { 65 | return cmts, nil 66 | } 67 | return nil, fmt.Errorf("get pull request or issue comments: %w, %v", prErr, err) //nolint:errorlint 68 | } 69 | 70 | func (g *CommentService) listIssueComment(ctx context.Context, owner, repo string, number int) ([]*IssueComment, error) { //nolint:dupl 71 | // https://github.com/shurcooL/githubv4#pagination 72 | var q struct { 73 | Repository struct { 74 | Issue struct { 75 | Comments struct { 76 | Nodes []*IssueComment 77 | PageInfo struct { 78 | EndCursor githubv4.String 79 | HasNextPage bool 80 | } 81 | } `graphql:"comments(first: 100, after: $commentsCursor)"` // 100 per page. 82 | } `graphql:"issue(number: $issueNumber)"` 83 | } `graphql:"repository(owner: $repositoryOwner, name: $repositoryName)"` 84 | } 85 | variables := map[string]any{ 86 | "repositoryOwner": githubv4.String(owner), 87 | "repositoryName": githubv4.String(repo), 88 | "issueNumber": githubv4.Int(number), //nolint:gosec 89 | "commentsCursor": (*githubv4.String)(nil), // Null after argument to get first page. 90 | } 91 | 92 | var allComments []*IssueComment 93 | for { 94 | if err := g.client.v4Client.Query(ctx, &q, variables); err != nil { 95 | return nil, fmt.Errorf("list issue comments by GitHub API: %w", err) 96 | } 97 | allComments = append(allComments, q.Repository.Issue.Comments.Nodes...) 98 | if !q.Repository.Issue.Comments.PageInfo.HasNextPage { 99 | break 100 | } 101 | variables["commentsCursor"] = githubv4.NewString(q.Repository.Issue.Comments.PageInfo.EndCursor) 102 | } 103 | return allComments, nil 104 | } 105 | 106 | func (g *CommentService) listPRComment(ctx context.Context, owner, repo string, number int) ([]*IssueComment, error) { //nolint:dupl 107 | // https://github.com/shurcooL/githubv4#pagination 108 | var q struct { 109 | Repository struct { 110 | PullRequest struct { 111 | Comments struct { 112 | Nodes []*IssueComment 113 | PageInfo struct { 114 | EndCursor githubv4.String 115 | HasNextPage bool 116 | } 117 | } `graphql:"comments(first: 100, after: $commentsCursor)"` // 100 per page. 118 | } `graphql:"pullRequest(number: $issueNumber)"` 119 | } `graphql:"repository(owner: $repositoryOwner, name: $repositoryName)"` 120 | } 121 | variables := map[string]any{ 122 | "repositoryOwner": githubv4.String(owner), 123 | "repositoryName": githubv4.String(repo), 124 | "issueNumber": githubv4.Int(number), //nolint:gosec 125 | "commentsCursor": (*githubv4.String)(nil), // Null after argument to get first page. 126 | } 127 | 128 | var allComments []*IssueComment 129 | for { 130 | if err := g.client.v4Client.Query(ctx, &q, variables); err != nil { 131 | return nil, fmt.Errorf("list issue comments by GitHub API: %w", err) 132 | } 133 | allComments = append(allComments, q.Repository.PullRequest.Comments.Nodes...) 134 | if !q.Repository.PullRequest.Comments.PageInfo.HasNextPage { 135 | break 136 | } 137 | variables["commentsCursor"] = githubv4.NewString(q.Repository.PullRequest.Comments.PageInfo.EndCursor) 138 | } 139 | return allComments, nil 140 | } 141 | -------------------------------------------------------------------------------- /pkg/notifier/github/comment_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommentPost(t *testing.T) { //nolint:tparallel 8 | t.Setenv("GITHUB_TOKEN", "xxx") 9 | testCases := []struct { 10 | name string 11 | config Config 12 | body string 13 | opt PostOptions 14 | ok bool 15 | }{ 16 | { 17 | name: "1", 18 | config: newFakeConfig(), 19 | body: "", 20 | opt: PostOptions{ 21 | Number: 1, 22 | Revision: "abcd", 23 | }, 24 | ok: true, 25 | }, 26 | { 27 | name: "2", 28 | config: newFakeConfig(), 29 | body: "", 30 | opt: PostOptions{ 31 | Number: 2, 32 | Revision: "", 33 | }, 34 | ok: true, 35 | }, 36 | { 37 | name: "3", 38 | config: newFakeConfig(), 39 | body: "", 40 | opt: PostOptions{ 41 | Number: 0, 42 | Revision: "", 43 | }, 44 | ok: false, 45 | }, 46 | } 47 | 48 | for _, testCase := range testCases { 49 | t.Run(testCase.name, func(t *testing.T) { 50 | t.Parallel() 51 | cfg := testCase.config 52 | client, err := NewClient(t.Context(), &cfg) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | api := newFakeAPI() 57 | client.API = &api 58 | opt := testCase.opt 59 | err = client.Comment.Post(t.Context(), testCase.body, &opt) 60 | if (err == nil) != testCase.ok { 61 | t.Errorf("got error %q", err) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/notifier/github/commits.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/google/go-github/v72/github" 8 | ) 9 | 10 | // CommitsService handles communication with the commits related 11 | // methods of GitHub API 12 | type CommitsService service 13 | 14 | func (g *CommitsService) PRNumber(ctx context.Context, sha string) (int, error) { 15 | prs, _, err := g.client.API.PullRequestsListPullRequestsWithCommit(ctx, sha, &github.ListOptions{}) 16 | if err != nil { 17 | return 0, err 18 | } 19 | if len(prs) == 0 { 20 | return 0, errors.New("associated pull request isn't found") 21 | } 22 | return prs[0].GetNumber(), nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/notifier/github/commits_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPRNumber(t *testing.T) { 8 | t.Setenv("GITHUB_TOKEN", "xxx") 9 | testCases := []struct { 10 | prNumber int 11 | ok bool 12 | revision string 13 | }{ 14 | { 15 | prNumber: 1, 16 | ok: true, 17 | revision: "xxx", 18 | }, 19 | } 20 | 21 | for _, testCase := range testCases { 22 | cfg := newFakeConfig() 23 | client, err := NewClient(t.Context(), &cfg) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | api := newFakeAPI() 28 | client.API = &api 29 | prNumber, err := client.Commits.PRNumber(t.Context(), testCase.revision) 30 | if (err == nil) != testCase.ok { 31 | t.Errorf("got error %q", err) 32 | } 33 | if prNumber != testCase.prNumber { 34 | t.Errorf("got %d but want %d", prNumber, testCase.prNumber) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/notifier/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/v72/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 | IssuesEditComment(ctx context.Context, commentID int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) 13 | IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) 14 | IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) 15 | IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) 16 | IssuesUpdateLabel(ctx context.Context, label, color string) (*github.Label, *github.Response, error) 17 | RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) 18 | PullRequestsListPullRequestsWithCommit(ctx context.Context, sha string, opt *github.ListOptions) ([]*github.PullRequest, *github.Response, error) 19 | } 20 | 21 | // GitHub represents the attribute information necessary for requesting GitHub API 22 | type GitHub struct { 23 | *github.Client 24 | owner string 25 | 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.Issues.CreateComment(ctx, g.owner, g.repo, number, comment) 31 | } 32 | 33 | func (g *GitHub) IssuesEditComment(ctx context.Context, commentID int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { 34 | return g.Issues.EditComment(ctx, g.owner, g.repo, commentID, comment) 35 | } 36 | 37 | // IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.AddLabelsToIssue 38 | func (g *GitHub) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 39 | return g.Issues.AddLabelsToIssue(ctx, g.owner, g.repo, number, labels) 40 | } 41 | 42 | // IssuesListLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListLabelsByIssue 43 | func (g *GitHub) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) { 44 | return g.Issues.ListLabelsByIssue(ctx, g.owner, g.repo, number, opt) 45 | } 46 | 47 | // IssuesRemoveLabel is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue 48 | func (g *GitHub) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) { 49 | return g.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, label) 50 | } 51 | 52 | // IssuesUpdateLabel is a wrapper of https://pkg.go.dev/github.com/google/go-github/github#IssuesService.EditLabel 53 | func (g *GitHub) IssuesUpdateLabel(ctx context.Context, label, color string) (*github.Label, *github.Response, error) { 54 | return g.Issues.EditLabel(ctx, g.owner, g.repo, label, &github.Label{ 55 | Color: &color, 56 | }) 57 | } 58 | 59 | // RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment 60 | func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 61 | return g.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment) 62 | } 63 | 64 | func (g *GitHub) PullRequestsListPullRequestsWithCommit(ctx context.Context, sha string, opt *github.ListOptions) ([]*github.PullRequest, *github.Response, error) { 65 | return g.PullRequests.ListPullRequestsWithCommit(ctx, g.owner, g.repo, sha, opt) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/notifier/github/github_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package github 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/google/go-github/v72/github" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 9 | ) 10 | 11 | type fakeAPI struct { 12 | API 13 | FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) 14 | FakeIssuesListLabels func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) 15 | FakeIssuesAddLabels func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) 16 | FakeIssuesRemoveLabel func(ctx context.Context, number int, label string) (*github.Response, error) 17 | FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) 18 | FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) 19 | FakeRepositoriesGetCommit func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) 20 | FakePullRequestsListPullRequestsWithCommit func(ctx context.Context, sha string, opt *github.ListOptions) ([]*github.PullRequest, *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) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) { 28 | return g.FakeIssuesListLabels(ctx, number, opt) 29 | } 30 | 31 | func (g *fakeAPI) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 32 | return g.FakeIssuesAddLabels(ctx, number, labels) 33 | } 34 | 35 | func (g *fakeAPI) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) { 36 | return g.FakeIssuesRemoveLabel(ctx, number, label) 37 | } 38 | 39 | func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 40 | return g.FakeRepositoriesCreateComment(ctx, sha, comment) 41 | } 42 | 43 | func (g *fakeAPI) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { 44 | return g.FakeRepositoriesListCommits(ctx, opt) 45 | } 46 | 47 | func (g *fakeAPI) RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) { 48 | return g.FakeRepositoriesGetCommit(ctx, sha) 49 | } 50 | 51 | func (g *fakeAPI) PullRequestsListPullRequestsWithCommit(ctx context.Context, sha string, opt *github.ListOptions) ([]*github.PullRequest, *github.Response, error) { 52 | return g.FakePullRequestsListPullRequestsWithCommit(ctx, sha, opt) 53 | } 54 | 55 | func newFakeAPI() fakeAPI { 56 | return fakeAPI{ 57 | FakeIssuesCreateComment: func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { 58 | return &github.IssueComment{ 59 | ID: github.Ptr(int64(371748792)), 60 | Body: github.Ptr("comment 1"), 61 | }, nil, nil 62 | }, 63 | FakeIssuesListLabels: func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { 64 | labels := []*github.Label{ 65 | { 66 | ID: github.Ptr(int64(371748792)), 67 | Name: github.Ptr("label 1"), 68 | }, 69 | { 70 | ID: github.Ptr(int64(371765743)), 71 | Name: github.Ptr("label 2"), 72 | }, 73 | } 74 | return labels, nil, nil 75 | }, 76 | FakeIssuesAddLabels: func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) { 77 | return nil, nil, nil 78 | }, 79 | FakeIssuesRemoveLabel: func(ctx context.Context, number int, label string) (*github.Response, error) { 80 | return nil, nil 81 | }, 82 | FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) { 83 | return &github.RepositoryComment{ 84 | ID: github.Ptr(int64(28427394)), 85 | CommitID: github.Ptr("04e0917e448b662c2b16330fad50e97af16ff27a"), 86 | Body: github.Ptr("comment 1"), 87 | }, nil, nil 88 | }, 89 | FakeRepositoriesListCommits: func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { 90 | commits := []*github.RepositoryCommit{ 91 | { 92 | SHA: github.Ptr("04e0917e448b662c2b16330fad50e97af16ff27a"), 93 | }, 94 | { 95 | SHA: github.Ptr("04e0917e448b662c2b16330fad50e97af16ff27b"), 96 | }, 97 | { 98 | SHA: github.Ptr("04e0917e448b662c2b16330fad50e97af16ff27c"), 99 | }, 100 | } 101 | return commits, nil, nil 102 | }, 103 | FakeRepositoriesGetCommit: func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) { 104 | return &github.RepositoryCommit{ 105 | SHA: github.Ptr(sha), 106 | Commit: &github.Commit{ 107 | Message: github.Ptr(sha), 108 | }, 109 | }, nil, nil 110 | }, 111 | FakePullRequestsListPullRequestsWithCommit: func(ctx context.Context, sha string, opt *github.ListOptions) ([]*github.PullRequest, *github.Response, error) { 112 | return []*github.PullRequest{ 113 | { 114 | State: github.Ptr("open"), 115 | Number: github.Ptr(1), 116 | }, 117 | { 118 | State: github.Ptr("closed"), 119 | Number: github.Ptr(2), 120 | }, 121 | }, nil, nil 122 | }, 123 | } 124 | } 125 | 126 | func newFakeConfig() Config { 127 | return Config{ 128 | Owner: "owner", 129 | Repo: "repo", 130 | PR: PullRequest{ 131 | Revision: "abcd", 132 | Number: 1, 133 | }, 134 | Parser: terraform.NewPlanParser(), 135 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /pkg/notifier/github/label.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/google/go-github/v72/github" 8 | "github.com/sirupsen/logrus" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 10 | ) 11 | 12 | func (g *NotifyService) UpdateLabels(ctx context.Context, result terraform.ParseResult) []string { //nolint:cyclop 13 | cfg := g.client.Config 14 | var ( 15 | labelToAdd string 16 | labelColor string 17 | ) 18 | 19 | switch { 20 | case result.HasAddOrUpdateOnly: 21 | labelToAdd = cfg.ResultLabels.AddOrUpdateLabel 22 | labelColor = cfg.ResultLabels.AddOrUpdateLabelColor 23 | case result.HasDestroy: 24 | labelToAdd = cfg.ResultLabels.DestroyLabel 25 | labelColor = cfg.ResultLabels.DestroyLabelColor 26 | case result.HasNoChanges: 27 | labelToAdd = cfg.ResultLabels.NoChangesLabel 28 | labelColor = cfg.ResultLabels.NoChangesLabelColor 29 | case result.HasError: 30 | labelToAdd = cfg.ResultLabels.PlanErrorLabel 31 | labelColor = cfg.ResultLabels.PlanErrorLabelColor 32 | } 33 | 34 | errMsgs := []string{} 35 | 36 | logE := logrus.WithFields(logrus.Fields{ 37 | "program": "tfcmt", 38 | }) 39 | 40 | currentLabelColor, err := g.removeResultLabels(ctx, labelToAdd) 41 | if err != nil { 42 | msg := "remove labels: " + err.Error() 43 | logE.WithError(err).Error("remove labels") 44 | errMsgs = append(errMsgs, msg) 45 | } 46 | 47 | if labelToAdd == "" { 48 | return errMsgs 49 | } 50 | 51 | if currentLabelColor == "" { 52 | labels, _, err := g.client.API.IssuesAddLabels(ctx, cfg.PR.Number, []string{labelToAdd}) 53 | if err != nil { 54 | msg := "add a label " + labelToAdd + ": " + err.Error() 55 | logE.WithError(err).WithFields(logrus.Fields{ 56 | "label": labelToAdd, 57 | }).Error("add a label") 58 | errMsgs = append(errMsgs, msg) 59 | } 60 | if labelColor != "" { 61 | // set the color of label 62 | for _, label := range labels { 63 | if labelToAdd == label.GetName() { 64 | if label.GetColor() != labelColor { 65 | if _, _, err := g.client.API.IssuesUpdateLabel(ctx, labelToAdd, labelColor); err != nil { 66 | msg := "update a label color (name: " + labelToAdd + ", color: " + labelColor + "): " + err.Error() 67 | logE.WithError(err).WithFields(logrus.Fields{ 68 | "label": labelToAdd, 69 | "color": labelColor, 70 | }).Error("update a label color") 71 | errMsgs = append(errMsgs, msg) 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } else if labelColor != "" && labelColor != currentLabelColor { 78 | // set the color of label 79 | if _, _, err := g.client.API.IssuesUpdateLabel(ctx, labelToAdd, labelColor); err != nil { 80 | msg := "update a label color (name: " + labelToAdd + ", color: " + labelColor + "): " + err.Error() 81 | logE.WithError(err).WithFields(logrus.Fields{ 82 | "label": labelToAdd, 83 | "color": labelColor, 84 | }).Error("update a label color") 85 | errMsgs = append(errMsgs, msg) 86 | } 87 | } 88 | return errMsgs 89 | } 90 | 91 | func (g *NotifyService) removeResultLabels(ctx context.Context, label string) (string, error) { 92 | cfg := g.client.Config 93 | // A Pull Request can have 100 labels the maximum 94 | labels, _, err := g.client.API.IssuesListLabels(ctx, cfg.PR.Number, &github.ListOptions{ 95 | PerPage: 100, //nolint:mnd 96 | }) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | labelColor := "" 102 | for _, l := range labels { 103 | labelText := l.GetName() 104 | if labelText == label { 105 | labelColor = l.GetColor() 106 | continue 107 | } 108 | if cfg.ResultLabels.IsResultLabel(labelText) { 109 | resp, err := g.client.API.IssuesRemoveLabel(ctx, cfg.PR.Number, labelText) 110 | // Ignore 404 errors, which are from the PR not having the label 111 | if err != nil && resp.StatusCode != http.StatusNotFound { 112 | return labelColor, err 113 | } 114 | } 115 | } 116 | 117 | return labelColor, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/notifier/github/notify.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/github-comment-metadata/metadata" 8 | ) 9 | 10 | // NotifyService handles communication with the notification related 11 | // methods of GitHub API 12 | type NotifyService service 13 | 14 | func (g *NotifyService) getPatchedComment(logE *logrus.Entry, comments []*IssueComment, target string) *IssueComment { 15 | var cmt *IssueComment 16 | for i, comment := range comments { 17 | logE := logE.WithFields(logrus.Fields{ 18 | "comment_database_id": comment.DatabaseID, 19 | "comment_index": i, 20 | }) 21 | data := &Metadata{} 22 | f, err := metadata.Extract(comment.Body, data) 23 | if err != nil { 24 | logE.WithError(err).Debug("extract metadata from comment") 25 | continue 26 | } 27 | if !f { 28 | logE.Debug("metadata isn't found") 29 | continue 30 | } 31 | if data.Program != "tfcmt" { 32 | logE.Debug("Program isn't tfcmt") 33 | continue 34 | } 35 | if data.Command != "plan" { 36 | logE.Debug("Command isn't plan") 37 | continue 38 | } 39 | if data.Target != target { 40 | logE.Debug("target is different") 41 | continue 42 | } 43 | if comment.IsMinimized { 44 | logE.Debug("comment is hidden") 45 | continue 46 | } 47 | cmt = comment 48 | } 49 | return cmt 50 | } 51 | 52 | type Metadata struct { 53 | Target string 54 | Program string 55 | Command string 56 | } 57 | 58 | func getEmbeddedComment(cfg *Config, ciName string, isPlan bool) (string, error) { 59 | vars := make(map[string]any, len(cfg.EmbeddedVarNames)) 60 | for _, name := range cfg.EmbeddedVarNames { 61 | vars[name] = cfg.Vars[name] 62 | } 63 | 64 | data := map[string]any{ 65 | "Program": "tfcmt", 66 | "Vars": vars, 67 | "SHA1": cfg.PR.Revision, 68 | "PRNumber": cfg.PR.Number, 69 | } 70 | if target := cfg.Vars["target"]; target != "" { 71 | data["Target"] = target 72 | } 73 | if isPlan { 74 | data["Command"] = "plan" 75 | } else { 76 | data["Command"] = "apply" 77 | } 78 | if err := metadata.SetCIEnv(ciName, os.Getenv, data); err != nil { 79 | return "", err 80 | } 81 | embeddedComment, err := metadata.Convert(data) 82 | if err != nil { 83 | return "", err 84 | } 85 | return embeddedComment, nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/notifier/github/notify_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 7 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 8 | ) 9 | 10 | func TestNotifyApply(t *testing.T) { //nolint:tparallel 11 | t.Setenv("GITHUB_TOKEN", "xxx") 12 | testCases := []struct { 13 | name string 14 | config Config 15 | ok bool 16 | paramExec notifier.ParamExec 17 | }{ 18 | { 19 | name: "case 8", 20 | // apply case without merge commit 21 | config: Config{ 22 | Owner: "owner", 23 | Repo: "repo", 24 | PR: PullRequest{ 25 | Revision: "revision", 26 | Number: 0, // For apply, it is always 0 27 | }, 28 | Parser: terraform.NewApplyParser(), 29 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), 30 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 31 | }, 32 | paramExec: notifier.ParamExec{ 33 | Stdout: "Apply complete!", 34 | ExitCode: 0, 35 | }, 36 | ok: true, 37 | }, 38 | { 39 | name: "case 9", 40 | // apply case as merge commit 41 | // TODO(drlau): validate cfg.PR.Number = 123 42 | config: Config{ 43 | Owner: "owner", 44 | Repo: "repo", 45 | PR: PullRequest{ 46 | Revision: "Merge pull request #123 from suzuki-shunsuke/tfcmt", 47 | Number: 0, // For apply, it is always 0 48 | }, 49 | Parser: terraform.NewApplyParser(), 50 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate), 51 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 52 | }, 53 | paramExec: notifier.ParamExec{ 54 | Stdout: "Apply complete!", 55 | ExitCode: 0, 56 | }, 57 | ok: true, 58 | }, 59 | } 60 | 61 | for i, testCase := range testCases { 62 | if testCase.name == "" { 63 | t.Fatalf("testCase.name is required: index: %d", i) 64 | } 65 | t.Run(testCase.name, func(t *testing.T) { 66 | t.Parallel() 67 | cfg := testCase.config 68 | client, err := NewClient(t.Context(), &cfg) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | api := newFakeAPI() 73 | client.API = &api 74 | paramExec := testCase.paramExec 75 | if err := client.Notify.Apply(t.Context(), ¶mExec); (err == nil) != testCase.ok { 76 | t.Errorf("got error %v", err) 77 | } 78 | }) 79 | } 80 | } 81 | 82 | func TestNotifyPlan(t *testing.T) { //nolint:tparallel 83 | t.Setenv("GITHUB_TOKEN", "xxx") 84 | testCases := []struct { 85 | name string 86 | config Config 87 | ok bool 88 | paramExec notifier.ParamExec 89 | }{ 90 | { 91 | name: "case 0", 92 | // invalid body (cannot parse) 93 | config: Config{ 94 | Owner: "owner", 95 | Repo: "repo", 96 | PR: PullRequest{ 97 | Revision: "abcd", 98 | Number: 1, 99 | }, 100 | Parser: terraform.NewPlanParser(), 101 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 102 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 103 | }, 104 | paramExec: notifier.ParamExec{ 105 | Stdout: "body", 106 | ExitCode: 1, 107 | }, 108 | ok: true, 109 | }, 110 | { 111 | name: "case 1", 112 | // invalid pr 113 | config: Config{ 114 | Owner: "owner", 115 | Repo: "repo", 116 | PR: PullRequest{ 117 | Revision: "", 118 | Number: 0, 119 | }, 120 | Parser: terraform.NewPlanParser(), 121 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 122 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 123 | }, 124 | paramExec: notifier.ParamExec{ 125 | Stdout: "Plan: 1 to add", 126 | ExitCode: 0, 127 | }, 128 | ok: false, 129 | }, 130 | { 131 | name: "case 2", 132 | // valid, error 133 | config: Config{ 134 | Owner: "owner", 135 | Repo: "repo", 136 | PR: PullRequest{ 137 | Revision: "", 138 | Number: 1, 139 | }, 140 | Parser: terraform.NewPlanParser(), 141 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 142 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 143 | }, 144 | paramExec: notifier.ParamExec{ 145 | Stdout: "Error: hoge", 146 | ExitCode: 1, 147 | }, 148 | ok: true, 149 | }, 150 | { 151 | name: "case 3", 152 | // valid, and isPR 153 | config: Config{ 154 | Owner: "owner", 155 | Repo: "repo", 156 | PR: PullRequest{ 157 | Revision: "", 158 | Number: 1, 159 | }, 160 | Parser: terraform.NewPlanParser(), 161 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 162 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 163 | }, 164 | paramExec: notifier.ParamExec{ 165 | Stdout: "Plan: 1 to add", 166 | ExitCode: 2, 167 | }, 168 | ok: true, 169 | }, 170 | { 171 | name: "case 4", 172 | // valid, and isRevision 173 | config: Config{ 174 | Owner: "owner", 175 | Repo: "repo", 176 | PR: PullRequest{ 177 | Revision: "revision-revision", 178 | Number: 0, 179 | }, 180 | Parser: terraform.NewPlanParser(), 181 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 182 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 183 | }, 184 | paramExec: notifier.ParamExec{ 185 | Stdout: "Plan: 1 to add", 186 | ExitCode: 2, 187 | }, 188 | ok: true, 189 | }, 190 | { 191 | name: "case 5", 192 | // valid, and contains destroy 193 | // TODO(dtan4): check two comments were made actually 194 | config: Config{ 195 | Owner: "owner", 196 | Repo: "repo", 197 | PR: PullRequest{ 198 | Revision: "", 199 | Number: 1, 200 | }, 201 | Parser: terraform.NewPlanParser(), 202 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 203 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 204 | }, 205 | paramExec: notifier.ParamExec{ 206 | Stdout: "Plan: 1 to add, 1 to destroy", 207 | ExitCode: 2, 208 | }, 209 | ok: true, 210 | }, 211 | { 212 | name: "case 6", 213 | // valid with no changes 214 | // TODO(drlau): check that the label was actually added 215 | config: Config{ 216 | Owner: "owner", 217 | Repo: "repo", 218 | PR: PullRequest{ 219 | Revision: "", 220 | Number: 1, 221 | }, 222 | Parser: terraform.NewPlanParser(), 223 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 224 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 225 | ResultLabels: ResultLabels{ 226 | AddOrUpdateLabel: "add-or-update", 227 | DestroyLabel: "destroy", 228 | NoChangesLabel: "no-changes", 229 | PlanErrorLabel: "error", 230 | }, 231 | }, 232 | paramExec: notifier.ParamExec{ 233 | Stdout: "No changes. Infrastructure is up-to-date.", 234 | ExitCode: 0, 235 | }, 236 | ok: true, 237 | }, 238 | { 239 | name: "case 7", 240 | // valid, contains destroy, but not to notify 241 | config: Config{ 242 | Owner: "owner", 243 | Repo: "repo", 244 | PR: PullRequest{ 245 | Revision: "", 246 | Number: 1, 247 | }, 248 | Parser: terraform.NewPlanParser(), 249 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate), 250 | ParseErrorTemplate: terraform.NewPlanParseErrorTemplate(terraform.DefaultPlanTemplate), 251 | }, 252 | paramExec: notifier.ParamExec{ 253 | Stdout: "Plan: 1 to add, 1 to destroy", 254 | ExitCode: 2, 255 | }, 256 | ok: true, 257 | }, 258 | } 259 | 260 | for i, testCase := range testCases { 261 | if testCase.name == "" { 262 | t.Fatalf("testCase.name is required: index: %d", i) 263 | } 264 | t.Run(testCase.name, func(t *testing.T) { 265 | t.Parallel() 266 | cfg := testCase.config 267 | client, err := NewClient(t.Context(), &cfg) 268 | if err != nil { 269 | t.Fatal(err) 270 | } 271 | api := newFakeAPI() 272 | client.API = &api 273 | paramExec := testCase.paramExec 274 | if err := client.Notify.Plan(t.Context(), ¶mExec); (err == nil) != testCase.ok { 275 | t.Errorf("got error %v", err) 276 | } 277 | }) 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /pkg/notifier/github/plan.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 10 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 11 | ) 12 | 13 | // Plan posts comment optimized for notifications 14 | func (g *NotifyService) Plan(ctx context.Context, param *notifier.ParamExec) error { //nolint:cyclop 15 | cfg := g.client.Config 16 | parser := g.client.Config.Parser 17 | template := g.client.Config.Template 18 | var errMsgs []string 19 | 20 | if cfg.PR.Number == 0 && cfg.PR.Revision != "" { 21 | if prNumber, err := g.client.Commits.PRNumber(ctx, cfg.PR.Revision); err == nil { 22 | cfg.PR.Number = prNumber 23 | } 24 | } 25 | 26 | result := parser.Parse(param.CombinedOutput) 27 | if result.HasParseError { 28 | template = g.client.Config.ParseErrorTemplate 29 | } else { 30 | if result.Error != nil { 31 | return result.Error 32 | } 33 | if result.Result == "" { 34 | return result.Error 35 | } 36 | } 37 | 38 | if cfg.PR.IsNumber() && cfg.ResultLabels.HasAnyLabelDefined() { 39 | errMsgs = append(errMsgs, g.UpdateLabels(ctx, result)...) 40 | } 41 | 42 | if cfg.IgnoreWarning { 43 | result.Warning = "" 44 | } 45 | 46 | template.SetValue(terraform.CommonTemplate{ 47 | Result: result.Result, 48 | ChangedResult: result.ChangedResult, 49 | ChangeOutsideTerraform: result.OutsideTerraform, 50 | Warning: result.Warning, 51 | HasDestroy: result.HasDestroy, 52 | HasError: result.HasError, 53 | Link: cfg.CI, 54 | UseRawOutput: cfg.UseRawOutput, 55 | Vars: cfg.Vars, 56 | Templates: cfg.Templates, 57 | Stdout: param.Stdout, 58 | Stderr: param.Stderr, 59 | CombinedOutput: param.CombinedOutput, 60 | ExitCode: param.ExitCode, 61 | ErrorMessages: errMsgs, 62 | CreatedResources: result.CreatedResources, 63 | UpdatedResources: result.UpdatedResources, 64 | DeletedResources: result.DeletedResources, 65 | ReplacedResources: result.ReplacedResources, 66 | MovedResources: result.MovedResources, 67 | ImportedResources: result.ImportedResources, 68 | }) 69 | body, err := template.Execute() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | logE := logrus.WithFields(logrus.Fields{ 75 | "program": "tfcmt", 76 | }) 77 | 78 | embeddedComment, err := getEmbeddedComment(cfg, param.CIName, true) 79 | if err != nil { 80 | return err 81 | } 82 | logE.WithFields(logrus.Fields{ 83 | "comment": embeddedComment, 84 | }).Debug("embedded HTML comment") 85 | // embed HTML tag to hide old comments 86 | body += embeddedComment 87 | 88 | body = mask.Mask(body, g.client.Config.Masks) 89 | 90 | if cfg.Patch && cfg.PR.Number != 0 { 91 | logE.Debug("try patching") 92 | comments, err := g.client.Comment.List(ctx, cfg.Owner, cfg.Repo, cfg.PR.Number) 93 | if err != nil { 94 | logE.WithError(err).Debug("list comments") 95 | // Post a new comment instead of patching an existing comment 96 | if err := g.client.Comment.Post(ctx, body, &PostOptions{ 97 | Number: cfg.PR.Number, 98 | Revision: cfg.PR.Revision, 99 | }); err != nil { 100 | return fmt.Errorf("post a comment: %w", err) 101 | } 102 | return nil 103 | } 104 | logE.WithField("size", len(comments)).Debug("list comments") 105 | comment := g.getPatchedComment(logE, comments, cfg.Vars["target"]) 106 | if comment != nil { 107 | if comment.Body == body { 108 | logE.Debug("comment isn't changed") 109 | return nil 110 | } 111 | logE.WithField("comment_id", comment.DatabaseID).Debug("patch a comment") 112 | if err := g.client.Comment.Patch(ctx, body, int64(comment.DatabaseID)); err != nil { 113 | return fmt.Errorf("patch a comment: %w", err) 114 | } 115 | return nil 116 | } 117 | } 118 | 119 | if result.HasNoChanges && result.Warning == "" && len(errMsgs) == 0 && cfg.SkipNoChanges { 120 | logE.Debug("skip posting a comment because there is no change") 121 | return nil 122 | } 123 | 124 | logE.Debug("create a comment") 125 | if err := g.client.Comment.Post(ctx, body, &PostOptions{ 126 | Number: cfg.PR.Number, 127 | Revision: cfg.PR.Revision, 128 | }); err != nil { 129 | return fmt.Errorf("post a comment: %w", err) 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /pkg/notifier/github/user.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "context" 4 | 5 | type UserService service 6 | 7 | func (g *UserService) Get(ctx context.Context) (string, error) { 8 | user, _, err := g.client.Users.Get(ctx, "") 9 | if err != nil { 10 | return "", err 11 | } 12 | return user.GetLogin(), nil 13 | } 14 | -------------------------------------------------------------------------------- /pkg/notifier/localfile/apply.go: -------------------------------------------------------------------------------- 1 | package localfile 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 10 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 11 | ) 12 | 13 | // Apply posts comment optimized for notifications 14 | func (g *NotifyService) Apply(_ context.Context, param *notifier.ParamExec) error { 15 | cfg := g.client.Config 16 | parser := g.client.Config.Parser 17 | template := g.client.Config.Template 18 | var errMsgs []string 19 | 20 | result := parser.Parse(param.CombinedOutput) 21 | if result.HasParseError { 22 | template = g.client.Config.ParseErrorTemplate 23 | } else { 24 | if result.Error != nil { 25 | return result.Error 26 | } 27 | if result.Result == "" { 28 | return result.Error 29 | } 30 | } 31 | 32 | template.SetValue(terraform.CommonTemplate{ 33 | Result: result.Result, 34 | ChangedResult: result.ChangedResult, 35 | ChangeOutsideTerraform: result.OutsideTerraform, 36 | Warning: result.Warning, 37 | HasDestroy: result.HasDestroy, 38 | HasError: result.HasError, 39 | Link: cfg.CI, 40 | UseRawOutput: cfg.UseRawOutput, 41 | Vars: cfg.Vars, 42 | Templates: cfg.Templates, 43 | Stdout: param.Stdout, 44 | Stderr: param.Stderr, 45 | CombinedOutput: param.CombinedOutput, 46 | ExitCode: param.ExitCode, 47 | ErrorMessages: errMsgs, 48 | CreatedResources: result.CreatedResources, 49 | UpdatedResources: result.UpdatedResources, 50 | DeletedResources: result.DeletedResources, 51 | ReplacedResources: result.ReplacedResources, 52 | }) 53 | body, err := template.Execute() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | logE := logrus.WithFields(logrus.Fields{ 59 | "program": "tfcmt", 60 | }) 61 | 62 | body = mask.Mask(body, g.client.Config.Masks) 63 | 64 | logE.Debug("writing the apply result to a file") 65 | if err := g.client.Output.WriteToFile(body, cfg.OutputFile); err != nil { 66 | return fmt.Errorf("write the apply result to a file: %w", err) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/notifier/localfile/client.go: -------------------------------------------------------------------------------- 1 | package localfile 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 7 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier/github" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 9 | ) 10 | 11 | // Client is a fake API client for write to local file 12 | type Client struct { 13 | Debug bool 14 | 15 | Config *Config 16 | 17 | common service 18 | 19 | Notify *NotifyService 20 | Output *OutputService 21 | labeler Labeler 22 | } 23 | 24 | // Config is a configuration for local file 25 | type Config struct { 26 | OutputFile string 27 | Parser terraform.Parser 28 | // Template is used for all Terraform command output 29 | Template *terraform.Template 30 | ParseErrorTemplate *terraform.Template 31 | Vars map[string]string 32 | EmbeddedVarNames []string 33 | Templates map[string]string 34 | CI string 35 | UseRawOutput bool 36 | Masks []*config.Mask 37 | 38 | // For labeling 39 | DisableLabel bool 40 | } 41 | 42 | type GitHubLabelConfig struct { 43 | BaseURL string 44 | GraphQLEndpoint string 45 | Owner string 46 | Repo string 47 | PRNumber int 48 | Revision string 49 | Labels github.ResultLabels 50 | } 51 | 52 | type service struct { 53 | client *Client 54 | } 55 | 56 | type Labeler interface { 57 | UpdateLabels(ctx context.Context, result terraform.ParseResult) []string 58 | } 59 | 60 | // NewClient returns Client initialized with Config 61 | func NewClient(cfg *Config, labeler Labeler) (*Client, error) { 62 | c := &Client{ 63 | Config: cfg, 64 | labeler: labeler, 65 | } 66 | 67 | c.common.client = c 68 | 69 | c.Notify = (*NotifyService)(&c.common) 70 | 71 | return c, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/notifier/localfile/notify.go: -------------------------------------------------------------------------------- 1 | package localfile 2 | 3 | // NotifyService handles communication with the notification related 4 | // methods of GitHub API 5 | type NotifyService service 6 | -------------------------------------------------------------------------------- /pkg/notifier/localfile/output.go: -------------------------------------------------------------------------------- 1 | package localfile 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type OutputService service 9 | 10 | const filePermission os.FileMode = 0o644 11 | 12 | // WriteToFile Write result to file 13 | func (f *OutputService) WriteToFile(body string, outputFile string) error { 14 | file, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, filePermission) 15 | if err != nil { 16 | return fmt.Errorf("open a file to output the result to a file: %w", err) 17 | } 18 | 19 | defer file.Close() 20 | 21 | if _, err := file.WriteString(body + "\n"); err != nil { 22 | return fmt.Errorf("write the result to a file: %w", err) 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/notifier/localfile/plan.go: -------------------------------------------------------------------------------- 1 | package localfile 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/mask" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/notifier" 10 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/terraform" 11 | ) 12 | 13 | // Plan posts comment optimized for notifications 14 | func (g *NotifyService) Plan(ctx context.Context, param *notifier.ParamExec) error { 15 | cfg := g.client.Config 16 | parser := g.client.Config.Parser 17 | template := g.client.Config.Template 18 | var errMsgs []string 19 | 20 | result := parser.Parse(param.CombinedOutput) 21 | if result.HasParseError { 22 | template = g.client.Config.ParseErrorTemplate 23 | } else { 24 | if result.Error != nil { 25 | return result.Error 26 | } 27 | if result.Result == "" { 28 | return result.Error 29 | } 30 | } 31 | 32 | logE := logrus.WithFields(logrus.Fields{ 33 | "program": "tfcmt", 34 | }) 35 | if !cfg.DisableLabel { 36 | logE.Debugf("updating labels") 37 | errMsgs = append(errMsgs, g.client.labeler.UpdateLabels(ctx, result)...) 38 | } 39 | 40 | template.SetValue(terraform.CommonTemplate{ 41 | Result: result.Result, 42 | ChangedResult: result.ChangedResult, 43 | ChangeOutsideTerraform: result.OutsideTerraform, 44 | Warning: result.Warning, 45 | HasDestroy: result.HasDestroy, 46 | HasError: result.HasError, 47 | Link: cfg.CI, 48 | UseRawOutput: cfg.UseRawOutput, 49 | Vars: cfg.Vars, 50 | Templates: cfg.Templates, 51 | Stdout: param.Stdout, 52 | Stderr: param.Stderr, 53 | CombinedOutput: param.CombinedOutput, 54 | ExitCode: param.ExitCode, 55 | ErrorMessages: errMsgs, 56 | CreatedResources: result.CreatedResources, 57 | UpdatedResources: result.UpdatedResources, 58 | DeletedResources: result.DeletedResources, 59 | ReplacedResources: result.ReplacedResources, 60 | MovedResources: result.MovedResources, 61 | ImportedResources: result.ImportedResources, 62 | }) 63 | body, err := template.Execute() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | body = mask.Mask(body, g.client.Config.Masks) 69 | 70 | logE.Debug("write a plan output to a file") 71 | if err := g.client.Output.WriteToFile(body, cfg.OutputFile); err != nil { 72 | return fmt.Errorf("write a plan output to a file: %w", err) 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/notifier/notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Notifier is a notification interface 8 | type Notifier interface { 9 | Apply(ctx context.Context, param *ParamExec) error 10 | Plan(ctx context.Context, param *ParamExec) error 11 | } 12 | 13 | type ParamExec struct { 14 | Stdout string 15 | Stderr string 16 | CombinedOutput string 17 | CIName string 18 | ExitCode int 19 | } 20 | -------------------------------------------------------------------------------- /pkg/platform/ci.go: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/suzuki-shunsuke/go-ci-env/v3/cienv" 9 | "github.com/suzuki-shunsuke/tfcmt/v4/pkg/config" 10 | ) 11 | 12 | func Complement(cfg *config.Config) error { 13 | if cfg.RepoOwner != "" { 14 | cfg.CI.Owner = cfg.RepoOwner 15 | } 16 | if cfg.RepoName != "" { 17 | cfg.CI.Repo = cfg.RepoName 18 | } 19 | if err := complementWithCIEnv(&cfg.CI); err != nil { 20 | return fmt.Errorf("complement parameters with CI specific environment variables: %w", err) 21 | } 22 | 23 | if err := complementCIInfo(&cfg.CI); err != nil { 24 | return fmt.Errorf("complement parameters with ci-info's environment variables: %w", err) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func complementCIInfo(ci *config.CI) error { 31 | if ci.PRNumber <= 0 { 32 | // support suzuki-shunsuke/ci-info 33 | if prS := os.Getenv("CI_INFO_PR_NUMBER"); prS != "" { 34 | a, err := strconv.Atoi(prS) 35 | if err != nil { 36 | return fmt.Errorf("parse CI_INFO_PR_NUMBER %s: %w", prS, err) 37 | } 38 | ci.PRNumber = a 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func getLink(ciname string) string { 45 | switch ciname { 46 | case "circleci", "circle-ci": 47 | return os.Getenv("CIRCLE_BUILD_URL") 48 | case "codebuild": 49 | return os.Getenv("CODEBUILD_BUILD_URL") 50 | case "github-actions": 51 | return fmt.Sprintf( 52 | "%s/%s/actions/runs/%s", 53 | os.Getenv("GITHUB_SERVER_URL"), 54 | os.Getenv("GITHUB_REPOSITORY"), 55 | os.Getenv("GITHUB_RUN_ID"), 56 | ) 57 | case "google-cloud-build": 58 | region := os.Getenv("_REGION") 59 | if region == "" { 60 | region = "global" 61 | } 62 | return fmt.Sprintf( 63 | "https://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s", 64 | region, 65 | os.Getenv("BUILD_ID"), 66 | os.Getenv("PROJECT_ID"), 67 | ) 68 | } 69 | return "" 70 | } 71 | 72 | func complementWithCIEnv(ci *config.CI) error { 73 | cienv.Add(func(param *cienv.Param) cienv.Platform { 74 | return NewGoogleCloudBuild(param) 75 | }) 76 | if pt := cienv.Get(nil); pt != nil { 77 | ci.Name = pt.ID() 78 | 79 | if ci.Owner == "" { 80 | ci.Owner = pt.RepoOwner() 81 | } 82 | 83 | if ci.Repo == "" { 84 | ci.Repo = pt.RepoName() 85 | } 86 | 87 | if ci.SHA == "" { 88 | ci.SHA = pt.SHA() 89 | } 90 | 91 | if ci.PRNumber <= 0 { 92 | n, err := pt.PRNumber() 93 | if err != nil { 94 | return err 95 | } 96 | ci.PRNumber = n 97 | } 98 | 99 | if ci.Link == "" { 100 | ci.Link = getLink(ci.Name) 101 | } 102 | } 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/platform/google_cloud_build.go: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/suzuki-shunsuke/go-ci-env/v3/cienv" 9 | ) 10 | 11 | type GoogleCloudBuild struct { 12 | getenv func(string) string 13 | } 14 | 15 | func NewGoogleCloudBuild(param *cienv.Param) *GoogleCloudBuild { 16 | if param == nil || param.Getenv == nil { 17 | return &GoogleCloudBuild{ 18 | getenv: os.Getenv, 19 | } 20 | } 21 | return &GoogleCloudBuild{ 22 | getenv: param.Getenv, 23 | } 24 | } 25 | 26 | func (cb *GoogleCloudBuild) ID() string { 27 | return "google-cloud-build" 28 | } 29 | 30 | func (cb *GoogleCloudBuild) Match() bool { 31 | return cb.getenv("GOOGLE_CLOUD_BUILD") != "" 32 | } 33 | 34 | func (cb *GoogleCloudBuild) RepoOwner() string { 35 | return "" 36 | } 37 | 38 | func (cb *GoogleCloudBuild) RepoName() string { 39 | return "" 40 | } 41 | 42 | func (cb *GoogleCloudBuild) Ref() string { 43 | return "" 44 | } 45 | 46 | func (cb *GoogleCloudBuild) Tag() string { 47 | return "" 48 | } 49 | 50 | func (cb *GoogleCloudBuild) Branch() string { 51 | return "" 52 | } 53 | 54 | func (cb *GoogleCloudBuild) PRBaseBranch() string { 55 | return "" 56 | } 57 | 58 | func (cb *GoogleCloudBuild) SHA() string { 59 | return cb.getenv("COMMIT_SHA") 60 | } 61 | 62 | func (cb *GoogleCloudBuild) IsPR() bool { 63 | return cb.getenv("_PR_NUMBER") != "" 64 | } 65 | 66 | func (cb *GoogleCloudBuild) PRNumber() (int, error) { 67 | pr := cb.getenv("_PR_NUMBER") 68 | if pr == "" { 69 | return 0, nil 70 | } 71 | b, err := strconv.Atoi(pr) 72 | if err == nil { 73 | return b, nil 74 | } 75 | return 0, fmt.Errorf("_PR_NUMBER is invalid. It failed to parse _PR_NUMBER as an integer: %w", err) 76 | } 77 | 78 | func (cb *GoogleCloudBuild) JobURL() string { 79 | region := cb.getenv("_REGION") 80 | if region == "" { 81 | region = "global" 82 | } 83 | return fmt.Sprintf( 84 | "https://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s", 85 | region, 86 | cb.getenv("BUILD_ID"), 87 | cb.getenv("PROJECT_ID"), 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | htmltemplate "html/template" 5 | texttemplate "text/template" 6 | 7 | "github.com/Masterminds/sprig/v3" 8 | ) 9 | 10 | func TxtFuncMap() texttemplate.FuncMap { 11 | // delete some functions for security reason 12 | funcs := sprig.TxtFuncMap() 13 | delete(funcs, "env") 14 | delete(funcs, "expandenv") 15 | delete(funcs, "getHostByName") 16 | return funcs 17 | } 18 | 19 | func FuncMap() htmltemplate.FuncMap { 20 | // delete some functions for security reason 21 | funcs := sprig.FuncMap() 22 | delete(funcs, "env") 23 | delete(funcs, "expandenv") 24 | delete(funcs, "getHostByName") 25 | return funcs 26 | } 27 | -------------------------------------------------------------------------------- /pkg/terraform/parser.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "errors" 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 | OutsideTerraform string 18 | ChangedResult string 19 | Warning string 20 | HasAddOrUpdateOnly bool 21 | HasDestroy bool 22 | HasNoChanges bool 23 | HasError bool 24 | HasParseError bool 25 | Error error 26 | CreatedResources []string 27 | UpdatedResources []string 28 | DeletedResources []string 29 | ReplacedResources []string 30 | MovedResources []*MovedResource 31 | ImportedResources []string 32 | } 33 | 34 | // PlanParser is a parser for terraform plan 35 | type PlanParser struct { 36 | Pass *regexp.Regexp 37 | Fail *regexp.Regexp 38 | Warning *regexp.Regexp 39 | OutputsChanges *regexp.Regexp 40 | HasDestroy *regexp.Regexp 41 | HasNoChanges *regexp.Regexp 42 | Create *regexp.Regexp 43 | Update *regexp.Regexp 44 | Delete *regexp.Regexp 45 | Replace *regexp.Regexp 46 | ReplaceOption *regexp.Regexp 47 | Move *regexp.Regexp 48 | Import *regexp.Regexp 49 | ImportedFrom *regexp.Regexp 50 | MovedFrom *regexp.Regexp 51 | } 52 | 53 | // ApplyParser is a parser for terraform apply 54 | type ApplyParser struct { 55 | Pass *regexp.Regexp 56 | Fail *regexp.Regexp 57 | } 58 | 59 | // NewPlanParser is PlanParser initialized with its Regexp 60 | func NewPlanParser() *PlanParser { 61 | return &PlanParser{ 62 | Pass: regexp.MustCompile(`(?m)^(Plan: \d|No changes.)`), 63 | Fail: regexp.MustCompile(`(?m)^([│|] )?(Error: )`), 64 | Warning: regexp.MustCompile(`(?m)^([│|] )?(Warning: )`), 65 | OutputsChanges: regexp.MustCompile(`(?m)^Changes to Outputs:`), 66 | // "0 to destroy" should be treated as "no destroy" 67 | HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`), 68 | HasNoChanges: regexp.MustCompile(`(?m)^(No changes.)`), 69 | Create: regexp.MustCompile(`^ *# (.*) will be created$`), 70 | Update: regexp.MustCompile(`^ *# (.*) will be updated in-place$`), 71 | Delete: regexp.MustCompile(`^ *# (.*) will be destroyed$`), 72 | Replace: regexp.MustCompile(`^ *# (.*?)(?: is tainted, so)? must be replaced$`), 73 | ReplaceOption: regexp.MustCompile(`^ *# (.*?) will be replaced, as requested$`), 74 | Move: regexp.MustCompile(`^ *# (.*?) has moved to (.*?)$`), 75 | Import: regexp.MustCompile(`^ *# (.*?) will be imported$`), 76 | ImportedFrom: regexp.MustCompile(`^ *# \(imported from (.*?)\)$`), 77 | MovedFrom: regexp.MustCompile(`^ *# \(moved from (.*?)\)$`), 78 | } 79 | } 80 | 81 | // NewApplyParser is ApplyParser initialized with its Regexp 82 | func NewApplyParser() *ApplyParser { 83 | return &ApplyParser{ 84 | Pass: regexp.MustCompile(`(?m)^(Apply complete!)`), 85 | Fail: regexp.MustCompile(`(?m)^(Error: )`), 86 | } 87 | } 88 | 89 | func extractResource(pattern *regexp.Regexp, line string) string { 90 | if arr := pattern.FindStringSubmatch(line); len(arr) == 2 { //nolint:mnd 91 | return arr[1] 92 | } 93 | return "" 94 | } 95 | 96 | func extractMovedResource(pattern *regexp.Regexp, line string) *MovedResource { 97 | if arr := pattern.FindStringSubmatch(line); len(arr) == 3 { //nolint:mnd 98 | return &MovedResource{ 99 | Before: arr[1], 100 | After: arr[2], 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | // Parse returns ParseResult related with terraform plan 107 | func (p *PlanParser) Parse(body string) ParseResult { //nolint:cyclop,maintidx 108 | switch { 109 | case p.Fail.MatchString(body): 110 | case p.Pass.MatchString(body) || p.OutputsChanges.MatchString(body): 111 | default: 112 | return ParseResult{ 113 | Result: "", 114 | HasParseError: true, 115 | Error: errors.New("cannot parse plan result"), 116 | } 117 | } 118 | lines := strings.Split(body, "\n") 119 | firstMatchLineIndex := -1 120 | var result, firstMatchLine string 121 | var createdResources, updatedResources, deletedResources, replacedResources, importedResources []string 122 | var movedResources []*MovedResource 123 | startOutsideTerraform := -1 124 | endOutsideTerraform := -1 125 | startChangeOutput := -1 126 | endChangeOutput := -1 127 | startWarning := -1 128 | endWarning := -1 129 | startErrorIndex := -1 130 | for i, line := range lines { 131 | if line == "Note: Objects have changed outside of Terraform" || line == "Note: Objects have changed outside of OpenTofu" { // https://github.com/hashicorp/terraform/blob/332045a4e4b1d256c45f98aac74e31102ace7af7/internal/command/views/plan.go#L403 132 | startOutsideTerraform = i + 1 133 | } 134 | if startOutsideTerraform != -1 && endOutsideTerraform == -1 && strings.HasPrefix(line, "Unless you have made equivalent changes to your configuration") { // https://github.com/hashicorp/terraform/blob/332045a4e4b1d256c45f98aac74e31102ace7af7/internal/command/views/plan.go#L110 135 | endOutsideTerraform = i + 1 136 | } 137 | if line == "Terraform will perform the following actions:" || line == "OpenTofu will perform the following actions:" { // https://github.com/hashicorp/terraform/blob/332045a4e4b1d256c45f98aac74e31102ace7af7/internal/command/views/plan.go#L252 138 | startChangeOutput = i + 1 139 | } 140 | // If we have output changes but not resource changes, Terraform 141 | // does not output `Terraform will perform the following actions:`. 142 | if line == "Changes to Outputs:" && startChangeOutput == -1 { 143 | startChangeOutput = i 144 | } 145 | if p.Warning.MatchString(line) && startWarning == -1 { 146 | startWarning = i 147 | } 148 | // Terraform uses two types of rules. 149 | if strings.HasPrefix(line, "─────") || strings.HasPrefix(line, "-----") { 150 | if startWarning != -1 && endWarning == -1 { 151 | endWarning = i 152 | } 153 | if startChangeOutput != -1 && endChangeOutput == -1 { 154 | endChangeOutput = i - 1 155 | } 156 | } 157 | if startErrorIndex == -1 { 158 | if p.Fail.MatchString(line) { 159 | startErrorIndex = i 160 | firstMatchLineIndex = i 161 | firstMatchLine = line 162 | } 163 | } 164 | if firstMatchLineIndex == -1 { 165 | if p.Pass.MatchString(line) || p.OutputsChanges.MatchString(line) { 166 | firstMatchLineIndex = i 167 | firstMatchLine = line 168 | } 169 | } 170 | if rsc := extractResource(p.Create, line); rsc != "" { 171 | createdResources = append(createdResources, rsc) 172 | } else if rsc := extractResource(p.Update, line); rsc != "" { 173 | updatedResources = append(updatedResources, rsc) 174 | } else if rsc := extractResource(p.Delete, line); rsc != "" { 175 | deletedResources = append(deletedResources, rsc) 176 | } else if rsc := extractResource(p.Replace, line); rsc != "" { 177 | replacedResources = append(replacedResources, rsc) 178 | } else if rsc := extractResource(p.ReplaceOption, line); rsc != "" { 179 | replacedResources = append(replacedResources, rsc) 180 | } else if rsc := extractResource(p.Import, line); rsc != "" { 181 | importedResources = append(importedResources, rsc) 182 | } else if rsc := extractResource(p.ImportedFrom, line); rsc != "" { 183 | if i == 0 { 184 | continue 185 | } 186 | if rsc := p.changedResources(lines[i-1]); rsc != "" { 187 | importedResources = append(importedResources, rsc) 188 | } 189 | } else if rsc := extractMovedResource(p.Move, line); rsc != nil { 190 | movedResources = append(movedResources, rsc) 191 | } else if fromRsc := extractResource(p.MovedFrom, line); fromRsc != "" { 192 | if i == 0 { 193 | continue 194 | } 195 | if toRsc := p.changedResources(lines[i-1]); toRsc != "" { 196 | movedResources = append(movedResources, &MovedResource{ 197 | Before: fromRsc, 198 | After: toRsc, 199 | }) 200 | } 201 | } 202 | } 203 | var hasPlanError bool 204 | switch { 205 | case p.Fail.MatchString(firstMatchLine): 206 | // Fail should be checked before Pass 207 | hasPlanError = true 208 | result = strings.Join(trimBars(trimLastNewline(lines[firstMatchLineIndex:])), "\n") 209 | case p.Pass.MatchString(firstMatchLine): 210 | result = lines[firstMatchLineIndex] 211 | case p.OutputsChanges.MatchString(firstMatchLine): 212 | result = "Only Outputs will be changed." 213 | } 214 | 215 | hasDestroy := p.HasDestroy.MatchString(firstMatchLine) 216 | hasNoChanges := p.HasNoChanges.MatchString(firstMatchLine) 217 | HasAddOrUpdateOnly := !hasNoChanges && !hasDestroy && !hasPlanError 218 | 219 | outsideTerraform := "" 220 | if startOutsideTerraform != -1 { 221 | outsideTerraform = strings.Join(lines[startOutsideTerraform:endOutsideTerraform], "\n") 222 | } 223 | 224 | changeResult := "" 225 | if startChangeOutput != -1 { 226 | // if we get here before finding a horizontal rule, output all remaining. 227 | if endChangeOutput == -1 { 228 | endChangeOutput = len(lines) - 1 229 | } 230 | changeResult = strings.Join(lines[startChangeOutput:endChangeOutput], "\n") 231 | } 232 | 233 | warnings := "" 234 | if startWarning != -1 { 235 | if endWarning == -1 { 236 | warnings = strings.Join(trimBars(lines[startWarning:]), "\n") 237 | } else { 238 | warnings = strings.Join(trimBars(lines[startWarning:endWarning]), "\n") 239 | } 240 | } 241 | 242 | return ParseResult{ 243 | Result: strings.TrimSpace(result), 244 | ChangedResult: changeResult, 245 | OutsideTerraform: outsideTerraform, 246 | Warning: strings.TrimSpace(warnings), 247 | HasAddOrUpdateOnly: HasAddOrUpdateOnly, 248 | HasDestroy: hasDestroy, 249 | HasNoChanges: hasNoChanges, 250 | HasError: hasPlanError, 251 | Error: nil, 252 | CreatedResources: createdResources, 253 | UpdatedResources: updatedResources, 254 | DeletedResources: deletedResources, 255 | ReplacedResources: replacedResources, 256 | MovedResources: movedResources, 257 | ImportedResources: importedResources, 258 | } 259 | } 260 | 261 | func (p *PlanParser) changedResources(line string) string { 262 | if rsc := extractResource(p.Update, line); rsc != "" { 263 | return rsc 264 | } else if rsc := extractResource(p.Replace, line); rsc != "" { 265 | return rsc 266 | } else if rsc := extractResource(p.ReplaceOption, line); rsc != "" { 267 | return rsc 268 | } 269 | return "" 270 | } 271 | 272 | type MovedResource struct { 273 | Before string 274 | After string 275 | } 276 | 277 | // Parse returns ParseResult related with terraform apply 278 | func (p *ApplyParser) Parse(body string) ParseResult { 279 | var hasError bool 280 | switch { 281 | case p.Fail.MatchString(body): 282 | hasError = true 283 | case p.Pass.MatchString(body): 284 | default: 285 | return ParseResult{ 286 | Result: "", 287 | HasParseError: true, 288 | Error: errors.New("cannot parse apply result"), 289 | } 290 | } 291 | lines := strings.Split(body, "\n") 292 | var i int 293 | var result, line string 294 | for i, line = range lines { 295 | if p.Pass.MatchString(line) || p.Fail.MatchString(line) { 296 | break 297 | } 298 | } 299 | switch { 300 | case p.Fail.MatchString(line): 301 | // Fail should be checked before Pass 302 | result = strings.Join(trimBars(trimLastNewline(lines[i:])), "\n") 303 | case p.Pass.MatchString(line): 304 | result = lines[i] 305 | } 306 | return ParseResult{ 307 | Result: strings.TrimSpace(result), 308 | HasError: hasError, 309 | Error: nil, 310 | } 311 | } 312 | 313 | func trimLastNewline(s []string) []string { 314 | if len(s) == 0 { 315 | return s 316 | } 317 | last := len(s) - 1 318 | if s[last] == "" { 319 | return s[:last] 320 | } 321 | return s 322 | } 323 | 324 | func trimBars(list []string) []string { 325 | ret := make([]string, len(list)) 326 | for i, elem := range list { 327 | ret[i] = strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(elem, "|"), "│"), "╵") 328 | } 329 | return ret 330 | } 331 | -------------------------------------------------------------------------------- /pkg/terraform/template.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "bytes" 5 | htmltemplate "html/template" 6 | "strings" 7 | texttemplate "text/template" 8 | 9 | tmpl "github.com/suzuki-shunsuke/tfcmt/v4/pkg/template" 10 | ) 11 | 12 | const ( 13 | // DefaultPlanTemplate is a default template for terraform plan 14 | DefaultPlanTemplate = ` 15 | {{template "plan_title" .}} 16 | 17 | {{if .Link}}[CI link]({{.Link}}){{end}} 18 | 19 | {{template "deletion_warning" .}} 20 | {{template "result" .}} 21 | {{template "updated_resources" .}} 22 | 23 | {{template "changed_result" .}} 24 | {{template "change_outside_terraform" .}} 25 | {{template "warning" .}} 26 | {{template "error_messages" .}}` 27 | 28 | // DefaultApplyTemplate is a default template for terraform apply 29 | DefaultApplyTemplate = ` 30 | {{template "apply_title" .}} 31 | 32 | {{if .Link}}[CI link]({{.Link}}){{end}} 33 | 34 | {{if ne .ExitCode 0}}{{template "guide_apply_failure" .}}{{end}} 35 | 36 | {{template "result" .}} 37 | 38 |
Details (Click me) 39 | {{wrapCode .CombinedOutput}} 40 |
41 | {{template "error_messages" .}}` 42 | 43 | // DefaultPlanParseErrorTemplate is a default template for terraform plan parse error 44 | DefaultPlanParseErrorTemplate = ` 45 | {{template "plan_title" .}} 46 | 47 | {{if .Link}}[CI link]({{.Link}}){{end}} 48 | 49 | It failed to parse the result. 50 | 51 |
Details (Click me) 52 | {{wrapCode .CombinedOutput}} 53 |
54 | ` 55 | 56 | // DefaultApplyParseErrorTemplate is a default template for terraform apply parse error 57 | DefaultApplyParseErrorTemplate = ` 58 | {{template "apply_title" .}} 59 | 60 | {{if .Link}}[CI link]({{.Link}}){{end}} 61 | 62 | {{template "guide_apply_parse_error" .}} 63 | 64 | It failed to parse the result. 65 | 66 |
Details (Click me) 67 | {{wrapCode .CombinedOutput}} 68 |
69 | ` 70 | ) 71 | 72 | // CommonTemplate represents template entities 73 | type CommonTemplate struct { 74 | Result string 75 | ChangedResult string 76 | ChangeOutsideTerraform string 77 | Warning string 78 | Link string 79 | UseRawOutput bool 80 | HasDestroy bool 81 | HasError bool 82 | Vars map[string]string 83 | Templates map[string]string 84 | Stdout string 85 | Stderr string 86 | CombinedOutput string 87 | ExitCode int 88 | ErrorMessages []string 89 | CreatedResources []string 90 | UpdatedResources []string 91 | DeletedResources []string 92 | ReplacedResources []string 93 | MovedResources []*MovedResource 94 | ImportedResources []string 95 | } 96 | 97 | // Template is a default template for terraform commands 98 | type Template struct { 99 | Template string 100 | CommonTemplate 101 | } 102 | 103 | // NewPlanTemplate is PlanTemplate initializer 104 | func NewPlanTemplate(template string) *Template { 105 | if template == "" { 106 | template = DefaultPlanTemplate 107 | } 108 | return &Template{ 109 | Template: template, 110 | } 111 | } 112 | 113 | // NewApplyTemplate is ApplyTemplate initializer 114 | func NewApplyTemplate(template string) *Template { 115 | if template == "" { 116 | template = DefaultApplyTemplate 117 | } 118 | return &Template{ 119 | Template: template, 120 | } 121 | } 122 | 123 | func NewPlanParseErrorTemplate(template string) *Template { 124 | if template == "" { 125 | template = DefaultPlanParseErrorTemplate 126 | } 127 | return &Template{ 128 | Template: template, 129 | } 130 | } 131 | 132 | func NewApplyParseErrorTemplate(template string) *Template { 133 | if template == "" { 134 | template = DefaultApplyParseErrorTemplate 135 | } 136 | return &Template{ 137 | Template: template, 138 | } 139 | } 140 | 141 | func avoidHTMLEscape(text string) htmltemplate.HTML { 142 | return htmltemplate.HTML(text) //nolint:gosec 143 | } 144 | 145 | func wrapCode(text string) any { 146 | header := "" 147 | if len(text) > 60000 { //nolint:mnd 148 | header = "\n:warning: **The content is omitted as it is too long.** :warning:\n" 149 | 150 | text = text[:20000] + ` 151 | 152 | # ... 153 | # ... The maximum length of GitHub Comment is 65536, so the content is omitted by tfcmt. 154 | # ... 155 | 156 | ` + text[len(text)-20000:] 157 | } 158 | if strings.Contains(text, "```") { 159 | if strings.Contains(text, "~~~") { 160 | return htmltemplate.HTML(header + `
` + htmltemplate.HTMLEscapeString(text) + `
`) //nolint:gosec 161 | } 162 | return htmltemplate.HTML(header + "\n~~~hcl\n" + text + "\n~~~\n") //nolint:gosec 163 | } 164 | return htmltemplate.HTML(header + "\n```hcl\n" + text + "\n```\n") //nolint:gosec 165 | } 166 | 167 | func generateOutput(kind, template string, data map[string]any, useRawOutput bool) (string, error) { 168 | var b bytes.Buffer 169 | 170 | if useRawOutput { 171 | tpl, err := texttemplate.New(kind).Funcs(texttemplate.FuncMap{ 172 | "avoidHTMLEscape": avoidHTMLEscape, 173 | "wrapCode": wrapCode, 174 | }).Funcs(tmpl.TxtFuncMap()).Parse(template) 175 | if err != nil { 176 | return "", err 177 | } 178 | if err := tpl.Execute(&b, data); err != nil { 179 | return "", err 180 | } 181 | } else { 182 | tpl, err := htmltemplate.New(kind).Funcs(htmltemplate.FuncMap{ 183 | "avoidHTMLEscape": avoidHTMLEscape, 184 | "wrapCode": wrapCode, 185 | }).Funcs(tmpl.FuncMap()).Parse(template) 186 | if err != nil { 187 | return "", err 188 | } 189 | if err := tpl.Execute(&b, data); err != nil { 190 | return "", err 191 | } 192 | } 193 | 194 | return b.String(), nil 195 | } 196 | 197 | // Execute binds the execution result of terraform command into template 198 | func (t *Template) Execute() (string, error) { 199 | data := map[string]any{ 200 | "Result": t.Result, 201 | "ChangedResult": t.ChangedResult, 202 | "ChangeOutsideTerraform": t.ChangeOutsideTerraform, 203 | "Warning": t.Warning, 204 | "Link": t.Link, 205 | "Vars": t.Vars, 206 | "Stdout": t.Stdout, 207 | "Stderr": t.Stderr, 208 | "CombinedOutput": t.CombinedOutput, 209 | "ExitCode": t.ExitCode, 210 | "HasError": t.HasError, 211 | "ErrorMessages": t.ErrorMessages, 212 | "CreatedResources": t.CreatedResources, 213 | "UpdatedResources": t.UpdatedResources, 214 | "DeletedResources": t.DeletedResources, 215 | "ReplacedResources": t.ReplacedResources, 216 | "MovedResources": t.MovedResources, 217 | "ImportedResources": t.ImportedResources, 218 | "HasDestroy": t.HasDestroy, 219 | } 220 | 221 | templates := map[string]string{ 222 | "plan_title": "## {{if or (eq .ExitCode 1) .HasError}}:x: Plan Failed{{else}}Plan Result{{end}}{{if .Vars.target}} ({{.Vars.target}}){{end}}", 223 | "apply_title": "## {{if and (eq .ExitCode 0) (not .HasError)}}:white_check_mark: Apply Succeeded{{else}}:x: Apply Failed{{end}}{{if .Vars.target}} ({{.Vars.target}}){{end}}", 224 | "result": "{{if .Result}}
{{ .Result }}
{{end}}", 225 | "updated_resources": `{{if .CreatedResources}} 226 | * Create 227 | {{- range .CreatedResources}} 228 | * {{.}} 229 | {{- end}}{{end}}{{if .UpdatedResources}} 230 | * Update 231 | {{- range .UpdatedResources}} 232 | * {{.}} 233 | {{- end}}{{end}}{{if .DeletedResources}} 234 | * Delete 235 | {{- range .DeletedResources}} 236 | * {{.}} 237 | {{- end}}{{end}}{{if .ReplacedResources}} 238 | * Replace 239 | {{- range .ReplacedResources}} 240 | * {{.}} 241 | {{- end}}{{end}}{{if .ImportedResources}} 242 | * Import 243 | {{- range .ImportedResources}} 244 | * {{.}} 245 | {{- end}}{{end}}{{if .MovedResources}} 246 | * Move 247 | {{- range .MovedResources}} 248 | * {{.Before}} => {{.After}} 249 | {{- end}}{{end}}`, 250 | "deletion_warning": `{{if .HasDestroy}} 251 | ### :warning: Resource Deletion will happen 252 | This plan contains resource delete operation. Please check the plan result very carefully! 253 | {{end}}`, 254 | "changed_result": `{{if .ChangedResult}} 255 |
Change Result (Click me) 256 | {{wrapCode .ChangedResult}} 257 |
258 | {{end}}`, 259 | "change_outside_terraform": `{{if .ChangeOutsideTerraform}} 260 |
:information_source: Objects have changed outside of Terraform 261 | 262 | _This feature was introduced from [Terraform v0.15.4](https://github.com/hashicorp/terraform/releases/tag/v0.15.4)._ 263 | {{wrapCode .ChangeOutsideTerraform}} 264 |
265 | {{end}}`, 266 | "warning": `{{if .Warning}} 267 | ## :warning: Warnings 268 | {{wrapCode .Warning}} 269 | {{end}}`, 270 | "error_messages": `{{if .ErrorMessages}} 271 | ## :warning: Errors 272 | {{range .ErrorMessages}} 273 | * {{. -}} 274 | {{- end}}{{end}}`, 275 | "guide_apply_failure": "", 276 | "guide_apply_parse_error": "", 277 | } 278 | 279 | for k, v := range t.Templates { 280 | templates[k] = v 281 | } 282 | 283 | resp, err := generateOutput("default", addTemplates(t.Template, templates), data, t.UseRawOutput) 284 | if err != nil { 285 | return "", err 286 | } 287 | 288 | return resp, nil 289 | } 290 | 291 | // SetValue sets template entities to CommonTemplate 292 | func (t *Template) SetValue(ct CommonTemplate) { 293 | t.CommonTemplate = ct 294 | } 295 | 296 | func addTemplates(tpl string, templates map[string]string) string { 297 | for k, v := range templates { 298 | tpl += `{{define "` + k + `"}}` + v + "{{end}}" 299 | } 300 | return tpl 301 | } 302 | -------------------------------------------------------------------------------- /pkg/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 | -------------------------------------------------------------------------------- /pkg/terraform/terraform_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "github>aquaproj/aqua-renovate-config#2.8.1", 4 | "github>aquaproj/aqua-renovate-config:file#2.8.1(aqua/.*\\.ya?ml)", 5 | "github>suzuki-shunsuke/renovate-config#3.2.1", 6 | "github>suzuki-shunsuke/renovate-config:nolimit#3.2.1", 7 | ], 8 | } 9 | --------------------------------------------------------------------------------