├── .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 | [](https://goreportcard.com/report/github.com/suzuki-shunsuke/tfcmt)
4 | [](https://github.com/suzuki-shunsuke/tfcmt)
5 | [](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}}\nDetails (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}}\nDetails (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 |
--------------------------------------------------------------------------------