diff --git a/README.md b/README.md index e53603f..6fc228d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,30 @@ terraform: # ... ``` +You can also let tfnotify add a label to PRs whose `terraform plan` output result in no change to the current infrastructure. Currently, this feature is for Github labels only. + +```yaml +--- +# ... +terraform: + # ... + plan: + template: | + {{ .Title }} [CI link]( {{ .Link }} ) + {{ .Message }} + {{if .Result}} +
{{ .Result }}
+
+ {{end}}
+ {{ .Body }}
+
{{ .Body }}
+ when_no_changes:
+ label: "no-changes"
when_destroy:
template: |
## :warning: WARNING: Resource Deletion will happen :warning:
diff --git a/main.go b/main.go
index e031976..0d6ff13 100644
--- a/main.go
+++ b/main.go
@@ -115,6 +115,7 @@ func (t *tfnotify) Run() error {
Template: t.template,
DestroyWarningTemplate: t.destroyWarningTemplate,
WarnDestroy: t.warnDestroy,
+ NoChangesLabel: t.config.Terraform.Plan.WhenNoChanges.Label,
})
if err != nil {
return err
diff --git a/notifier/github/client.go b/notifier/github/client.go
index 2d4586b..cea8a5f 100644
--- a/notifier/github/client.go
+++ b/notifier/github/client.go
@@ -48,6 +48,8 @@ type Config struct {
// DestroyWarningTemplate is used only for additional warning
// the plan result contains destroy operation
DestroyWarningTemplate terraform.Template
+ // NoChangesLabel is a label to add to PRs when terraform output contains no changes
+ NoChangesLabel string
}
// PullRequest represents GitHub Pull Request metadata
diff --git a/notifier/github/github.go b/notifier/github/github.go
index db71ffd..3738af8 100644
--- a/notifier/github/github.go
+++ b/notifier/github/github.go
@@ -11,6 +11,8 @@ type API interface {
IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error)
IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
+ IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
+ IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error)
RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
RepositoriesListCommits(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
RepositoriesGetCommit(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error)
@@ -37,6 +39,16 @@ func (g *GitHub) IssuesListComments(ctx context.Context, number int, opt *github
return g.Client.Issues.ListComments(ctx, g.owner, g.repo, number, opt)
}
+// IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.AddLabelsToIssue
+func (g *GitHub) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
+ return g.Client.Issues.AddLabelsToIssue(ctx, g.owner, g.repo, number, labels)
+}
+
+// IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue
+func (g *GitHub) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) {
+ return g.Client.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, label)
+}
+
// RepositoriesCreateComment is a wrapper of https://godoc.org/github.com/google/go-github/github#RepositoriesService.CreateComment
func (g *GitHub) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
return g.Client.Repositories.CreateComment(ctx, g.owner, g.repo, sha, comment)
diff --git a/notifier/github/github_test.go b/notifier/github/github_test.go
index 982eeb6..e84a78e 100644
--- a/notifier/github/github_test.go
+++ b/notifier/github/github_test.go
@@ -12,6 +12,8 @@ type fakeAPI struct {
FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error)
FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
+ FakeIssuesAddLabels func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
+ FakeIssuesRemoveLabel func(ctx context.Context, number int, label string) (*github.Response, error)
FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
FakeRepositoriesListCommits func(ctx context.Context, opt *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error)
FakeRepositoriesGetCommit func(ctx context.Context, sha string) (*github.RepositoryCommit, *github.Response, error)
@@ -29,6 +31,14 @@ func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *githu
return g.FakeIssuesListComments(ctx, number, opt)
}
+func (g *fakeAPI) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
+ return g.FakeIssuesAddLabels(ctx, number, labels)
+}
+
+func (g *fakeAPI) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) {
+ return g.FakeIssuesRemoveLabel(ctx, number, label)
+}
+
func (g *fakeAPI) RepositoriesCreateComment(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
return g.FakeRepositoriesCreateComment(ctx, sha, comment)
}
@@ -66,6 +76,12 @@ func newFakeAPI() fakeAPI {
}
return comments, nil, nil
},
+ FakeIssuesAddLabels: func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
+ return nil, nil, nil
+ },
+ FakeIssuesRemoveLabel: func(ctx context.Context, number int, label string) (*github.Response, error) {
+ return nil, nil
+ },
FakeRepositoriesCreateComment: func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error) {
return &github.RepositoryComment{
ID: github.Int64(28427394),
diff --git a/notifier/github/notify.go b/notifier/github/notify.go
index 8cd74cf..e631b56 100644
--- a/notifier/github/notify.go
+++ b/notifier/github/notify.go
@@ -1,7 +1,9 @@
package github
import (
+ "context"
"github.com/mercari/tfnotify/terraform"
+ "net/http"
)
// NotifyService handles communication with the notification related
@@ -30,6 +32,29 @@ func (g *NotifyService) Notify(body string) (exit int, err error) {
return result.ExitCode, err
}
}
+ if cfg.PR.IsNumber() && cfg.NoChangesLabel != "" {
+ // Always attempt to remove the label first so that an IssueLabeled event is created
+ resp, err := g.client.API.IssuesRemoveLabel(
+ context.Background(),
+ cfg.PR.Number,
+ cfg.NoChangesLabel,
+ )
+ // Ignore 404 errors, which are from the PR not having the label
+ if err != nil && resp.StatusCode != http.StatusNotFound {
+ return result.ExitCode, err
+ }
+
+ if result.HasNoChanges {
+ _, _, err = g.client.API.IssuesAddLabels(
+ context.Background(),
+ cfg.PR.Number,
+ []string{cfg.NoChangesLabel},
+ )
+ if err != nil {
+ return result.ExitCode, err
+ }
+ }
+ }
}
template.SetValue(terraform.CommonTemplate{
diff --git a/notifier/github/notify_test.go b/notifier/github/notify_test.go
index 9394991..2cc317b 100644
--- a/notifier/github/notify_test.go
+++ b/notifier/github/notify_test.go
@@ -124,6 +124,26 @@ func TestNotifyNotify(t *testing.T) {
ok: true,
exitCode: 0,
},
+ {
+ // valid with no changes
+ // TODO(drlau): check that the label was actually added
+ config: Config{
+ Token: "token",
+ Owner: "owner",
+ Repo: "repo",
+ PR: PullRequest{
+ Revision: "",
+ Number: 1,
+ Message: "message",
+ },
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ NoChangesLabel: "terraform/no-changes",
+ },
+ body: "No changes. Infrastructure is up-to-date.",
+ ok: true,
+ exitCode: 0,
+ },
{
// valid, contains destroy, but not to notify
config: Config{
diff --git a/terraform/parser.go b/terraform/parser.go
index 1c6926a..b7cabea 100644
--- a/terraform/parser.go
+++ b/terraform/parser.go
@@ -13,10 +13,11 @@ type Parser interface {
// ParseResult represents the result of parsed terraform execution
type ParseResult struct {
- Result string
- HasDestroy bool
- ExitCode int
- Error error
+ Result string
+ HasDestroy bool
+ HasNoChanges bool
+ ExitCode int
+ Error error
}
// DefaultParser is a parser for terraform commands
@@ -31,9 +32,10 @@ type FmtParser struct {
// PlanParser is a parser for terraform plan
type PlanParser struct {
- Pass *regexp.Regexp
- Fail *regexp.Regexp
- HasDestroy *regexp.Regexp
+ Pass *regexp.Regexp
+ Fail *regexp.Regexp
+ HasDestroy *regexp.Regexp
+ HasNoChanges *regexp.Regexp
}
// ApplyParser is a parser for terraform apply
@@ -60,7 +62,8 @@ func NewPlanParser() *PlanParser {
Pass: regexp.MustCompile(`(?m)^(Plan: \d|No changes.)`),
Fail: regexp.MustCompile(`(?m)^(Error: )`),
// "0 to destroy" should be treated as "no destroy"
- HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`),
+ HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`),
+ HasNoChanges: regexp.MustCompile(`(?m)^(No changes. Infrastructure is up-to-date.)`),
}
}
@@ -122,12 +125,14 @@ func (p *PlanParser) Parse(body string) ParseResult {
}
hasDestroy := p.HasDestroy.MatchString(line)
+ hasNoChanges := p.HasNoChanges.MatchString(line)
return ParseResult{
- Result: result,
- HasDestroy: hasDestroy,
- ExitCode: exitCode,
- Error: nil,
+ Result: result,
+ HasDestroy: hasDestroy,
+ HasNoChanges: hasNoChanges,
+ ExitCode: exitCode,
+ Error: nil,
}
}
diff --git a/terraform/parser_test.go b/terraform/parser_test.go
index c7f964e..5988b81 100644
--- a/terraform/parser_test.go
+++ b/terraform/parser_test.go
@@ -307,20 +307,22 @@ func TestPlanParserParse(t *testing.T) {
name: "plan ok pattern",
body: planSuccessResult,
result: ParseResult{
- Result: "Plan: 1 to add, 0 to change, 0 to destroy.",
- HasDestroy: false,
- ExitCode: 0,
- Error: nil,
+ Result: "Plan: 1 to add, 0 to change, 0 to destroy.",
+ HasDestroy: false,
+ HasNoChanges: false,
+ ExitCode: 0,
+ Error: nil,
},
},
{
name: "no stdin",
body: "",
result: ParseResult{
- Result: "",
- HasDestroy: false,
- ExitCode: 1,
- Error: errors.New("cannot parse plan result"),
+ Result: "",
+ HasDestroy: false,
+ HasNoChanges: false,
+ ExitCode: 1,
+ Error: errors.New("cannot parse plan result"),
},
},
{
@@ -341,20 +343,22 @@ func TestPlanParserParse(t *testing.T) {
name: "plan no changes",
body: planNoChanges,
result: ParseResult{
- Result: "No changes. Infrastructure is up-to-date.",
- HasDestroy: false,
- ExitCode: 0,
- Error: nil,
+ Result: "No changes. Infrastructure is up-to-date.",
+ HasDestroy: false,
+ HasNoChanges: true,
+ ExitCode: 0,
+ Error: nil,
},
},
{
name: "plan has destroy",
body: planHasDestroy,
result: ParseResult{
- Result: "Plan: 0 to add, 0 to change, 1 to destroy.",
- HasDestroy: true,
- ExitCode: 0,
- Error: nil,
+ Result: "Plan: 0 to add, 0 to change, 1 to destroy.",
+ HasDestroy: true,
+ HasNoChanges: false,
+ ExitCode: 0,
+ Error: nil,
},
},
}