\nbaz message\n"),
217 | },
218 | },
219 | },
220 | }
221 |
222 | for _, testCase := range testCases {
223 | cfg := newFakeConfig()
224 | cfg.PR.Message = testCase.message
225 | client, err := NewClient(cfg)
226 | if err != nil {
227 | t.Fatal(err)
228 | }
229 | client.API = &api
230 | comments := client.Comment.getDuplicates(testCase.title)
231 | if !reflect.DeepEqual(comments, testCase.comments) {
232 | t.Errorf("got %q but want %q", comments, testCase.comments)
233 | }
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/notifier/github/commits.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/google/go-github/github"
10 | )
11 |
12 | // CommitsService handles communication with the commits related
13 | // methods of GitHub API
14 | type CommitsService service
15 |
16 | // List lists commits on a repository
17 | func (g *CommitsService) List(revision string) ([]string, error) {
18 | if revision == "" {
19 | return []string{}, errors.New("no revision specified")
20 | }
21 | var s []string
22 | commits, _, err := g.client.API.RepositoriesListCommits(
23 | context.Background(),
24 | &github.CommitsListOptions{SHA: revision},
25 | )
26 | if err != nil {
27 | return s, err
28 | }
29 | for _, commit := range commits {
30 | s = append(s, *commit.SHA)
31 | }
32 | return s, nil
33 | }
34 |
35 | // Last returns the hash of the previous commit of the given commit
36 | func (g *CommitsService) lastOne(commits []string, revision string) (string, error) {
37 | if revision == "" {
38 | return "", errors.New("no revision specified")
39 | }
40 | if len(commits) == 0 {
41 | return "", errors.New("no commits")
42 | }
43 | // e.g.
44 | // a0ce5bf 2018/04/05 20:50:01 (HEAD -> master, origin/master)
45 | // 5166cfc 2018/04/05 20:40:12
46 | // 74c4d6e 2018/04/05 20:34:31
47 | // 9260c54 2018/04/05 20:16:20
48 | return commits[1], nil
49 | }
50 |
51 | func (g *CommitsService) MergedPRNumber(revision string) (int, error) {
52 | commit, _, err := g.client.API.RepositoriesGetCommit(context.Background(), revision)
53 | if err != nil {
54 | return 0, err
55 | }
56 |
57 | message := commit.Commit.GetMessage()
58 | if !strings.HasPrefix(message, "Merge pull request #") {
59 | return 0, errors.New("not a merge commit")
60 | }
61 |
62 | message = strings.TrimPrefix(message, "Merge pull request #")
63 | i := strings.Index(message, " from")
64 | if i >= 0 {
65 | return strconv.Atoi(message[0:i])
66 | }
67 |
68 | return 0, errors.New("not a merge commit")
69 | }
70 |
--------------------------------------------------------------------------------
/notifier/github/commits_test.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestCommitsList(t *testing.T) {
8 | testCases := []struct {
9 | revision string
10 | ok bool
11 | }{
12 | {
13 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
14 | ok: true,
15 | },
16 | {
17 | revision: "",
18 | ok: false,
19 | },
20 | }
21 |
22 | for _, testCase := range testCases {
23 | cfg := newFakeConfig()
24 | client, err := NewClient(cfg)
25 | if err != nil {
26 | t.Fatal(err)
27 | }
28 | api := newFakeAPI()
29 | client.API = &api
30 | _, err = client.Commits.List(testCase.revision)
31 | if (err == nil) != testCase.ok {
32 | t.Errorf("got error %q", err)
33 | }
34 | }
35 | }
36 |
37 | func TestCommitsLastOne(t *testing.T) {
38 | testCases := []struct {
39 | commits []string
40 | revision string
41 | lastRev string
42 | ok bool
43 | }{
44 | {
45 | // ok
46 | commits: []string{
47 | "04e0917e448b662c2b16330fad50e97af16ff27a",
48 | "04e0917e448b662c2b16330fad50e97af16ff27b",
49 | "04e0917e448b662c2b16330fad50e97af16ff27c",
50 | },
51 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
52 | lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b",
53 | ok: true,
54 | },
55 | {
56 | // no revision
57 | commits: []string{
58 | "04e0917e448b662c2b16330fad50e97af16ff27a",
59 | "04e0917e448b662c2b16330fad50e97af16ff27b",
60 | "04e0917e448b662c2b16330fad50e97af16ff27c",
61 | },
62 | revision: "",
63 | lastRev: "",
64 | ok: false,
65 | },
66 | {
67 | // no commits
68 | commits: []string{},
69 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
70 | lastRev: "",
71 | ok: false,
72 | },
73 | }
74 |
75 | for _, testCase := range testCases {
76 | cfg := newFakeConfig()
77 | client, err := NewClient(cfg)
78 | if err != nil {
79 | t.Fatal(err)
80 | }
81 | api := newFakeAPI()
82 | client.API = &api
83 | commit, err := client.Commits.lastOne(testCase.commits, testCase.revision)
84 | if (err == nil) != testCase.ok {
85 | t.Errorf("got error %q", err)
86 | }
87 | if commit != testCase.lastRev {
88 | t.Errorf("got %q but want %q", commit, testCase.lastRev)
89 | }
90 | }
91 | }
92 |
93 | func TestMergedPRNumber(t *testing.T) {
94 | testCases := []struct {
95 | prNumber int
96 | ok bool
97 | revision string
98 | }{
99 | {
100 | prNumber: 1,
101 | ok: true,
102 | revision: "Merge pull request #1 from mercari/tfnotify",
103 | },
104 | {
105 | prNumber: 123,
106 | ok: true,
107 | revision: "Merge pull request #123 from mercari/tfnotify",
108 | },
109 | {
110 | prNumber: 0,
111 | ok: false,
112 | revision: "destroyed the world",
113 | },
114 | {
115 | prNumber: 0,
116 | ok: false,
117 | revision: "Merge pull request #string from mercari/tfnotify",
118 | },
119 | }
120 |
121 | for _, testCase := range testCases {
122 | cfg := newFakeConfig()
123 | client, err := NewClient(cfg)
124 | if err != nil {
125 | t.Fatal(err)
126 | }
127 | api := newFakeAPI()
128 | client.API = &api
129 | prNumber, err := client.Commits.MergedPRNumber(testCase.revision)
130 | if (err == nil) != testCase.ok {
131 | t.Errorf("got error %q", err)
132 | }
133 | if prNumber != testCase.prNumber {
134 | t.Errorf("got %q but want %q", prNumber, testCase.prNumber)
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/notifier/github/github.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/go-github/github"
7 | )
8 |
9 | // API is GitHub API interface
10 | type API interface {
11 | IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
12 | IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error)
13 | IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error)
14 | IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
15 | IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
16 | IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error)
17 | RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
18 | RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
19 | RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error)
20 | }
21 |
22 | // GitHub represents the attribute information necessary for requesting GitHub API
23 | type GitHub struct {
24 | *github.Client
25 | owner, repo string
26 | }
27 |
28 | // IssuesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.CreateComment
29 | func (g *GitHub) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
30 | return g.Client.Issues.CreateComment(ctx, g.owner, g.repo, number, comment)
31 | }
32 |
33 | // IssuesDeleteComment is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.DeleteComment
34 | func (g *GitHub) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) {
35 | return g.Client.Issues.DeleteComment(ctx, g.owner, g.repo, int64(commentID))
36 | }
37 |
38 | // IssuesListComments is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListComments
39 | func (g *GitHub) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
40 | return g.Client.Issues.ListComments(ctx, g.owner, g.repo, number, opt)
41 | }
42 |
43 | // IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.AddLabelsToIssue
44 | func (g *GitHub) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
45 | return g.Client.Issues.AddLabelsToIssue(ctx, g.owner, g.repo, number, labels)
46 | }
47 |
48 | // IssuesListLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListLabelsByIssue
49 | func (g *GitHub) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
50 | return g.Client.Issues.ListLabelsByIssue(ctx, g.owner, g.repo, number, opt)
51 | }
52 |
53 | // IssuesRemoveLabel is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue
54 | func (g *GitHub) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) {
55 | return g.Client.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, label)
56 | }
57 |
58 | // RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment
59 | func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
60 | return g.Client.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment)
61 | }
62 |
63 | // RepositoriesListCommits is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.ListCommits
64 | func (g *GitHub) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
65 | return g.Client.Repositories.ListCommits(ctx, g.owner, g.repo, opt)
66 | }
67 |
68 | // RepositoriesGetCommit is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.GetCommit
69 | func (g *GitHub) RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) {
70 | return g.Client.Repositories.GetCommit(ctx, g.owner, g.repo, sha)
71 | }
72 |
--------------------------------------------------------------------------------
/notifier/github/github_test.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/go-github/github"
7 | "github.com/mercari/tfnotify/terraform"
8 | )
9 |
10 | type fakeAPI struct {
11 | API
12 | FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
13 | FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error)
14 | FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
15 | FakeIssuesListLabels func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error)
16 | FakeIssuesAddLabels func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
17 | FakeIssuesRemoveLabel func(ctx context.Context, number int, label string) (*github.Response, error)
18 | FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
19 | FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
20 | FakeRepositoriesGetCommit func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error)
21 | }
22 |
23 | func (g *fakeAPI) IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
24 | return g.FakeIssuesCreateComment(ctx, number, comment)
25 | }
26 |
27 | func (g *fakeAPI) IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error) {
28 | return g.FakeIssuesDeleteComment(ctx, commentID)
29 | }
30 |
31 | func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
32 | return g.FakeIssuesListComments(ctx, number, opt)
33 | }
34 |
35 | func (g *fakeAPI) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
36 | return g.FakeIssuesListLabels(ctx, number, opt)
37 | }
38 |
39 | func (g *fakeAPI) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
40 | return g.FakeIssuesAddLabels(ctx, number, labels)
41 | }
42 |
43 | func (g *fakeAPI) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) {
44 | return g.FakeIssuesRemoveLabel(ctx, number, label)
45 | }
46 |
47 | func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
48 | return g.FakeRepositoriesCreateComment(ctx, sha, comment)
49 | }
50 |
51 | func (g *fakeAPI) RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
52 | return g.FakeRepositoriesListCommits(ctx, opt)
53 | }
54 |
55 | func (g *fakeAPI) RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) {
56 | return g.FakeRepositoriesGetCommit(ctx, sha)
57 | }
58 |
59 | func newFakeAPI() fakeAPI {
60 | return fakeAPI{
61 | FakeIssuesCreateComment: func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
62 | return &github.IssueComment{
63 | ID: github.Int64(371748792),
64 | Body: github.String("comment 1"),
65 | }, nil, nil
66 | },
67 | FakeIssuesDeleteComment: func(ctx context.Context, commentID int64) (*github.Response, error) {
68 | return nil, nil
69 | },
70 | FakeIssuesListComments: func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
71 | var comments []*github.IssueComment
72 | comments = []*github.IssueComment{
73 | &github.IssueComment{
74 | ID: github.Int64(371748792),
75 | Body: github.String("comment 1"),
76 | },
77 | &github.IssueComment{
78 | ID: github.Int64(371765743),
79 | Body: github.String("comment 2"),
80 | },
81 | }
82 | return comments, nil, nil
83 | },
84 | FakeIssuesListLabels: func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) {
85 | labels := []*github.Label{
86 | &github.Label{
87 | ID: github.Int64(371748792),
88 | Name: github.String("label 1"),
89 | },
90 | &github.Label{
91 | ID: github.Int64(371765743),
92 | Name: github.String("label 2"),
93 | },
94 | }
95 | return labels, nil, nil
96 | },
97 | FakeIssuesAddLabels: func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
98 | return nil, nil, nil
99 | },
100 | FakeIssuesRemoveLabel: func(ctx context.Context, number int, label string) (*github.Response, error) {
101 | return nil, nil
102 | },
103 | FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
104 | return &github.RepositoryComment{
105 | ID: github.Int64(28427394),
106 | CommitID: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"),
107 | Body: github.String("comment 1"),
108 | }, nil, nil
109 | },
110 | FakeRepositoriesListCommits: func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
111 | var commits []*github.RepositoryCommit
112 | commits = []*github.RepositoryCommit{
113 | &github.RepositoryCommit{
114 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27a"),
115 | },
116 | &github.RepositoryCommit{
117 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27b"),
118 | },
119 | &github.RepositoryCommit{
120 | SHA: github.String("04e0917e448b662c2b16330fad50e97af16ff27c"),
121 | },
122 | }
123 | return commits, nil, nil
124 | },
125 | FakeRepositoriesGetCommit: func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error) {
126 | return &github.RepositoryCommit{
127 | SHA: github.String(sha),
128 | Commit: &github.Commit{
129 | Message: github.String(sha),
130 | },
131 | }, nil, nil
132 | },
133 | }
134 | }
135 |
136 | func newFakeConfig() Config {
137 | return Config{
138 | Token: "token",
139 | Owner: "owner",
140 | Repo: "repo",
141 | PR: PullRequest{
142 | Revision: "abcd",
143 | Number: 1,
144 | Message: "message",
145 | },
146 | Parser: terraform.NewPlanParser(),
147 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/notifier/github/notify.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "unicode/utf8"
7 |
8 | "github.com/mercari/tfnotify/terraform"
9 | )
10 |
11 | // NotifyService handles communication with the notification related
12 | // methods of GitHub API
13 | type NotifyService service
14 |
15 | // Notify posts comment optimized for notifications
16 | func (g *NotifyService) Notify(body string) (exit int, err error) {
17 | cfg := g.client.Config
18 | parser := g.client.Config.Parser
19 | template := g.client.Config.Template
20 |
21 | result := parser.Parse(body)
22 | if result.Error != nil {
23 | return result.ExitCode, result.Error
24 | }
25 | if result.Result == "" {
26 | return result.ExitCode, result.Error
27 | }
28 |
29 | _, isPlan := parser.(*terraform.PlanParser)
30 | if isPlan {
31 | if result.HasDestroy && cfg.WarnDestroy {
32 | // Notify destroy warning as a new comment before normal plan result
33 | if err = g.notifyDestroyWarning(body, result); err != nil {
34 | return result.ExitCode, err
35 | }
36 | }
37 | if cfg.PR.IsNumber() && cfg.ResultLabels.HasAnyLabelDefined() {
38 | err = g.removeResultLabels()
39 | if err != nil {
40 | return result.ExitCode, err
41 | }
42 | var labelToAdd string
43 |
44 | if result.HasAddOrUpdateOnly {
45 | labelToAdd = cfg.ResultLabels.AddOrUpdateLabel
46 | } else if result.HasDestroy {
47 | labelToAdd = cfg.ResultLabels.DestroyLabel
48 | } else if result.HasNoChanges {
49 | labelToAdd = cfg.ResultLabels.NoChangesLabel
50 | } else if result.HasPlanError {
51 | labelToAdd = cfg.ResultLabels.PlanErrorLabel
52 | }
53 |
54 | if labelToAdd != "" {
55 | _, _, err = g.client.API.IssuesAddLabels(
56 | context.Background(),
57 | cfg.PR.Number,
58 | []string{labelToAdd},
59 | )
60 | if err != nil {
61 | return result.ExitCode, err
62 | }
63 | }
64 | }
65 | }
66 |
67 | template.SetValue(terraform.CommonTemplate{
68 | Title: cfg.PR.Title,
69 | Message: cfg.PR.Message,
70 | Result: result.Result,
71 | Body: body,
72 | Link: cfg.CI,
73 | UseRawOutput: cfg.UseRawOutput,
74 | })
75 | body, err = templateExecute(template)
76 | if err != nil {
77 | return result.ExitCode, err
78 | }
79 |
80 | value := template.GetValue()
81 |
82 | if cfg.PR.IsNumber() {
83 | g.client.Comment.DeleteDuplicates(value.Title)
84 | }
85 |
86 | _, isApply := parser.(*terraform.ApplyParser)
87 | if isApply {
88 | prNumber, err := g.client.Commits.MergedPRNumber(cfg.PR.Revision)
89 | if err == nil {
90 | cfg.PR.Number = prNumber
91 | } else if !cfg.PR.IsNumber() {
92 | commits, err := g.client.Commits.List(cfg.PR.Revision)
93 | if err != nil {
94 | return result.ExitCode, err
95 | }
96 | lastRevision, _ := g.client.Commits.lastOne(commits, cfg.PR.Revision)
97 | cfg.PR.Revision = lastRevision
98 | }
99 | }
100 |
101 | return result.ExitCode, g.client.Comment.Post(body, PostOptions{
102 | Number: cfg.PR.Number,
103 | Revision: cfg.PR.Revision,
104 | })
105 | }
106 |
107 | func (g *NotifyService) notifyDestroyWarning(body string, result terraform.ParseResult) error {
108 | cfg := g.client.Config
109 | destroyWarningTemplate := g.client.Config.DestroyWarningTemplate
110 | destroyWarningTemplate.SetValue(terraform.CommonTemplate{
111 | Title: cfg.PR.DestroyWarningTitle,
112 | Message: cfg.PR.DestroyWarningMessage,
113 | Result: result.Result,
114 | Body: body,
115 | Link: cfg.CI,
116 | UseRawOutput: cfg.UseRawOutput,
117 | })
118 | body, err := templateExecute(destroyWarningTemplate)
119 | if err != nil {
120 | return err
121 | }
122 |
123 | return g.client.Comment.Post(body, PostOptions{
124 | Number: cfg.PR.Number,
125 | Revision: cfg.PR.Revision,
126 | })
127 | }
128 |
129 | func (g *NotifyService) removeResultLabels() error {
130 | cfg := g.client.Config
131 | labels, _, err := g.client.API.IssuesListLabels(context.Background(), cfg.PR.Number, nil)
132 | if err != nil {
133 | return err
134 | }
135 |
136 | for _, l := range labels {
137 | labelText := l.GetName()
138 | if cfg.ResultLabels.IsResultLabel(labelText) {
139 | resp, err := g.client.API.IssuesRemoveLabel(context.Background(), cfg.PR.Number, labelText)
140 | // Ignore 404 errors, which are from the PR not having the label
141 | if err != nil && resp.StatusCode != http.StatusNotFound {
142 | return err
143 | }
144 | }
145 | }
146 |
147 | return nil
148 | }
149 |
150 | func templateExecute(template terraform.Template) (string, error) {
151 | body, err := template.Execute()
152 | if err != nil {
153 | return "", err
154 | }
155 |
156 | if utf8.RuneCountInString(body) <= 65536 {
157 | return body, nil
158 | }
159 |
160 | templateValues := template.GetValue()
161 | templateValues.Body = "Body is too long. Please check the CI result."
162 |
163 | template.SetValue(templateValues)
164 | return template.Execute()
165 | }
166 |
--------------------------------------------------------------------------------
/notifier/github/notify_test.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/mercari/tfnotify/terraform"
8 | )
9 |
10 | func TestNotifyNotify(t *testing.T) {
11 | testCases := []struct {
12 | config Config
13 | body string
14 | ok bool
15 | exitCode int
16 | }{
17 | {
18 | // invalid body (cannot parse)
19 | config: Config{
20 | Token: "token",
21 | Owner: "owner",
22 | Repo: "repo",
23 | PR: PullRequest{
24 | Revision: "abcd",
25 | Number: 1,
26 | Message: "message",
27 | },
28 | Parser: terraform.NewPlanParser(),
29 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
30 | },
31 | body: "body",
32 | ok: false,
33 | exitCode: 1,
34 | },
35 | {
36 | // invalid pr
37 | config: Config{
38 | Token: "token",
39 | Owner: "owner",
40 | Repo: "repo",
41 | PR: PullRequest{
42 | Revision: "",
43 | Number: 0,
44 | Message: "message",
45 | },
46 | Parser: terraform.NewPlanParser(),
47 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
48 | },
49 | body: "Plan: 1 to add",
50 | ok: false,
51 | exitCode: 0,
52 | },
53 | {
54 | // valid, error
55 | config: Config{
56 | Token: "token",
57 | Owner: "owner",
58 | Repo: "repo",
59 | PR: PullRequest{
60 | Revision: "",
61 | Number: 1,
62 | Message: "message",
63 | },
64 | Parser: terraform.NewPlanParser(),
65 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
66 | },
67 | body: "Error: hoge",
68 | ok: true,
69 | exitCode: 1,
70 | },
71 | {
72 | // valid, and isPR
73 | config: Config{
74 | Token: "token",
75 | Owner: "owner",
76 | Repo: "repo",
77 | PR: PullRequest{
78 | Revision: "",
79 | Number: 1,
80 | Message: "message",
81 | },
82 | Parser: terraform.NewPlanParser(),
83 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
84 | },
85 | body: "Plan: 1 to add",
86 | ok: true,
87 | exitCode: 0,
88 | },
89 | {
90 | // valid, isPR, and cannot comment details because body is too long
91 | config: Config{
92 | Token: "token",
93 | Owner: "owner",
94 | Repo: "repo",
95 | PR: PullRequest{
96 | Revision: "",
97 | Number: 1,
98 | Message: "message",
99 | },
100 | Parser: terraform.NewPlanParser(),
101 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
102 | },
103 | body: fmt.Sprintf("Plan: 1 to add \n%065537s", "0"),
104 | ok: true,
105 | exitCode: 0,
106 | },
107 | {
108 | // valid, and isRevision
109 | config: Config{
110 | Token: "token",
111 | Owner: "owner",
112 | Repo: "repo",
113 | PR: PullRequest{
114 | Revision: "revision-revision",
115 | Number: 0,
116 | Message: "message",
117 | },
118 | Parser: terraform.NewPlanParser(),
119 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
120 | },
121 | body: "Plan: 1 to add",
122 | ok: true,
123 | exitCode: 0,
124 | },
125 | {
126 | // valid, and contains destroy
127 | // TODO(dtan4): check two comments were made actually
128 | config: Config{
129 | Token: "token",
130 | Owner: "owner",
131 | Repo: "repo",
132 | PR: PullRequest{
133 | Revision: "",
134 | Number: 1,
135 | Message: "message",
136 | },
137 | Parser: terraform.NewPlanParser(),
138 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
139 | DestroyWarningTemplate: terraform.NewDestroyWarningTemplate(terraform.DefaultDestroyWarningTemplate),
140 | WarnDestroy: true,
141 | },
142 | body: "Plan: 1 to add, 1 to destroy",
143 | ok: true,
144 | exitCode: 0,
145 | },
146 | {
147 | // valid with no changes
148 | // TODO(drlau): check that the label was actually added
149 | config: Config{
150 | Token: "token",
151 | Owner: "owner",
152 | Repo: "repo",
153 | PR: PullRequest{
154 | Revision: "",
155 | Number: 1,
156 | Message: "message",
157 | },
158 | Parser: terraform.NewPlanParser(),
159 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
160 | ResultLabels: ResultLabels{
161 | AddOrUpdateLabel: "add-or-update",
162 | DestroyLabel: "destroy",
163 | NoChangesLabel: "no-changes",
164 | PlanErrorLabel: "error",
165 | },
166 | },
167 | body: "No changes. Infrastructure is up-to-date.",
168 | ok: true,
169 | exitCode: 0,
170 | },
171 | {
172 | // valid, contains destroy, but not to notify
173 | config: Config{
174 | Token: "token",
175 | Owner: "owner",
176 | Repo: "repo",
177 | PR: PullRequest{
178 | Revision: "",
179 | Number: 1,
180 | Message: "message",
181 | },
182 | Parser: terraform.NewPlanParser(),
183 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
184 | DestroyWarningTemplate: terraform.NewDestroyWarningTemplate(terraform.DefaultDestroyWarningTemplate),
185 | WarnDestroy: false,
186 | },
187 | body: "Plan: 1 to add, 1 to destroy",
188 | ok: true,
189 | exitCode: 0,
190 | },
191 | {
192 | // apply case without merge commit
193 | config: Config{
194 | Token: "token",
195 | Owner: "owner",
196 | Repo: "repo",
197 | PR: PullRequest{
198 | Revision: "revision",
199 | Number: 0, // For apply, it is always 0
200 | Message: "message",
201 | },
202 | Parser: terraform.NewApplyParser(),
203 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate),
204 | },
205 | body: "Apply complete!",
206 | ok: true,
207 | exitCode: 0,
208 | },
209 | {
210 | // apply case as merge commit
211 | // TODO(drlau): validate cfg.PR.Number = 123
212 | config: Config{
213 | Token: "token",
214 | Owner: "owner",
215 | Repo: "repo",
216 | PR: PullRequest{
217 | Revision: "Merge pull request #123 from mercari/tfnotify",
218 | Number: 0, // For apply, it is always 0
219 | Message: "message",
220 | },
221 | Parser: terraform.NewApplyParser(),
222 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate),
223 | },
224 | body: "Apply complete!",
225 | ok: true,
226 | exitCode: 0,
227 | },
228 | }
229 |
230 | for _, testCase := range testCases {
231 | client, err := NewClient(testCase.config)
232 | if err != nil {
233 | t.Fatal(err)
234 | }
235 | api := newFakeAPI()
236 | client.API = &api
237 | exitCode, err := client.Notify.Notify(testCase.body)
238 | if (err == nil) != testCase.ok {
239 | t.Errorf("got error %q", err)
240 | }
241 | if exitCode != testCase.exitCode {
242 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode)
243 | }
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/notifier/gitlab/client.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "strings"
7 |
8 | "github.com/mercari/tfnotify/terraform"
9 |
10 | gitlab "github.com/xanzy/go-gitlab"
11 | )
12 |
13 | // EnvToken is GitLab API Token
14 | const EnvToken = "GITLAB_TOKEN"
15 |
16 | // EnvBaseURL is GitLab base URL. This can be set to a domain endpoint to use with Private GitLab.
17 | const EnvBaseURL = "GITLAB_BASE_URL"
18 |
19 | // Client ...
20 | type Client struct {
21 | *gitlab.Client
22 | Debug bool
23 | Config Config
24 |
25 | common service
26 |
27 | Comment *CommentService
28 | Commits *CommitsService
29 | Notify *NotifyService
30 |
31 | API API
32 | }
33 |
34 | // Config is a configuration for GitHub client
35 | type Config struct {
36 | Token string
37 | BaseURL string
38 | NameSpace string
39 | Project string
40 | MR MergeRequest
41 | CI string
42 | Parser terraform.Parser
43 | Template terraform.Template
44 | }
45 |
46 | // MergeRequest represents GitLab Merge Request metadata
47 | type MergeRequest struct {
48 | Revision string
49 | Title string
50 | Message string
51 | Number int
52 | }
53 |
54 | type service struct {
55 | client *Client
56 | }
57 |
58 | // NewClient returns Client initialized with Config
59 | func NewClient(cfg Config) (*Client, error) {
60 | token := cfg.Token
61 | token = strings.TrimPrefix(token, "$")
62 | if token == EnvToken {
63 | token = os.Getenv(EnvToken)
64 | }
65 | if token == "" {
66 | return &Client{}, errors.New("gitlab token is missing")
67 | }
68 |
69 | baseURL := cfg.BaseURL
70 | baseURL = strings.TrimPrefix(baseURL, "$")
71 | if baseURL == EnvBaseURL {
72 | baseURL = os.Getenv(EnvBaseURL)
73 | }
74 |
75 | option := make([]gitlab.ClientOptionFunc, 0, 1)
76 | if baseURL != "" {
77 | option = append(option, gitlab.WithBaseURL(baseURL))
78 | }
79 |
80 | client, err := gitlab.NewClient(token, option...)
81 |
82 | if err != nil {
83 | return &Client{}, err
84 | }
85 |
86 | c := &Client{
87 | Config: cfg,
88 | Client: client,
89 | }
90 | c.common.client = c
91 | c.Comment = (*CommentService)(&c.common)
92 | c.Commits = (*CommitsService)(&c.common)
93 | c.Notify = (*NotifyService)(&c.common)
94 |
95 | c.API = &GitLab{
96 | Client: client,
97 | namespace: cfg.NameSpace,
98 | project: cfg.Project,
99 | }
100 |
101 | return c, nil
102 | }
103 |
104 | // IsNumber returns true if MergeRequest is Merge Request build
105 | func (mr *MergeRequest) IsNumber() bool {
106 | return mr.Number != 0
107 | }
108 |
--------------------------------------------------------------------------------
/notifier/gitlab/client_test.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestNewClient(t *testing.T) {
9 | gitlabToken := os.Getenv(EnvToken)
10 | defer func() {
11 | os.Setenv(EnvToken, gitlabToken)
12 | }()
13 | os.Setenv(EnvToken, "")
14 |
15 | testCases := []struct {
16 | config Config
17 | envToken string
18 | expect string
19 | }{
20 | {
21 | // specify directly
22 | config: Config{Token: "abcdefg"},
23 | envToken: "",
24 | expect: "",
25 | },
26 | {
27 | // specify via env but not to be set env (part 1)
28 | config: Config{Token: "GITLAB_TOKEN"},
29 | envToken: "",
30 | expect: "gitlab token is missing",
31 | },
32 | {
33 | // specify via env (part 1)
34 | config: Config{Token: "GITLAB_TOKEN"},
35 | envToken: "abcdefg",
36 | expect: "",
37 | },
38 | {
39 | // specify via env but not to be set env (part 2)
40 | config: Config{Token: "$GITLAB_TOKEN"},
41 | envToken: "",
42 | expect: "gitlab token is missing",
43 | },
44 | {
45 | // specify via env (part 2)
46 | config: Config{Token: "$GITLAB_TOKEN"},
47 | envToken: "abcdefg",
48 | expect: "",
49 | },
50 | {
51 | // no specification (part 1)
52 | config: Config{},
53 | envToken: "",
54 | expect: "gitlab token is missing",
55 | },
56 | {
57 | // no specification (part 2)
58 | config: Config{},
59 | envToken: "abcdefg",
60 | expect: "gitlab token is missing",
61 | },
62 | }
63 | for _, testCase := range testCases {
64 | os.Setenv(EnvToken, testCase.envToken)
65 | _, err := NewClient(testCase.config)
66 | if err == nil {
67 | continue
68 | }
69 | if err.Error() != testCase.expect {
70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect)
71 | }
72 | }
73 | }
74 |
75 | func TestNewClientWithBaseURL(t *testing.T) {
76 | gitlabBaseURL := os.Getenv(EnvBaseURL)
77 | defer func() {
78 | os.Setenv(EnvBaseURL, gitlabBaseURL)
79 | }()
80 | os.Setenv(EnvBaseURL, "")
81 |
82 | testCases := []struct {
83 | config Config
84 | envBaseURL string
85 | expect string
86 | }{
87 | {
88 | // specify directly
89 | config: Config{
90 | Token: "abcdefg",
91 | BaseURL: "https://git.example.com/",
92 | },
93 | envBaseURL: "",
94 | expect: "https://git.example.com/api/v4/",
95 | },
96 | {
97 | // specify via env but not to be set env (part 1)
98 | config: Config{
99 | Token: "abcdefg",
100 | BaseURL: "GITLAB_BASE_URL",
101 | },
102 | envBaseURL: "",
103 | expect: "https://gitlab.com/api/v4/",
104 | },
105 | {
106 | // specify via env (part 1)
107 | config: Config{
108 | Token: "abcdefg",
109 | BaseURL: "GITLAB_BASE_URL",
110 | },
111 | envBaseURL: "https://git.example.com/",
112 | expect: "https://git.example.com/api/v4/",
113 | },
114 | {
115 | // specify via env but not to be set env (part 2)
116 | config: Config{
117 | Token: "abcdefg",
118 | BaseURL: "$GITLAB_BASE_URL",
119 | },
120 | envBaseURL: "",
121 | expect: "https://gitlab.com/api/v4/",
122 | },
123 | {
124 | // specify via env (part 2)
125 | config: Config{
126 | Token: "abcdefg",
127 | BaseURL: "$GITLAB_BASE_URL",
128 | },
129 | envBaseURL: "https://git.example.com/",
130 | expect: "https://git.example.com/api/v4/",
131 | },
132 | {
133 | // no specification (part 1)
134 | config: Config{Token: "abcdefg"},
135 | envBaseURL: "",
136 | expect: "https://gitlab.com/api/v4/",
137 | },
138 | {
139 | // no specification (part 2)
140 | config: Config{Token: "abcdefg"},
141 | envBaseURL: "https://git.example.com/",
142 | expect: "https://gitlab.com/api/v4/",
143 | },
144 | }
145 | for _, testCase := range testCases {
146 | os.Setenv(EnvBaseURL, testCase.envBaseURL)
147 | c, err := NewClient(testCase.config)
148 | if err != nil {
149 | continue
150 | }
151 | url := c.Client.BaseURL().String()
152 | if url != testCase.expect {
153 | t.Errorf("got %q but want %q", url, testCase.expect)
154 | }
155 | }
156 | }
157 |
158 | func TestIsNumber(t *testing.T) {
159 | testCases := []struct {
160 | mr MergeRequest
161 | isPR bool
162 | }{
163 | {
164 | mr: MergeRequest{
165 | Number: 0,
166 | },
167 | isPR: false,
168 | },
169 | {
170 | mr: MergeRequest{
171 | Number: 123,
172 | },
173 | isPR: true,
174 | },
175 | }
176 | for _, testCase := range testCases {
177 | if testCase.mr.IsNumber() != testCase.isPR {
178 | t.Errorf("got %v but want %v", testCase.mr.IsNumber(), testCase.isPR)
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/notifier/gitlab/comment.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 |
7 | gitlab "github.com/xanzy/go-gitlab"
8 | )
9 |
10 | // CommentService handles communication with the comment related
11 | // methods of GitLab API
12 | type CommentService service
13 |
14 | // PostOptions specifies the optional parameters to post comments to a pull request
15 | type PostOptions struct {
16 | Number int
17 | Revision string
18 | }
19 |
20 | // Post posts comment
21 | func (g *CommentService) Post(body string, opt PostOptions) error {
22 | if opt.Number != 0 {
23 | _, _, err := g.client.API.CreateMergeRequestNote(
24 | opt.Number,
25 | &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.String(body)},
26 | )
27 | return err
28 | }
29 | if opt.Revision != "" {
30 | _, _, err := g.client.API.PostCommitComment(
31 | opt.Revision,
32 | &gitlab.PostCommitCommentOptions{Note: gitlab.String(body)},
33 | )
34 | return err
35 | }
36 | return fmt.Errorf("gitlab.comment.post: Number or Revision is required")
37 | }
38 |
39 | // List lists comments on GitLab merge requests
40 | func (g *CommentService) List(number int) ([]*gitlab.Note, error) {
41 | comments, _, err := g.client.API.ListMergeRequestNotes(
42 | number,
43 | &gitlab.ListMergeRequestNotesOptions{},
44 | )
45 | return comments, err
46 | }
47 |
48 | // Delete deletes comment on GitLab merge requests
49 | func (g *CommentService) Delete(note int) error {
50 | _, err := g.client.API.DeleteMergeRequestNote(
51 | g.client.Config.MR.Number,
52 | note,
53 | )
54 | return err
55 | }
56 |
57 | // DeleteDuplicates deletes duplicate comments containing arbitrary character strings
58 | func (g *CommentService) DeleteDuplicates(title string) {
59 | var ids []int
60 | comments := g.getDuplicates(title)
61 | for _, comment := range comments {
62 | ids = append(ids, comment.ID)
63 | }
64 | for _, id := range ids {
65 | // don't handle error
66 | g.client.Comment.Delete(id)
67 | }
68 | }
69 |
70 | func (g *CommentService) getDuplicates(title string) []*gitlab.Note {
71 | var dup []*gitlab.Note
72 | re := regexp.MustCompile(`(?m)^(\n+)?` + title + `( +.*)?\n+` + g.client.Config.MR.Message + `\n+`)
73 |
74 | comments, _ := g.client.Comment.List(g.client.Config.MR.Number)
75 | for _, comment := range comments {
76 | if re.MatchString(comment.Body) {
77 | dup = append(dup, comment)
78 | }
79 | }
80 |
81 | return dup
82 | }
83 |
--------------------------------------------------------------------------------
/notifier/gitlab/comment_test.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | gitlab "github.com/xanzy/go-gitlab"
8 | )
9 |
10 | func TestCommentPost(t *testing.T) {
11 | testCases := []struct {
12 | config Config
13 | body string
14 | opt PostOptions
15 | ok bool
16 | }{
17 | {
18 | config: newFakeConfig(),
19 | body: "",
20 | opt: PostOptions{
21 | Number: 1,
22 | Revision: "abcd",
23 | },
24 | ok: true,
25 | },
26 | {
27 | config: newFakeConfig(),
28 | body: "",
29 | opt: PostOptions{
30 | Number: 0,
31 | Revision: "abcd",
32 | },
33 | ok: true,
34 | },
35 | {
36 | config: newFakeConfig(),
37 | body: "",
38 | opt: PostOptions{
39 | Number: 2,
40 | Revision: "",
41 | },
42 | ok: true,
43 | },
44 | {
45 | config: newFakeConfig(),
46 | body: "",
47 | opt: PostOptions{
48 | Number: 0,
49 | Revision: "",
50 | },
51 | ok: false,
52 | },
53 | }
54 |
55 | for _, testCase := range testCases {
56 | client, err := NewClient(testCase.config)
57 | if err != nil {
58 | t.Fatal(err)
59 | }
60 | api := newFakeAPI()
61 | client.API = &api
62 | err = client.Comment.Post(testCase.body, testCase.opt)
63 | if (err == nil) != testCase.ok {
64 | t.Errorf("got error %q", err)
65 | }
66 | }
67 | }
68 |
69 | func TestCommentList(t *testing.T) {
70 | comments := []*gitlab.Note{
71 | &gitlab.Note{
72 | ID: 371748792,
73 | Body: "comment 1",
74 | },
75 | &gitlab.Note{
76 | ID: 371765743,
77 | Body: "comment 2",
78 | },
79 | }
80 | testCases := []struct {
81 | config Config
82 | number int
83 | ok bool
84 | comments []*gitlab.Note
85 | }{
86 | {
87 | config: newFakeConfig(),
88 | number: 1,
89 | ok: true,
90 | comments: comments,
91 | },
92 | {
93 | config: newFakeConfig(),
94 | number: 12,
95 | ok: true,
96 | comments: comments,
97 | },
98 | {
99 | config: newFakeConfig(),
100 | number: 123,
101 | ok: true,
102 | comments: comments,
103 | },
104 | }
105 |
106 | for _, testCase := range testCases {
107 | client, err := NewClient(testCase.config)
108 | if err != nil {
109 | t.Fatal(err)
110 | }
111 | api := newFakeAPI()
112 | client.API = &api
113 | comments, err := client.Comment.List(testCase.number)
114 | if (err == nil) != testCase.ok {
115 | t.Errorf("got error %q", err)
116 | }
117 | if !reflect.DeepEqual(comments, testCase.comments) {
118 | t.Errorf("got %v but want %v", comments, testCase.comments)
119 | }
120 | }
121 | }
122 |
123 | func TestCommentDelete(t *testing.T) {
124 | testCases := []struct {
125 | config Config
126 | id int
127 | ok bool
128 | }{
129 | {
130 | config: newFakeConfig(),
131 | id: 1,
132 | ok: true,
133 | },
134 | {
135 | config: newFakeConfig(),
136 | id: 12,
137 | ok: true,
138 | },
139 | {
140 | config: newFakeConfig(),
141 | id: 123,
142 | ok: true,
143 | },
144 | }
145 |
146 | for _, testCase := range testCases {
147 | client, err := NewClient(testCase.config)
148 | if err != nil {
149 | t.Fatal(err)
150 | }
151 | api := newFakeAPI()
152 | client.API = &api
153 | err = client.Comment.Delete(testCase.id)
154 | if (err == nil) != testCase.ok {
155 | t.Errorf("got error %q", err)
156 | }
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/notifier/gitlab/commits.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "errors"
5 |
6 | gitlab "github.com/xanzy/go-gitlab"
7 | )
8 |
9 | // CommitsService handles communication with the commits related
10 | // methods of GitLab API
11 | type CommitsService service
12 |
13 | // List lists commits on a repository
14 | func (g *CommitsService) List(revision string) ([]string, error) {
15 | if revision == "" {
16 | return []string{}, errors.New("no revision specified")
17 | }
18 | var s []string
19 | commits, _, err := g.client.API.ListCommits(
20 | &gitlab.ListCommitsOptions{},
21 | )
22 | if err != nil {
23 | return s, err
24 | }
25 | for _, commit := range commits {
26 | s = append(s, commit.ID)
27 | }
28 | return s, nil
29 | }
30 |
31 | // lastOne returns the hash of the previous commit of the given commit
32 | func (g *CommitsService) lastOne(commits []string, revision string) (string, error) {
33 | if revision == "" {
34 | return "", errors.New("no revision specified")
35 | }
36 | if len(commits) == 0 {
37 | return "", errors.New("no commits")
38 | }
39 |
40 | return commits[1], nil
41 | }
42 |
--------------------------------------------------------------------------------
/notifier/gitlab/commits_test.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestCommitsList(t *testing.T) {
8 | testCases := []struct {
9 | revision string
10 | ok bool
11 | }{
12 | {
13 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
14 | ok: true,
15 | },
16 | {
17 | revision: "",
18 | ok: false,
19 | },
20 | }
21 |
22 | for _, testCase := range testCases {
23 | cfg := newFakeConfig()
24 | client, err := NewClient(cfg)
25 | if err != nil {
26 | t.Fatal(err)
27 | }
28 | api := newFakeAPI()
29 | client.API = &api
30 | _, err = client.Commits.List(testCase.revision)
31 | if (err == nil) != testCase.ok {
32 | t.Errorf("got error %q", err)
33 | }
34 | }
35 | }
36 |
37 | func TestCommitsLastOne(t *testing.T) {
38 | testCases := []struct {
39 | commits []string
40 | revision string
41 | lastRev string
42 | ok bool
43 | }{
44 | {
45 | // ok
46 | commits: []string{
47 | "04e0917e448b662c2b16330fad50e97af16ff27a",
48 | "04e0917e448b662c2b16330fad50e97af16ff27b",
49 | "04e0917e448b662c2b16330fad50e97af16ff27c",
50 | },
51 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
52 | lastRev: "04e0917e448b662c2b16330fad50e97af16ff27b",
53 | ok: true,
54 | },
55 | {
56 | // no revision
57 | commits: []string{
58 | "04e0917e448b662c2b16330fad50e97af16ff27a",
59 | "04e0917e448b662c2b16330fad50e97af16ff27b",
60 | "04e0917e448b662c2b16330fad50e97af16ff27c",
61 | },
62 | revision: "",
63 | lastRev: "",
64 | ok: false,
65 | },
66 | {
67 | // no commits
68 | commits: []string{},
69 | revision: "04e0917e448b662c2b16330fad50e97af16ff27a",
70 | lastRev: "",
71 | ok: false,
72 | },
73 | }
74 |
75 | for _, testCase := range testCases {
76 | cfg := newFakeConfig()
77 | client, err := NewClient(cfg)
78 | if err != nil {
79 | t.Fatal(err)
80 | }
81 | api := newFakeAPI()
82 | client.API = &api
83 | commit, err := client.Commits.lastOne(testCase.commits, testCase.revision)
84 | if (err == nil) != testCase.ok {
85 | t.Errorf("got error %q", err)
86 | }
87 | if commit != testCase.lastRev {
88 | t.Errorf("got %q but want %q", commit, testCase.lastRev)
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/notifier/gitlab/gitlab.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "fmt"
5 | gitlab "github.com/xanzy/go-gitlab"
6 | )
7 |
8 | // API is GitLab API interface
9 | type API interface {
10 | CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
11 | DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
12 | ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error)
13 | PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error)
14 | ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error)
15 | }
16 |
17 | // GitLab represents the attribute information necessary for requesting GitLab API
18 | type GitLab struct {
19 | *gitlab.Client
20 | namespace, project string
21 | }
22 |
23 | // CreateMergeRequestNote is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.CreateMergeRequestNote
24 | func (g *GitLab) CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
25 | return g.Client.Notes.CreateMergeRequestNote(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, opt, options...)
26 | }
27 |
28 | // DeleteMergeRequestNote is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.DeleteMergeRequestNote
29 | func (g *GitLab) DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
30 | return g.Client.Notes.DeleteMergeRequestNote(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, note, options...)
31 | }
32 |
33 | // ListMergeRequestNotes is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#NotesService.ListMergeRequestNotes
34 | func (g *GitLab) ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) {
35 | return g.Client.Notes.ListMergeRequestNotes(fmt.Sprintf("%s/%s", g.namespace, g.project), mergeRequest, opt, options...)
36 | }
37 |
38 | // PostCommitComment is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#CommitsService.PostCommitComment
39 | func (g *GitLab) PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) {
40 | return g.Client.Commits.PostCommitComment(fmt.Sprintf("%s/%s", g.namespace, g.project), sha, opt, options...)
41 | }
42 |
43 | // ListCommits is a wrapper of https://godoc.org/github.com/xanzy/go-gitlab#CommitsService.ListCommits
44 | func (g *GitLab) ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) {
45 | return g.Client.Commits.ListCommits(fmt.Sprintf("%s/%s", g.namespace, g.project), opt, options...)
46 | }
47 |
--------------------------------------------------------------------------------
/notifier/gitlab/gitlab_test.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "github.com/mercari/tfnotify/terraform"
5 | gitlab "github.com/xanzy/go-gitlab"
6 | )
7 |
8 | type fakeAPI struct {
9 | API
10 | FakeCreateMergeRequestNote func(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
11 | FakeDeleteMergeRequestNote func(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
12 | FakeListMergeRequestNotes func(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error)
13 | FakePostCommitComment func(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error)
14 | FakeListCommits func(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error)
15 | }
16 |
17 | func (g *fakeAPI) CreateMergeRequestNote(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
18 | return g.FakeCreateMergeRequestNote(mergeRequest, opt, options...)
19 | }
20 |
21 | func (g *fakeAPI) DeleteMergeRequestNote(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
22 | return g.FakeDeleteMergeRequestNote(mergeRequest, note, options...)
23 | }
24 |
25 | func (g *fakeAPI) ListMergeRequestNotes(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) {
26 | return g.FakeListMergeRequestNotes(mergeRequest, opt, options...)
27 | }
28 |
29 | func (g *fakeAPI) PostCommitComment(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) {
30 | return g.FakePostCommitComment(sha, opt, options...)
31 | }
32 |
33 | func (g *fakeAPI) ListCommits(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) {
34 | return g.FakeListCommits(opt, options...)
35 | }
36 |
37 | func newFakeAPI() fakeAPI {
38 | return fakeAPI{
39 | FakeCreateMergeRequestNote: func(mergeRequest int, opt *gitlab.CreateMergeRequestNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
40 | return &gitlab.Note{
41 | ID: 371748792,
42 | Body: "comment 1",
43 | }, nil, nil
44 | },
45 | FakeDeleteMergeRequestNote: func(mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
46 | return nil, nil
47 | },
48 | FakeListMergeRequestNotes: func(mergeRequest int, opt *gitlab.ListMergeRequestNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Note, *gitlab.Response, error) {
49 | var comments []*gitlab.Note
50 | comments = []*gitlab.Note{
51 | &gitlab.Note{
52 | ID: 371748792,
53 | Body: "comment 1",
54 | },
55 | &gitlab.Note{
56 | ID: 371765743,
57 | Body: "comment 2",
58 | },
59 | }
60 | return comments, nil, nil
61 | },
62 | FakePostCommitComment: func(sha string, opt *gitlab.PostCommitCommentOptions, options ...gitlab.RequestOptionFunc) (*gitlab.CommitComment, *gitlab.Response, error) {
63 | return &gitlab.CommitComment{
64 | Note: "comment 1",
65 | }, nil, nil
66 | },
67 | FakeListCommits: func(opt *gitlab.ListCommitsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Commit, *gitlab.Response, error) {
68 | var commits []*gitlab.Commit
69 | commits = []*gitlab.Commit{
70 | &gitlab.Commit{
71 | ID: "04e0917e448b662c2b16330fad50e97af16ff27a",
72 | },
73 | &gitlab.Commit{
74 | ID: "04e0917e448b662c2b16330fad50e97af16ff27b",
75 | },
76 | &gitlab.Commit{
77 | ID: "04e0917e448b662c2b16330fad50e97af16ff27c",
78 | },
79 | }
80 | return commits, nil, nil
81 | },
82 | }
83 | }
84 |
85 | func newFakeConfig() Config {
86 | return Config{
87 | Token: "token",
88 | NameSpace: "owner",
89 | Project: "repo",
90 | MR: MergeRequest{
91 | Revision: "abcd",
92 | Number: 1,
93 | Message: "message",
94 | },
95 | Parser: terraform.NewPlanParser(),
96 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/notifier/gitlab/notify.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "github.com/mercari/tfnotify/terraform"
5 | )
6 |
7 | // NotifyService handles communication with the notification related
8 | // methods of GitHub API
9 | type NotifyService service
10 |
11 | // Notify posts comment optimized for notifications
12 | func (g *NotifyService) Notify(body string) (exit int, err error) {
13 | cfg := g.client.Config
14 | parser := g.client.Config.Parser
15 | template := g.client.Config.Template
16 |
17 | result := parser.Parse(body)
18 | if result.Error != nil {
19 | return result.ExitCode, result.Error
20 | }
21 | if result.Result == "" {
22 | return result.ExitCode, result.Error
23 | }
24 |
25 | template.SetValue(terraform.CommonTemplate{
26 | Title: cfg.MR.Title,
27 | Message: cfg.MR.Message,
28 | Result: result.Result,
29 | Body: body,
30 | Link: cfg.CI,
31 | })
32 | body, err = template.Execute()
33 | if err != nil {
34 | return result.ExitCode, err
35 | }
36 |
37 | value := template.GetValue()
38 |
39 | if cfg.MR.IsNumber() {
40 | g.client.Comment.DeleteDuplicates(value.Title)
41 | }
42 |
43 | _, isApply := parser.(*terraform.ApplyParser)
44 | if !cfg.MR.IsNumber() && isApply {
45 | commits, err := g.client.Commits.List(cfg.MR.Revision)
46 | if err != nil {
47 | return result.ExitCode, err
48 | }
49 | lastRevision, _ := g.client.Commits.lastOne(commits, cfg.MR.Revision)
50 | cfg.MR.Revision = lastRevision
51 | }
52 |
53 | return result.ExitCode, g.client.Comment.Post(body, PostOptions{
54 | Number: cfg.MR.Number,
55 | Revision: cfg.MR.Revision,
56 | })
57 | }
58 |
--------------------------------------------------------------------------------
/notifier/gitlab/notify_test.go:
--------------------------------------------------------------------------------
1 | package gitlab
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/mercari/tfnotify/terraform"
7 | )
8 |
9 | func TestNotifyNotify(t *testing.T) {
10 | testCases := []struct {
11 | config Config
12 | body string
13 | ok bool
14 | exitCode int
15 | }{
16 | {
17 | // invalid body (cannot parse)
18 | config: Config{
19 | Token: "token",
20 | NameSpace: "namespace",
21 | Project: "project",
22 | MR: MergeRequest{
23 | Revision: "abcd",
24 | Number: 1,
25 | Message: "message",
26 | },
27 | Parser: terraform.NewPlanParser(),
28 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
29 | },
30 | body: "body",
31 | ok: false,
32 | exitCode: 1,
33 | },
34 | {
35 | // invalid pr
36 | config: Config{
37 | Token: "token",
38 | NameSpace: "owner",
39 | Project: "repo",
40 | MR: MergeRequest{
41 | Revision: "",
42 | Number: 0,
43 | Message: "message",
44 | },
45 | Parser: terraform.NewPlanParser(),
46 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
47 | },
48 | body: "Plan: 1 to add",
49 | ok: false,
50 | exitCode: 0,
51 | },
52 | {
53 | // valid, error
54 | config: Config{
55 | Token: "token",
56 | NameSpace: "owner",
57 | Project: "repo",
58 | MR: MergeRequest{
59 | Revision: "",
60 | Number: 1,
61 | Message: "message",
62 | },
63 | Parser: terraform.NewPlanParser(),
64 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
65 | },
66 | body: "Error: hoge",
67 | ok: true,
68 | exitCode: 1,
69 | },
70 | {
71 | // valid, and isPR
72 | config: Config{
73 | Token: "token",
74 | NameSpace: "owner",
75 | Project: "repo",
76 | MR: MergeRequest{
77 | Revision: "",
78 | Number: 1,
79 | Message: "message",
80 | },
81 | Parser: terraform.NewPlanParser(),
82 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
83 | },
84 | body: "Plan: 1 to add",
85 | ok: true,
86 | exitCode: 0,
87 | },
88 | {
89 | // valid, and isRevision
90 | config: Config{
91 | Token: "token",
92 | NameSpace: "owner",
93 | Project: "repo",
94 | MR: MergeRequest{
95 | Revision: "revision-revision",
96 | Number: 0,
97 | Message: "message",
98 | },
99 | Parser: terraform.NewPlanParser(),
100 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
101 | },
102 | body: "Plan: 1 to add",
103 | ok: true,
104 | exitCode: 0,
105 | },
106 | {
107 | // apply case
108 | config: Config{
109 | Token: "token",
110 | NameSpace: "owner",
111 | Project: "repo",
112 | MR: MergeRequest{
113 | Revision: "revision",
114 | Number: 0, // For apply, it is always 0
115 | Message: "message",
116 | },
117 | Parser: terraform.NewApplyParser(),
118 | Template: terraform.NewApplyTemplate(terraform.DefaultApplyTemplate),
119 | },
120 | body: "Apply complete!",
121 | ok: true,
122 | exitCode: 0,
123 | },
124 | }
125 |
126 | for _, testCase := range testCases {
127 | client, err := NewClient(testCase.config)
128 | if err != nil {
129 | t.Fatal(err)
130 | }
131 | api := newFakeAPI()
132 | client.API = &api
133 | exitCode, err := client.Notify.Notify(testCase.body)
134 | if (err == nil) != testCase.ok {
135 | t.Errorf("got error %q", err)
136 | }
137 | if exitCode != testCase.exitCode {
138 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode)
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/notifier/notifier.go:
--------------------------------------------------------------------------------
1 | package notifier
2 |
3 | // Notifier is a notification interface
4 | type Notifier interface {
5 | Notify(body string) (exit int, err error)
6 | }
7 |
--------------------------------------------------------------------------------
/notifier/notifier_test.go:
--------------------------------------------------------------------------------
1 | package notifier
2 |
--------------------------------------------------------------------------------
/notifier/slack/client.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "strings"
7 |
8 | "github.com/lestrrat-go/slack"
9 | "github.com/mercari/tfnotify/terraform"
10 | )
11 |
12 | // EnvToken is Slack API Token
13 | const EnvToken = "SLACK_TOKEN"
14 |
15 | // EnvChannelID is Slack channel ID
16 | const EnvChannelID = "SLACK_CHANNEL_ID"
17 |
18 | // EnvBotName is Slack bot name
19 | const EnvBotName = "SLACK_BOT_NAME"
20 |
21 | // Client is a API client for Slack
22 | type Client struct {
23 | *slack.Client
24 |
25 | Config Config
26 |
27 | common service
28 |
29 | Notify *NotifyService
30 |
31 | API API
32 | }
33 |
34 | // Config is a configuration for Slack client
35 | type Config struct {
36 | Token string
37 | Channel string
38 | Botname string
39 | Title string
40 | Message string
41 | CI string
42 | Parser terraform.Parser
43 | UseRawOutput bool
44 | Template terraform.Template
45 | }
46 |
47 | type service struct {
48 | client *Client
49 | }
50 |
51 | // NewClient returns Client initialized with Config
52 | func NewClient(cfg Config) (*Client, error) {
53 | token := cfg.Token
54 | token = strings.TrimPrefix(token, "$")
55 | if token == EnvToken {
56 | token = os.Getenv(EnvToken)
57 | }
58 | if token == "" {
59 | return &Client{}, errors.New("slack token is missing")
60 | }
61 |
62 | channel := cfg.Channel
63 | channel = strings.TrimPrefix(channel, "$")
64 | if channel == EnvChannelID {
65 | channel = os.Getenv(EnvChannelID)
66 | }
67 |
68 | botname := cfg.Botname
69 | botname = strings.TrimPrefix(botname, "$")
70 | if botname == EnvBotName {
71 | botname = os.Getenv(EnvBotName)
72 | }
73 |
74 | client := slack.New(token)
75 | c := &Client{
76 | Config: cfg,
77 | Client: client,
78 | }
79 | c.common.client = c
80 | c.Notify = (*NotifyService)(&c.common)
81 | c.API = &Slack{
82 | Client: client,
83 | Channel: channel,
84 | Botname: botname,
85 | }
86 | return c, nil
87 | }
88 |
--------------------------------------------------------------------------------
/notifier/slack/client_test.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestNewClient(t *testing.T) {
9 | slackToken := os.Getenv(EnvToken)
10 | defer func() {
11 | os.Setenv(EnvToken, slackToken)
12 | }()
13 | os.Setenv(EnvToken, "")
14 |
15 | testCases := []struct {
16 | config Config
17 | envToken string
18 | expect string
19 | }{
20 | {
21 | // specify directly
22 | config: Config{Token: "abcdefg"},
23 | envToken: "",
24 | expect: "",
25 | },
26 | {
27 | // specify via env but not to be set env (part 1)
28 | config: Config{Token: "SLACK_TOKEN"},
29 | envToken: "",
30 | expect: "slack token is missing",
31 | },
32 | {
33 | // specify via env (part 1)
34 | config: Config{Token: "SLACK_TOKEN"},
35 | envToken: "abcdefg",
36 | expect: "",
37 | },
38 | {
39 | // specify via env but not to be set env (part 2)
40 | config: Config{Token: "$SLACK_TOKEN"},
41 | envToken: "",
42 | expect: "slack token is missing",
43 | },
44 | {
45 | // specify via env (part 2)
46 | config: Config{Token: "$SLACK_TOKEN"},
47 | envToken: "abcdefg",
48 | expect: "",
49 | },
50 | {
51 | // no specification (part 1)
52 | config: Config{},
53 | envToken: "",
54 | expect: "slack token is missing",
55 | },
56 | {
57 | // no specification (part 2)
58 | config: Config{},
59 | envToken: "abcdefg",
60 | expect: "slack token is missing",
61 | },
62 | }
63 | for _, testCase := range testCases {
64 | os.Setenv(EnvToken, testCase.envToken)
65 | _, err := NewClient(testCase.config)
66 | if err == nil {
67 | continue
68 | }
69 | if err.Error() != testCase.expect {
70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/notifier/slack/notify.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/lestrrat-go/slack/objects"
8 | "github.com/mercari/tfnotify/terraform"
9 | )
10 |
11 | // NotifyService handles communication with the notification related
12 | // methods of Slack API
13 | type NotifyService service
14 |
15 | // Notify posts comment optimized for notifications
16 | func (s *NotifyService) Notify(body string) (exit int, err error) {
17 | cfg := s.client.Config
18 | parser := s.client.Config.Parser
19 | template := s.client.Config.Template
20 |
21 | if cfg.Channel == "" {
22 | return terraform.ExitFail, errors.New("channel id is required")
23 | }
24 |
25 | result := parser.Parse(body)
26 | if result.Error != nil {
27 | return result.ExitCode, result.Error
28 | }
29 | if result.Result == "" {
30 | return result.ExitCode, result.Error
31 | }
32 |
33 | color := "warning"
34 | switch result.ExitCode {
35 | case terraform.ExitPass:
36 | color = "good"
37 | case terraform.ExitFail:
38 | color = "danger"
39 | }
40 |
41 | template.SetValue(terraform.CommonTemplate{
42 | Title: cfg.Title,
43 | Message: cfg.Message,
44 | Result: result.Result,
45 | Body: body,
46 | UseRawOutput: cfg.UseRawOutput,
47 | Link: cfg.CI,
48 | })
49 | text, err := template.Execute()
50 | if err != nil {
51 | return result.ExitCode, err
52 | }
53 |
54 | var attachments objects.AttachmentList
55 | attachment := &objects.Attachment{
56 | Color: color,
57 | Fallback: text,
58 | Footer: cfg.CI,
59 | Text: text,
60 | Title: template.GetValue().Title,
61 | }
62 |
63 | attachments.Append(attachment)
64 | // _, err = s.client.Chat().PostMessage(cfg.Channel).Username(cfg.Botname).SetAttachments(attachments).Do(cfg.Context)
65 | _, err = s.client.API.ChatPostMessage(context.Background(), attachments)
66 | return result.ExitCode, err
67 | }
68 |
--------------------------------------------------------------------------------
/notifier/slack/notify_test.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/lestrrat-go/slack/objects"
8 | "github.com/mercari/tfnotify/terraform"
9 | )
10 |
11 | func TestNotify(t *testing.T) {
12 | testCases := []struct {
13 | config Config
14 | body string
15 | exitCode int
16 | ok bool
17 | }{
18 | {
19 | config: Config{
20 | Token: "token",
21 | Channel: "channel",
22 | Botname: "botname",
23 | Message: "",
24 | Parser: terraform.NewPlanParser(),
25 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
26 | },
27 | body: "Plan: 1 to add",
28 | exitCode: 0,
29 | ok: true,
30 | },
31 | {
32 | config: Config{
33 | Token: "token",
34 | Channel: "",
35 | Botname: "botname",
36 | Message: "",
37 | Parser: terraform.NewPlanParser(),
38 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
39 | },
40 | body: "Plan: 1 to add",
41 | exitCode: 1,
42 | ok: false,
43 | },
44 | }
45 | fake := fakeAPI{
46 | FakeChatPostMessage: func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
47 | return nil, nil
48 | },
49 | }
50 |
51 | for _, testCase := range testCases {
52 | client, err := NewClient(testCase.config)
53 | if err != nil {
54 | t.Fatal(err)
55 | }
56 | client.API = &fake
57 | exitCode, err := client.Notify.Notify(testCase.body)
58 | if (err == nil) != testCase.ok {
59 | t.Errorf("got error %q", err)
60 | }
61 | if exitCode != testCase.exitCode {
62 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode)
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/notifier/slack/slack.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/lestrrat-go/slack"
7 | "github.com/lestrrat-go/slack/objects"
8 | )
9 |
10 | // API is Slack API interface
11 | type API interface {
12 | ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error)
13 | }
14 |
15 | // Slack represents the attribute information necessary for requesting Slack API
16 | type Slack struct {
17 | *slack.Client
18 | Channel string
19 | Botname string
20 | }
21 |
22 | // ChatPostMessage is a wrapper of https://godoc.org/github.com/lestrrat-go/slack#ChatPostMessageCall
23 | func (s *Slack) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
24 | return s.Client.Chat().PostMessage(s.Channel).Username(s.Botname).SetAttachments(attachments).Do(ctx)
25 | }
26 |
--------------------------------------------------------------------------------
/notifier/slack/slack_test.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/lestrrat-go/slack/objects"
7 | )
8 |
9 | type fakeAPI struct {
10 | API
11 | FakeChatPostMessage func(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error)
12 | }
13 |
14 | func (g *fakeAPI) ChatPostMessage(ctx context.Context, attachments []*objects.Attachment) (*objects.ChatResponse, error) {
15 | return g.FakeChatPostMessage(ctx, attachments)
16 | }
17 |
--------------------------------------------------------------------------------
/notifier/typetalk/client.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "strconv"
7 |
8 | "github.com/mercari/tfnotify/terraform"
9 | typetalk "github.com/nulab/go-typetalk/typetalk/v1"
10 | )
11 |
12 | // EnvToken is Typetalk API Token
13 | const EnvToken = "TYPETALK_TOKEN"
14 |
15 | // EnvTopicID is Typetalk topic ID
16 | const EnvTopicID = "TYPETALK_TOPIC_ID"
17 |
18 | // Client represents Typetalk API client.
19 | type Client struct {
20 | *typetalk.Client
21 | Config Config
22 | common service
23 | Notify *NotifyService
24 | API API
25 | }
26 |
27 | // Config is a configuration for Typetalk Client
28 | type Config struct {
29 | Token string
30 | Title string
31 | TopicID string
32 | Message string
33 | CI string
34 | Parser terraform.Parser
35 | Template terraform.Template
36 | }
37 |
38 | type service struct {
39 | client *Client
40 | }
41 |
42 | // NewClient returns Client initialized with Config
43 | func NewClient(cfg Config) (*Client, error) {
44 | token := os.ExpandEnv(cfg.Token)
45 | if token == EnvToken {
46 | token = os.Getenv(EnvToken)
47 | }
48 | if token == "" {
49 | return &Client{}, errors.New("Typetalk token is missing")
50 | }
51 |
52 | topicIDString := os.ExpandEnv(cfg.TopicID)
53 | if topicIDString == EnvTopicID {
54 | topicIDString = os.Getenv(EnvTopicID)
55 | }
56 | if topicIDString == "" {
57 | return &Client{}, errors.New("Typetalk topic ID is missing")
58 | }
59 |
60 | topicID, err := strconv.Atoi(topicIDString)
61 | if err != nil {
62 | return &Client{}, errors.New("Typetalk topic ID is not numeric value")
63 | }
64 |
65 | client := typetalk.NewClient(nil)
66 | client.SetTypetalkToken(token)
67 | c := &Client{
68 | Config: cfg,
69 | Client: client,
70 | }
71 | c.common.client = c
72 | c.Notify = (*NotifyService)(&c.common)
73 | c.API = &Typetalk{
74 | Client: client,
75 | TopicID: topicID,
76 | }
77 |
78 | return c, nil
79 | }
80 |
--------------------------------------------------------------------------------
/notifier/typetalk/client_test.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestNewClient(t *testing.T) {
9 | typetalkToken := os.Getenv(EnvToken)
10 | defer func() {
11 | os.Setenv(EnvToken, typetalkToken)
12 | }()
13 | os.Setenv(EnvToken, "")
14 |
15 | testCases := []struct {
16 | config Config
17 | envToken string
18 | expect string
19 | }{
20 | {
21 | // specify directly
22 | config: Config{Token: "abcdefg", TopicID: "12345"},
23 | envToken: "",
24 | expect: "",
25 | },
26 | {
27 | // specify via env but not to be set env (part 1)
28 | config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"},
29 | envToken: "",
30 | expect: "Typetalk token is missing",
31 | },
32 | {
33 | // specify via env (part 1)
34 | config: Config{Token: "TYPETALK_TOKEN", TopicID: "12345"},
35 | envToken: "abcdefg",
36 | expect: "",
37 | },
38 | {
39 | // specify via env but not to be set env (part 2)
40 | config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"},
41 | envToken: "",
42 | expect: "Typetalk token is missing",
43 | },
44 | {
45 | // specify via env (part 2)
46 | config: Config{Token: "$TYPETALK_TOKEN", TopicID: "12345"},
47 | envToken: "abcdefg",
48 | expect: "",
49 | },
50 | {
51 | // no specification (part 1)
52 | config: Config{TopicID: "12345"},
53 | envToken: "",
54 | expect: "Typetalk token is missing",
55 | },
56 | {
57 | // no specification (part 2)
58 | config: Config{TopicID: "12345"},
59 | envToken: "abcdefg",
60 | expect: "Typetalk token is missing",
61 | },
62 | }
63 | for _, testCase := range testCases {
64 | os.Setenv(EnvToken, testCase.envToken)
65 | _, err := NewClient(testCase.config)
66 | if err == nil {
67 | continue
68 | }
69 | if err.Error() != testCase.expect {
70 | t.Errorf("got %q but want %q", err.Error(), testCase.expect)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/notifier/typetalk/notify.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/mercari/tfnotify/terraform"
8 | )
9 |
10 | // NotifyService handles notification process.
11 | type NotifyService service
12 |
13 | // Notify posts message to Typetalk.
14 | func (s *NotifyService) Notify(body string) (exit int, err error) {
15 | cfg := s.client.Config
16 | parser := s.client.Config.Parser
17 | template := s.client.Config.Template
18 |
19 | if cfg.TopicID == "" {
20 | return terraform.ExitFail, errors.New("topic id is required")
21 | }
22 |
23 | result := parser.Parse(body)
24 | if result.Error != nil {
25 | return result.ExitCode, result.Error
26 | }
27 | if result.Result == "" {
28 | return result.ExitCode, result.Error
29 | }
30 |
31 | template.SetValue(terraform.CommonTemplate{
32 | Title: cfg.Title,
33 | Message: cfg.Message,
34 | Result: result.Result,
35 | Body: body,
36 | })
37 | text, err := template.Execute()
38 | if err != nil {
39 | return result.ExitCode, err
40 | }
41 |
42 | _, _, err = s.client.API.ChatPostMessage(context.Background(), text)
43 | return result.ExitCode, err
44 | }
45 |
--------------------------------------------------------------------------------
/notifier/typetalk/notify_test.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/mercari/tfnotify/terraform"
8 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared"
9 | typetalk "github.com/nulab/go-typetalk/typetalk/v1"
10 | )
11 |
12 | func TestNotify(t *testing.T) {
13 | testCases := []struct {
14 | config Config
15 | body string
16 | exitCode int
17 | ok bool
18 | }{
19 | {
20 | config: Config{
21 | Token: "token",
22 | TopicID: "12345",
23 | Message: "",
24 | Parser: terraform.NewPlanParser(),
25 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
26 | },
27 | body: "Plan: 1 to add",
28 | exitCode: 0,
29 | ok: true,
30 | },
31 | {
32 | config: Config{
33 | Token: "token",
34 | TopicID: "12345",
35 | Message: "",
36 | Parser: terraform.NewPlanParser(),
37 | Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
38 | },
39 | body: "BLUR BLUR BLUR",
40 | exitCode: 1,
41 | ok: false,
42 | },
43 | }
44 | fake := fakeAPI{
45 | FakeChatPostMessage: func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) {
46 | return nil, nil, nil
47 | },
48 | }
49 |
50 | for _, testCase := range testCases {
51 | client, err := NewClient(testCase.config)
52 | if err != nil {
53 | t.Fatal(err)
54 | }
55 | client.API = &fake
56 | exitCode, err := client.Notify.Notify(testCase.body)
57 | if (err == nil) != testCase.ok {
58 | t.Errorf("got error %q", err)
59 | }
60 | if exitCode != testCase.exitCode {
61 | t.Errorf("got %q but want %q", exitCode, testCase.exitCode)
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/notifier/typetalk/typetalk.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "context"
5 |
6 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared"
7 | typetalk "github.com/nulab/go-typetalk/typetalk/v1"
8 | )
9 |
10 | // API is Typetalk API interface
11 | type API interface {
12 | ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error)
13 | }
14 |
15 | // Typetalk represents the attribute information necessary for requesting Typetalk API
16 | type Typetalk struct {
17 | *typetalk.Client
18 | TopicID int
19 | }
20 |
21 | // ChatPostMessage is wrapper for https://godoc.org/github.com/nulab/go-typetalk/typetalk/v1#MessagesService.PostMessage
22 | func (t *Typetalk) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) {
23 | return t.Client.Messages.PostMessage(ctx, t.TopicID, message, nil)
24 | }
25 |
--------------------------------------------------------------------------------
/notifier/typetalk/typetalk_test.go:
--------------------------------------------------------------------------------
1 | package typetalk
2 |
3 | import (
4 | "context"
5 |
6 | typetalkShared "github.com/nulab/go-typetalk/typetalk/shared"
7 | typetalk "github.com/nulab/go-typetalk/typetalk/v1"
8 | )
9 |
10 | type fakeAPI struct {
11 | API
12 | FakeChatPostMessage func(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error)
13 | }
14 |
15 | func (g *fakeAPI) ChatPostMessage(ctx context.Context, message string) (*typetalk.PostedMessageResult, *typetalkShared.Response, error) {
16 | return g.FakeChatPostMessage(ctx, message)
17 | }
18 |
--------------------------------------------------------------------------------
/tee.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "io"
7 |
8 | "github.com/mattn/go-colorable"
9 | )
10 |
11 | func tee(stdin io.Reader, stdout io.Writer) string {
12 | var b1 bytes.Buffer
13 | var b2 bytes.Buffer
14 |
15 | tee := io.TeeReader(stdin, &b1)
16 | s := bufio.NewScanner(tee)
17 | s.Split(bufio.ScanBytes)
18 | for s.Scan() {
19 | stdout.Write(s.Bytes())
20 | }
21 |
22 | uncolorize := colorable.NewNonColorable(&b2)
23 | uncolorize.Write(b1.Bytes())
24 |
25 | return b2.String()
26 | }
27 |
--------------------------------------------------------------------------------
/tee_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "testing"
7 | )
8 |
9 | func TestTee(t *testing.T) {
10 | testCases := []struct {
11 | stdin io.Reader
12 | stdout string
13 | body string
14 | }{
15 | {
16 | // Regular
17 | stdin: bytes.NewBufferString("Plan: 1 to add\n"),
18 | stdout: "Plan: 1 to add\n",
19 | body: "Plan: 1 to add\n",
20 | },
21 | {
22 | // ANSI color codes are included
23 | stdin: bytes.NewBufferString("\033[mPlan: 1 to add\033[m\n"),
24 | stdout: "\033[mPlan: 1 to add\033[m\n",
25 | body: "Plan: 1 to add\n",
26 | },
27 | }
28 |
29 | for _, testCase := range testCases {
30 | stdout := new(bytes.Buffer)
31 | body := tee(testCase.stdin, stdout)
32 | if body != testCase.body {
33 | t.Errorf("got %q but want %q", body, testCase.body)
34 | }
35 | if stdout.String() != testCase.stdout {
36 | t.Errorf("got %q but want %q", stdout.String(), testCase.stdout)
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/terraform/parser.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strings"
7 | )
8 |
9 | // Parser is an interface for parsing terraform execution result
10 | type Parser interface {
11 | Parse(body string) ParseResult
12 | }
13 |
14 | // ParseResult represents the result of parsed terraform execution
15 | type ParseResult struct {
16 | Result string
17 | HasAddOrUpdateOnly bool
18 | HasDestroy bool
19 | HasNoChanges bool
20 | HasPlanError bool
21 | ExitCode int
22 | Error error
23 | }
24 |
25 | // DefaultParser is a parser for terraform commands
26 | type DefaultParser struct {
27 | }
28 |
29 | // FmtParser is a parser for terraform fmt
30 | type FmtParser struct {
31 | Pass *regexp.Regexp
32 | Fail *regexp.Regexp
33 | }
34 |
35 | // ValidateParser is a parser for terraform Validate
36 | type ValidateParser struct {
37 | Pass *regexp.Regexp
38 | Fail *regexp.Regexp
39 | }
40 |
41 | // PlanParser is a parser for terraform plan
42 | type PlanParser struct {
43 | Pass *regexp.Regexp
44 | Fail *regexp.Regexp
45 | HasDestroy *regexp.Regexp
46 | HasNoChanges *regexp.Regexp
47 | }
48 |
49 | // ApplyParser is a parser for terraform apply
50 | type ApplyParser struct {
51 | Pass *regexp.Regexp
52 | Fail *regexp.Regexp
53 | }
54 |
55 | // NewDefaultParser is DefaultParser initializer
56 | func NewDefaultParser() *DefaultParser {
57 | return &DefaultParser{}
58 | }
59 |
60 | // NewFmtParser is FmtParser initialized with its Regexp
61 | func NewFmtParser() *FmtParser {
62 | return &FmtParser{
63 | Fail: regexp.MustCompile(`(?m)^@@[^@]+@@`),
64 | }
65 | }
66 |
67 | // NewValidateParser is ValidateParser initialized with its Regexp
68 | func NewValidateParser() *ValidateParser {
69 | return &ValidateParser{
70 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`),
71 | }
72 | }
73 |
74 | // NewPlanParser is PlanParser initialized with its Regexp
75 | func NewPlanParser() *PlanParser {
76 | return &PlanParser{
77 | Pass: regexp.MustCompile(`(?m)^((Plan: \d|No changes.)|(Changes to Outputs:))`),
78 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`),
79 | // "0 to destroy" should be treated as "no destroy"
80 | HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`),
81 | HasNoChanges: regexp.MustCompile(`(?m)^(No changes. Infrastructure is up-to-date.)`),
82 | }
83 | }
84 |
85 | // NewApplyParser is ApplyParser initialized with its Regexp
86 | func NewApplyParser() *ApplyParser {
87 | return &ApplyParser{
88 | Pass: regexp.MustCompile(`(?m)^(Apply complete!)`),
89 | Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`),
90 | }
91 | }
92 |
93 | // Parse returns ParseResult related with terraform commands
94 | func (p *DefaultParser) Parse(body string) ParseResult {
95 | return ParseResult{
96 | Result: body,
97 | ExitCode: ExitPass,
98 | Error: nil,
99 | }
100 | }
101 |
102 | // Parse returns ParseResult related with terraform fmt
103 | func (p *FmtParser) Parse(body string) ParseResult {
104 | result := ParseResult{}
105 | if p.Fail.MatchString(body) {
106 | result.Result = "There is diff in your .tf file (need to be formatted)"
107 | result.ExitCode = ExitFail
108 | }
109 | return result
110 | }
111 |
112 | // Parse returns ParseResult related with terraform validate
113 | func (p *ValidateParser) Parse(body string) ParseResult {
114 | result := ParseResult{}
115 | if p.Fail.MatchString(body) {
116 | result.Result = "There is a validation error in your Terraform code"
117 | result.ExitCode = ExitFail
118 | }
119 | return result
120 | }
121 |
122 | // Parse returns ParseResult related with terraform plan
123 | func (p *PlanParser) Parse(body string) ParseResult {
124 | var exitCode int
125 | switch {
126 | case p.Pass.MatchString(body):
127 | exitCode = ExitPass
128 | case p.Fail.MatchString(body):
129 | exitCode = ExitFail
130 | default:
131 | return ParseResult{
132 | Result: "",
133 | ExitCode: ExitFail,
134 | Error: fmt.Errorf("cannot parse plan result"),
135 | }
136 | }
137 | lines := strings.Split(body, "\n")
138 | var i int
139 | var result, line string
140 | for i, line = range lines {
141 | if p.Pass.MatchString(line) || p.Fail.MatchString(line) {
142 | break
143 | }
144 | }
145 | var hasPlanError bool
146 | switch {
147 | case p.Pass.MatchString(line):
148 | result = lines[i]
149 | case p.Fail.MatchString(line):
150 | hasPlanError = true
151 | result = strings.Join(trimLastNewline(lines[i:]), "\n")
152 | }
153 |
154 | hasDestroy := p.HasDestroy.MatchString(line)
155 | hasNoChanges := p.HasNoChanges.MatchString(line)
156 | HasAddOrUpdateOnly := !hasNoChanges && !hasDestroy && !hasPlanError
157 |
158 | return ParseResult{
159 | Result: result,
160 | HasAddOrUpdateOnly: HasAddOrUpdateOnly,
161 | HasDestroy: hasDestroy,
162 | HasNoChanges: hasNoChanges,
163 | HasPlanError: hasPlanError,
164 | ExitCode: exitCode,
165 | Error: nil,
166 | }
167 | }
168 |
169 | // Parse returns ParseResult related with terraform apply
170 | func (p *ApplyParser) Parse(body string) ParseResult {
171 | var exitCode int
172 | switch {
173 | case p.Pass.MatchString(body):
174 | exitCode = ExitPass
175 | case p.Fail.MatchString(body):
176 | exitCode = ExitFail
177 | default:
178 | return ParseResult{
179 | Result: "",
180 | ExitCode: ExitFail,
181 | Error: fmt.Errorf("cannot parse apply result"),
182 | }
183 | }
184 | lines := strings.Split(body, "\n")
185 | var i int
186 | var result, line string
187 | for i, line = range lines {
188 | if p.Pass.MatchString(line) || p.Fail.MatchString(line) {
189 | break
190 | }
191 | }
192 | switch {
193 | case p.Pass.MatchString(line):
194 | result = lines[i]
195 | case p.Fail.MatchString(line):
196 | result = strings.Join(trimLastNewline(lines[i:]), "\n")
197 | }
198 | return ParseResult{
199 | Result: result,
200 | ExitCode: exitCode,
201 | Error: nil,
202 | }
203 | }
204 |
205 | func trimLastNewline(s []string) []string {
206 | if len(s) == 0 {
207 | return s
208 | }
209 | last := len(s) - 1
210 | if s[last] == "" {
211 | return s[:last]
212 | }
213 | return s
214 | }
215 |
--------------------------------------------------------------------------------
/terraform/template.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "bytes"
5 | htmltemplate "html/template"
6 | texttemplate "text/template"
7 | )
8 |
9 | const (
10 | // DefaultDefaultTitle is a default title for terraform commands
11 | DefaultDefaultTitle = "## Terraform result"
12 | // DefaultFmtTitle is a default title for terraform fmt
13 | DefaultFmtTitle = "## Fmt result"
14 | // DefaultValidateTitle is a default title for terraform validate
15 | DefaultValidateTitle = "## Validate result"
16 | // DefaultPlanTitle is a default title for terraform plan
17 | DefaultPlanTitle = "## Plan result"
18 | // DefaultDestroyWarningTitle is a default title of destroy warning
19 | DefaultDestroyWarningTitle = "## WARNING: Resource Deletion will happen"
20 | // DefaultApplyTitle is a default title for terraform apply
21 | DefaultApplyTitle = "## Apply result"
22 |
23 | // DefaultDefaultTemplate is a default template for terraform commands
24 | DefaultDefaultTemplate = `
25 | {{ .Title }}
26 |
27 | {{ .Message }}
28 |
29 | {{if .Result}}
30 | {{ .Result }}
31 |
32 | {{end}}
33 |
34 | Details (Click me)
35 |
36 | {{ .Body }}
37 |
38 | `
39 |
40 | // DefaultFmtTemplate is a default template for terraform fmt
41 | DefaultFmtTemplate = `
42 | {{ .Title }}
43 |
44 | {{ .Message }}
45 |
46 | {{if .Result}}
47 | {{ .Result }}
48 |
49 | {{end}}
50 |
51 | Details (Click me)
52 |
53 | {{ .Body }}
54 |
55 | `
56 |
57 | // DefaultValidateTemplate is a default template for terraform validate
58 | DefaultValidateTemplate = `
59 | {{ .Title }}
60 |
61 | {{ .Message }}
62 |
63 | {{if .Result}}
64 | {{ .Result }}
65 |
66 | {{end}}
67 |
68 | Details (Click me)
69 |
70 | {{ .Body }}
71 |
72 | `
73 |
74 | // DefaultPlanTemplate is a default template for terraform plan
75 | DefaultPlanTemplate = `
76 | {{ .Title }}
77 |
78 | {{ .Message }}
79 |
80 | {{if .Result}}
81 | {{ .Result }}
82 |
83 | {{end}}
84 |
85 | Details (Click me)
86 |
87 | {{ .Body }}
88 |
89 | `
90 |
91 | // DefaultDestroyWarningTemplate is a default template for terraform plan
92 | DefaultDestroyWarningTemplate = `
93 | {{ .Title }}
94 |
95 | This plan contains resource delete operation. Please check the plan result very carefully!
96 |
97 | {{if .Result}}
98 | {{ .Result }}
99 |
100 | {{end}}
101 | `
102 |
103 | // DefaultApplyTemplate is a default template for terraform apply
104 | DefaultApplyTemplate = `
105 | {{ .Title }}
106 |
107 | {{ .Message }}
108 |
109 | {{if .Result}}
110 | {{ .Result }}
111 |
112 | {{end}}
113 |
114 | Details (Click me)
115 |
116 | {{ .Body }}
117 |
118 | `
119 | )
120 |
121 | // Template is an template interface for parsed terraform execution result
122 | type Template interface {
123 | Execute() (resp string, err error)
124 | SetValue(template CommonTemplate)
125 | GetValue() CommonTemplate
126 | }
127 |
128 | // CommonTemplate represents template entities
129 | type CommonTemplate struct {
130 | Title string
131 | Message string
132 | Result string
133 | Body string
134 | Link string
135 | UseRawOutput bool
136 | }
137 |
138 | // DefaultTemplate is a default template for terraform commands
139 | type DefaultTemplate struct {
140 | Template string
141 |
142 | CommonTemplate
143 | }
144 |
145 | // FmtTemplate is a default template for terraform fmt
146 | type FmtTemplate struct {
147 | Template string
148 |
149 | CommonTemplate
150 | }
151 |
152 | // ValidateTemplate is a default template for terraform validate
153 | type ValidateTemplate struct {
154 | Template string
155 |
156 | CommonTemplate
157 | }
158 |
159 | // PlanTemplate is a default template for terraform plan
160 | type PlanTemplate struct {
161 | Template string
162 |
163 | CommonTemplate
164 | }
165 |
166 | // DestroyWarningTemplate is a default template for warning of destroy operation in plan
167 | type DestroyWarningTemplate struct {
168 | Template string
169 |
170 | CommonTemplate
171 | }
172 |
173 | // ApplyTemplate is a default template for terraform apply
174 | type ApplyTemplate struct {
175 | Template string
176 |
177 | CommonTemplate
178 | }
179 |
180 | // NewDefaultTemplate is DefaultTemplate initializer
181 | func NewDefaultTemplate(template string) *DefaultTemplate {
182 | if template == "" {
183 | template = DefaultDefaultTemplate
184 | }
185 | return &DefaultTemplate{
186 | Template: template,
187 | }
188 | }
189 |
190 | // NewFmtTemplate is FmtTemplate initializer
191 | func NewFmtTemplate(template string) *FmtTemplate {
192 | if template == "" {
193 | template = DefaultFmtTemplate
194 | }
195 | return &FmtTemplate{
196 | Template: template,
197 | }
198 | }
199 |
200 | // NewValidateTemplate is ValidateTemplate initializer
201 | func NewValidateTemplate(template string) *ValidateTemplate {
202 | if template == "" {
203 | template = DefaultValidateTemplate
204 | }
205 | return &ValidateTemplate{
206 | Template: template,
207 | }
208 | }
209 |
210 | // NewPlanTemplate is PlanTemplate initializer
211 | func NewPlanTemplate(template string) *PlanTemplate {
212 | if template == "" {
213 | template = DefaultPlanTemplate
214 | }
215 | return &PlanTemplate{
216 | Template: template,
217 | }
218 | }
219 |
220 | // NewDestroyWarningTemplate is DestroyWarningTemplate initializer
221 | func NewDestroyWarningTemplate(template string) *DestroyWarningTemplate {
222 | if template == "" {
223 | template = DefaultDestroyWarningTemplate
224 | }
225 | return &DestroyWarningTemplate{
226 | Template: template,
227 | }
228 | }
229 |
230 | // NewApplyTemplate is ApplyTemplate initializer
231 | func NewApplyTemplate(template string) *ApplyTemplate {
232 | if template == "" {
233 | template = DefaultApplyTemplate
234 | }
235 | return &ApplyTemplate{
236 | Template: template,
237 | }
238 | }
239 |
240 | func generateOutput(kind, template string, data map[string]interface{}, useRawOutput bool) (string, error) {
241 | var b bytes.Buffer
242 |
243 | if useRawOutput {
244 | tpl, err := texttemplate.New(kind).Parse(template)
245 | if err != nil {
246 | return "", err
247 | }
248 | if err := tpl.Execute(&b, data); err != nil {
249 | return "", err
250 | }
251 | } else {
252 | tpl, err := htmltemplate.New(kind).Parse(template)
253 | if err != nil {
254 | return "", err
255 | }
256 | if err := tpl.Execute(&b, data); err != nil {
257 | return "", err
258 | }
259 | }
260 |
261 | return b.String(), nil
262 | }
263 |
264 | // Execute binds the execution result of terraform command into template
265 | func (t *DefaultTemplate) Execute() (string, error) {
266 | data := map[string]interface{}{
267 | "Title": t.Title,
268 | "Message": t.Message,
269 | "Result": "",
270 | "Body": t.Result,
271 | "Link": t.Link,
272 | }
273 |
274 | resp, err := generateOutput("default", t.Template, data, t.UseRawOutput)
275 | if err != nil {
276 | return "", err
277 | }
278 |
279 | return resp, nil
280 | }
281 |
282 | // Execute binds the execution result of terraform fmt into template
283 | func (t *FmtTemplate) Execute() (string, error) {
284 | data := map[string]interface{}{
285 | "Title": t.Title,
286 | "Message": t.Message,
287 | "Result": t.Result,
288 | "Body": t.Body,
289 | "Link": t.Link,
290 | }
291 |
292 | resp, err := generateOutput("fmt", t.Template, data, t.UseRawOutput)
293 | if err != nil {
294 | return "", err
295 | }
296 |
297 | return resp, nil
298 | }
299 |
300 | // Execute binds the execution result of terraform validate into template
301 | func (t *ValidateTemplate) Execute() (string, error) {
302 | data := map[string]interface{}{
303 | "Title": t.Title,
304 | "Message": t.Message,
305 | "Result": t.Result,
306 | "Body": t.Body,
307 | "Link": t.Link,
308 | }
309 |
310 | resp, err := generateOutput("validate", t.Template, data, t.UseRawOutput)
311 | if err != nil {
312 | return "", err
313 | }
314 |
315 | return resp, nil
316 | }
317 |
318 | // Execute binds the execution result of terraform plan into template
319 | func (t *PlanTemplate) Execute() (string, error) {
320 | data := map[string]interface{}{
321 | "Title": t.Title,
322 | "Message": t.Message,
323 | "Result": t.Result,
324 | "Body": t.Body,
325 | "Link": t.Link,
326 | }
327 |
328 | resp, err := generateOutput("plan", t.Template, data, t.UseRawOutput)
329 | if err != nil {
330 | return "", err
331 | }
332 |
333 | return resp, nil
334 | }
335 |
336 | // Execute binds the execution result of terraform plan into template
337 | func (t *DestroyWarningTemplate) Execute() (string, error) {
338 | data := map[string]interface{}{
339 | "Title": t.Title,
340 | "Message": t.Message,
341 | "Result": t.Result,
342 | "Body": t.Body,
343 | "Link": t.Link,
344 | }
345 |
346 | resp, err := generateOutput("destroy_warning", t.Template, data, t.UseRawOutput)
347 | if err != nil {
348 | return "", err
349 | }
350 |
351 | return resp, nil
352 | }
353 |
354 | // Execute binds the execution result of terraform apply into template
355 | func (t *ApplyTemplate) Execute() (string, error) {
356 | data := map[string]interface{}{
357 | "Title": t.Title,
358 | "Message": t.Message,
359 | "Result": t.Result,
360 | "Body": t.Body,
361 | "Link": t.Link,
362 | }
363 |
364 | resp, err := generateOutput("apply", t.Template, data, t.UseRawOutput)
365 | if err != nil {
366 | return "", err
367 | }
368 |
369 | return resp, nil
370 | }
371 |
372 | // SetValue sets template entities to CommonTemplate
373 | func (t *DefaultTemplate) SetValue(ct CommonTemplate) {
374 | if ct.Title == "" {
375 | ct.Title = DefaultDefaultTitle
376 | }
377 | t.CommonTemplate = ct
378 | }
379 |
380 | // SetValue sets template entities about terraform fmt to CommonTemplate
381 | func (t *FmtTemplate) SetValue(ct CommonTemplate) {
382 | if ct.Title == "" {
383 | ct.Title = DefaultFmtTitle
384 | }
385 | t.CommonTemplate = ct
386 | }
387 |
388 | // SetValue sets template entities about terraform validate to CommonTemplate
389 | func (t *ValidateTemplate) SetValue(ct CommonTemplate) {
390 | if ct.Title == "" {
391 | ct.Title = DefaultValidateTitle
392 | }
393 | t.CommonTemplate = ct
394 | }
395 |
396 | // SetValue sets template entities about terraform plan to CommonTemplate
397 | func (t *PlanTemplate) SetValue(ct CommonTemplate) {
398 | if ct.Title == "" {
399 | ct.Title = DefaultPlanTitle
400 | }
401 | t.CommonTemplate = ct
402 | }
403 |
404 | // SetValue sets template entities about destroy warning to CommonTemplate
405 | func (t *DestroyWarningTemplate) SetValue(ct CommonTemplate) {
406 | if ct.Title == "" {
407 | ct.Title = DefaultDestroyWarningTitle
408 | }
409 | t.CommonTemplate = ct
410 | }
411 |
412 | // SetValue sets template entities about terraform apply to CommonTemplate
413 | func (t *ApplyTemplate) SetValue(ct CommonTemplate) {
414 | if ct.Title == "" {
415 | ct.Title = DefaultApplyTitle
416 | }
417 | t.CommonTemplate = ct
418 | }
419 |
420 | // GetValue gets template entities
421 | func (t *DefaultTemplate) GetValue() CommonTemplate {
422 | return t.CommonTemplate
423 | }
424 |
425 | // GetValue gets template entities
426 | func (t *FmtTemplate) GetValue() CommonTemplate {
427 | return t.CommonTemplate
428 | }
429 |
430 | // GetValue gets template entities
431 | func (t *ValidateTemplate) GetValue() CommonTemplate {
432 | return t.CommonTemplate
433 | }
434 |
435 | // GetValue gets template entities
436 | func (t *PlanTemplate) GetValue() CommonTemplate {
437 | return t.CommonTemplate
438 | }
439 |
440 | // GetValue gets template entities
441 | func (t *DestroyWarningTemplate) GetValue() CommonTemplate {
442 | return t.CommonTemplate
443 | }
444 |
445 | // GetValue gets template entities
446 | func (t *ApplyTemplate) GetValue() CommonTemplate {
447 | return t.CommonTemplate
448 | }
449 |
--------------------------------------------------------------------------------
/terraform/template_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestDefaultTemplateExecute(t *testing.T) {
9 | testCases := []struct {
10 | template string
11 | value CommonTemplate
12 | resp string
13 | }{
14 | {
15 | template: DefaultDefaultTemplate,
16 | value: CommonTemplate{},
17 | resp: `
18 | ## Terraform result
19 |
20 |
21 |
22 |
23 |
24 | Details (Click me)
25 |
26 |
27 |
28 | `,
29 | },
30 | {
31 | template: DefaultDefaultTemplate,
32 | value: CommonTemplate{
33 | Message: "message",
34 | },
35 | resp: `
36 | ## Terraform result
37 |
38 | message
39 |
40 |
41 |
42 | Details (Click me)
43 |
44 |
45 |
46 | `,
47 | },
48 | {
49 | template: DefaultDefaultTemplate,
50 | value: CommonTemplate{
51 | Title: "a",
52 | Message: "b",
53 | Result: "c",
54 | Body: "d",
55 | },
56 | resp: `
57 | a
58 |
59 | b
60 |
61 |
62 |
63 | Details (Click me)
64 |
65 | c
66 |
67 | `,
68 | },
69 | {
70 | template: "",
71 | value: CommonTemplate{
72 | Title: "a",
73 | Message: "b",
74 | Result: "c",
75 | Body: "d",
76 | },
77 | resp: `
78 | a
79 |
80 | b
81 |
82 |
83 |
84 | Details (Click me)
85 |
86 | c
87 |
88 | `,
89 | },
90 | {
91 | template: "",
92 | value: CommonTemplate{
93 | Title: "a",
94 | Message: "b",
95 | Result: `This is a "result".`,
96 | Body: "d",
97 | },
98 | resp: `
99 | a
100 |
101 | b
102 |
103 |
104 |
105 | Details (Click me)
106 |
107 | This is a "result".
108 |
109 | `,
110 | },
111 | {
112 | template: "",
113 | value: CommonTemplate{
114 | Title: "a",
115 | Message: "b",
116 | Result: `This is a "result".`,
117 | Body: "d",
118 | UseRawOutput: true,
119 | },
120 | resp: `
121 | a
122 |
123 | b
124 |
125 |
126 |
127 | Details (Click me)
128 |
129 | This is a "result".
130 |
131 | `,
132 | },
133 | {
134 | template: "",
135 | value: CommonTemplate{
136 | Title: "a",
137 | Message: "b",
138 | Result: `This is a "result".`,
139 | Body: "d",
140 | UseRawOutput: true,
141 | },
142 | resp: `
143 | a
144 |
145 | b
146 |
147 |
148 |
149 | Details (Click me)
150 |
151 | This is a "result".
152 |
153 | `,
154 | },
155 | {
156 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
157 | value: CommonTemplate{
158 | Title: "a",
159 | Message: "b",
160 | Result: "should be used as body",
161 | Body: "should be empty",
162 | },
163 | resp: `a-b--should be used as body`,
164 | },
165 | }
166 | for _, testCase := range testCases {
167 | template := NewDefaultTemplate(testCase.template)
168 | template.SetValue(testCase.value)
169 | resp, err := template.Execute()
170 | if err != nil {
171 | t.Fatal(err)
172 | }
173 | if resp != testCase.resp {
174 | t.Errorf("got %q but want %q", resp, testCase.resp)
175 | }
176 | }
177 | }
178 |
179 | func TestFmtTemplateExecute(t *testing.T) {
180 | testCases := []struct {
181 | template string
182 | value CommonTemplate
183 | resp string
184 | }{
185 | {
186 | template: DefaultFmtTemplate,
187 | value: CommonTemplate{},
188 | resp: `
189 | ## Fmt result
190 |
191 |
192 |
193 |
194 |
195 | Details (Click me)
196 |
197 |
198 |
199 | `,
200 | },
201 | {
202 | template: DefaultFmtTemplate,
203 | value: CommonTemplate{
204 | Message: "message",
205 | },
206 | resp: `
207 | ## Fmt result
208 |
209 | message
210 |
211 |
212 |
213 | Details (Click me)
214 |
215 |
216 |
217 | `,
218 | },
219 | {
220 | template: DefaultFmtTemplate,
221 | value: CommonTemplate{
222 | Title: "a",
223 | Message: "b",
224 | Result: "c",
225 | Body: "d",
226 | },
227 | resp: `
228 | a
229 |
230 | b
231 |
232 |
233 | c
234 |
235 |
236 |
237 | Details (Click me)
238 |
239 | d
240 |
241 | `,
242 | },
243 | {
244 | template: "",
245 | value: CommonTemplate{
246 | Title: "a",
247 | Message: "b",
248 | Result: "c",
249 | Body: "d",
250 | },
251 | resp: `
252 | a
253 |
254 | b
255 |
256 |
257 | c
258 |
259 |
260 |
261 | Details (Click me)
262 |
263 | d
264 |
265 | `,
266 | },
267 | {
268 | template: "",
269 | value: CommonTemplate{
270 | Title: "a",
271 | Message: "b",
272 | Result: `This is a "result".`,
273 | Body: "d",
274 | },
275 | resp: `
276 | a
277 |
278 | b
279 |
280 |
281 | This is a "result".
282 |
283 |
284 |
285 | Details (Click me)
286 |
287 | d
288 |
289 | `,
290 | },
291 | {
292 | template: "",
293 | value: CommonTemplate{
294 | Title: "a",
295 | Message: "b",
296 | Result: `This is a "result".`,
297 | Body: "d",
298 | UseRawOutput: true,
299 | },
300 | resp: `
301 | a
302 |
303 | b
304 |
305 |
306 | This is a "result".
307 |
308 |
309 |
310 | Details (Click me)
311 |
312 | d
313 |
314 | `,
315 | },
316 | {
317 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
318 | value: CommonTemplate{
319 | Title: "a",
320 | Message: "b",
321 | Result: "c",
322 | Body: "d",
323 | },
324 | resp: `a-b-c-d`,
325 | },
326 | }
327 | for _, testCase := range testCases {
328 | template := NewFmtTemplate(testCase.template)
329 | template.SetValue(testCase.value)
330 | resp, err := template.Execute()
331 | if err != nil {
332 | t.Fatal(err)
333 | }
334 | if resp != testCase.resp {
335 | t.Errorf("got %q but want %q", resp, testCase.resp)
336 | }
337 | }
338 | }
339 |
340 | func TestValidateTemplateExecute(t *testing.T) {
341 | testCases := []struct {
342 | template string
343 | value CommonTemplate
344 | resp string
345 | }{
346 | {
347 | template: DefaultValidateTemplate,
348 | value: CommonTemplate{},
349 | resp: `
350 | ## Validate result
351 |
352 |
353 |
354 |
355 |
356 | Details (Click me)
357 |
358 |
359 |
360 | `,
361 | },
362 | {
363 | template: DefaultValidateTemplate,
364 | value: CommonTemplate{
365 | Message: "message",
366 | },
367 | resp: `
368 | ## Validate result
369 |
370 | message
371 |
372 |
373 |
374 | Details (Click me)
375 |
376 |
377 |
378 | `,
379 | },
380 | {
381 | template: DefaultValidateTemplate,
382 | value: CommonTemplate{
383 | Title: "a",
384 | Message: "b",
385 | Result: "c",
386 | Body: "d",
387 | },
388 | resp: `
389 | a
390 |
391 | b
392 |
393 |
394 | c
395 |
396 |
397 |
398 | Details (Click me)
399 |
400 | d
401 |
402 | `,
403 | },
404 | {
405 | template: "",
406 | value: CommonTemplate{
407 | Title: "a",
408 | Message: "b",
409 | Result: "c",
410 | Body: "d",
411 | },
412 | resp: `
413 | a
414 |
415 | b
416 |
417 |
418 | c
419 |
420 |
421 |
422 | Details (Click me)
423 |
424 | d
425 |
426 | `,
427 | },
428 | {
429 | template: "",
430 | value: CommonTemplate{
431 | Title: "a",
432 | Message: "b",
433 | Result: `This is a "result".`,
434 | Body: "d",
435 | },
436 | resp: `
437 | a
438 |
439 | b
440 |
441 |
442 | This is a "result".
443 |
444 |
445 |
446 | Details (Click me)
447 |
448 | d
449 |
450 | `,
451 | },
452 | {
453 | template: "",
454 | value: CommonTemplate{
455 | Title: "a",
456 | Message: "b",
457 | Result: `This is a "result".`,
458 | Body: "d",
459 | UseRawOutput: true,
460 | },
461 | resp: `
462 | a
463 |
464 | b
465 |
466 |
467 | This is a "result".
468 |
469 |
470 |
471 | Details (Click me)
472 |
473 | d
474 |
475 | `,
476 | },
477 | {
478 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
479 | value: CommonTemplate{
480 | Title: "a",
481 | Message: "b",
482 | Result: "c",
483 | Body: "d",
484 | },
485 | resp: `a-b-c-d`,
486 | },
487 | }
488 | for _, testCase := range testCases {
489 | template := NewValidateTemplate(testCase.template)
490 | template.SetValue(testCase.value)
491 | resp, err := template.Execute()
492 | if err != nil {
493 | t.Fatal(err)
494 | }
495 | if resp != testCase.resp {
496 | t.Errorf("got %q but want %q", resp, testCase.resp)
497 | }
498 | }
499 | }
500 |
501 | func TestPlanTemplateExecute(t *testing.T) {
502 | testCases := []struct {
503 | template string
504 | value CommonTemplate
505 | resp string
506 | }{
507 | {
508 | template: DefaultPlanTemplate,
509 | value: CommonTemplate{},
510 | resp: `
511 | ## Plan result
512 |
513 |
514 |
515 |
516 |
517 | Details (Click me)
518 |
519 |
520 |
521 | `,
522 | },
523 | {
524 | template: DefaultPlanTemplate,
525 | value: CommonTemplate{
526 | Title: "title",
527 | Message: "message",
528 | Result: "result",
529 | Body: "body",
530 | },
531 | resp: `
532 | title
533 |
534 | message
535 |
536 |
537 | result
538 |
539 |
540 |
541 | Details (Click me)
542 |
543 | body
544 |
545 | `,
546 | },
547 | {
548 | template: DefaultPlanTemplate,
549 | value: CommonTemplate{
550 | Title: "title",
551 | Message: "message",
552 | Result: "",
553 | Body: "body",
554 | },
555 | resp: `
556 | title
557 |
558 | message
559 |
560 |
561 |
562 | Details (Click me)
563 |
564 | body
565 |
566 | `,
567 | },
568 | {
569 | template: DefaultPlanTemplate,
570 | value: CommonTemplate{
571 | Title: "title",
572 | Message: "message",
573 | Result: "",
574 | Body: `This is a "body".`,
575 | },
576 | resp: `
577 | title
578 |
579 | message
580 |
581 |
582 |
583 | Details (Click me)
584 |
585 | This is a "body".
586 |
587 | `,
588 | },
589 | {
590 | template: DefaultPlanTemplate,
591 | value: CommonTemplate{
592 | Title: "title",
593 | Message: "message",
594 | Result: "",
595 | Body: `This is a "body".`,
596 | UseRawOutput: true,
597 | },
598 | resp: `
599 | title
600 |
601 | message
602 |
603 |
604 |
605 | Details (Click me)
606 |
607 | This is a "body".
608 |
609 | `,
610 | },
611 | {
612 | template: "",
613 | value: CommonTemplate{
614 | Title: "title",
615 | Message: "message",
616 | Result: "",
617 | Body: "body",
618 | },
619 | resp: `
620 | title
621 |
622 | message
623 |
624 |
625 |
626 | Details (Click me)
627 |
628 | body
629 |
630 | `,
631 | },
632 | {
633 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
634 | value: CommonTemplate{
635 | Title: "a",
636 | Message: "b",
637 | Result: "c",
638 | Body: "d",
639 | },
640 | resp: `a-b-c-d`,
641 | },
642 | }
643 | for _, testCase := range testCases {
644 | template := NewPlanTemplate(testCase.template)
645 | template.SetValue(testCase.value)
646 | resp, err := template.Execute()
647 | if err != nil {
648 | t.Fatal(err)
649 | }
650 | if resp != testCase.resp {
651 | t.Errorf("got %q but want %q", resp, testCase.resp)
652 | }
653 | }
654 | }
655 |
656 | func TestDestroyWarningTemplateExecute(t *testing.T) {
657 | testCases := []struct {
658 | template string
659 | value CommonTemplate
660 | resp string
661 | }{
662 | {
663 | template: DefaultDestroyWarningTemplate,
664 | value: CommonTemplate{},
665 | resp: `
666 | ## WARNING: Resource Deletion will happen
667 |
668 | This plan contains resource delete operation. Please check the plan result very carefully!
669 |
670 |
671 | `,
672 | },
673 | {
674 | template: DefaultDestroyWarningTemplate,
675 | value: CommonTemplate{
676 | Title: "title",
677 | Result: `This is a "result".`,
678 | },
679 | resp: `
680 | title
681 |
682 | This plan contains resource delete operation. Please check the plan result very carefully!
683 |
684 |
685 | This is a "result".
686 |
687 |
688 | `,
689 | },
690 | {
691 | template: DefaultDestroyWarningTemplate,
692 | value: CommonTemplate{
693 | Title: "title",
694 | Result: `This is a "result".`,
695 | UseRawOutput: true,
696 | },
697 | resp: `
698 | title
699 |
700 | This plan contains resource delete operation. Please check the plan result very carefully!
701 |
702 |
703 | This is a "result".
704 |
705 |
706 | `,
707 | },
708 | {
709 | template: DefaultDestroyWarningTemplate,
710 | value: CommonTemplate{
711 | Title: "title",
712 | Result: "",
713 | },
714 | resp: `
715 | title
716 |
717 | This plan contains resource delete operation. Please check the plan result very carefully!
718 |
719 |
720 | `,
721 | },
722 | {
723 | template: "",
724 | value: CommonTemplate{
725 | Title: "title",
726 | Result: "",
727 | },
728 | resp: `
729 | title
730 |
731 | This plan contains resource delete operation. Please check the plan result very carefully!
732 |
733 |
734 | `,
735 | },
736 | {
737 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
738 | value: CommonTemplate{
739 | Title: "a",
740 | Message: "b",
741 | Result: "c",
742 | Body: "d",
743 | },
744 | resp: `a-b-c-d`,
745 | },
746 | }
747 | for _, testCase := range testCases {
748 | template := NewDestroyWarningTemplate(testCase.template)
749 | template.SetValue(testCase.value)
750 | resp, err := template.Execute()
751 | if err != nil {
752 | t.Fatal(err)
753 | }
754 | if resp != testCase.resp {
755 | t.Errorf("got %q but want %q", resp, testCase.resp)
756 | }
757 | }
758 | }
759 |
760 | func TestApplyTemplateExecute(t *testing.T) {
761 | testCases := []struct {
762 | template string
763 | value CommonTemplate
764 | resp string
765 | }{
766 | {
767 | template: DefaultApplyTemplate,
768 | value: CommonTemplate{},
769 | resp: `
770 | ## Apply result
771 |
772 |
773 |
774 |
775 |
776 | Details (Click me)
777 |
778 |
779 |
780 | `,
781 | },
782 | {
783 | template: DefaultApplyTemplate,
784 | value: CommonTemplate{
785 | Title: "title",
786 | Message: "message",
787 | Result: "result",
788 | Body: "body",
789 | },
790 | resp: `
791 | title
792 |
793 | message
794 |
795 |
796 | result
797 |
798 |
799 |
800 | Details (Click me)
801 |
802 | body
803 |
804 | `,
805 | },
806 | {
807 | template: DefaultApplyTemplate,
808 | value: CommonTemplate{
809 | Title: "title",
810 | Message: "message",
811 | Result: "",
812 | Body: "body",
813 | },
814 | resp: `
815 | title
816 |
817 | message
818 |
819 |
820 |
821 | Details (Click me)
822 |
823 | body
824 |
825 | `,
826 | },
827 | {
828 | template: "",
829 | value: CommonTemplate{
830 | Title: "title",
831 | Message: "message",
832 | Result: "",
833 | Body: "body",
834 | },
835 | resp: `
836 | title
837 |
838 | message
839 |
840 |
841 |
842 | Details (Click me)
843 |
844 | body
845 |
846 | `,
847 | },
848 | {
849 | template: "",
850 | value: CommonTemplate{
851 | Title: "title",
852 | Message: "message",
853 | Result: "",
854 | Body: `This is a "body".`,
855 | },
856 | resp: `
857 | title
858 |
859 | message
860 |
861 |
862 |
863 | Details (Click me)
864 |
865 | This is a "body".
866 |
867 | `,
868 | },
869 | {
870 | template: "",
871 | value: CommonTemplate{
872 | Title: "title",
873 | Message: "message",
874 | Result: "",
875 | Body: `This is a "body".`,
876 | UseRawOutput: true,
877 | },
878 | resp: `
879 | title
880 |
881 | message
882 |
883 |
884 |
885 | Details (Click me)
886 |
887 | This is a "body".
888 |
889 | `,
890 | },
891 | {
892 | template: `{{ .Title }}-{{ .Message }}-{{ .Result }}-{{ .Body }}`,
893 | value: CommonTemplate{
894 | Title: "a",
895 | Message: "b",
896 | Result: "c",
897 | Body: "d",
898 | },
899 | resp: `a-b-c-d`,
900 | },
901 | }
902 | for _, testCase := range testCases {
903 | template := NewApplyTemplate(testCase.template)
904 | template.SetValue(testCase.value)
905 | resp, err := template.Execute()
906 | if err != nil {
907 | t.Error(err)
908 | }
909 | if resp != testCase.resp {
910 | t.Errorf("got %q but want %q", resp, testCase.resp)
911 | }
912 | }
913 | }
914 |
915 | func TestGetValue(t *testing.T) {
916 | testCases := []struct {
917 | template Template
918 | expected CommonTemplate
919 | }{
920 | {
921 | template: NewDefaultTemplate(""),
922 | expected: CommonTemplate{},
923 | },
924 | {
925 | template: NewFmtTemplate(""),
926 | expected: CommonTemplate{},
927 | },
928 | {
929 | template: NewPlanTemplate(""),
930 | expected: CommonTemplate{},
931 | },
932 | {
933 | template: NewApplyTemplate(""),
934 | expected: CommonTemplate{},
935 | },
936 | }
937 | for _, testCase := range testCases {
938 | template := testCase.template
939 | value := template.GetValue()
940 | if !reflect.DeepEqual(value, testCase.expected) {
941 | t.Errorf("got %#v but want %#v", value, testCase.expected)
942 | }
943 | }
944 | }
945 |
--------------------------------------------------------------------------------
/terraform/terraform.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | const (
4 | // ExitPass is status code zero
5 | ExitPass int = iota
6 |
7 | // ExitFail is status code non-zero
8 | ExitFail
9 | )
10 |
--------------------------------------------------------------------------------
/terraform/terraform_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
--------------------------------------------------------------------------------