diff --git a/examples/jiralert.yml b/examples/jiralert.yml index da9b0b8..c2d0bcc 100644 --- a/examples/jiralert.yml +++ b/examples/jiralert.yml @@ -25,6 +25,8 @@ defaults: reopen_duration: 0h # Static label that will be added to the JIRA ticket alongisde the JIRALERT{...} or ALERT{...} label static_labels: ["custom"] + # Include ticket update as comment. Optional (default: false). + update_in_comment: false # Receiver definitions. At least one must be defined. receivers: @@ -34,6 +36,8 @@ receivers: project: AB # Copy all Prometheus labels into separate JIRA labels. Optional (default: false). add_group_labels: false + # Include ticket update as comment too. Optional (default: false). + update_in_comment: false # Will be merged with the static_labels from the default map static_labels: ["anotherLabel"] @@ -56,7 +60,10 @@ receivers: # # Automatically resolve jira issues when alert is resolved. Optional. If declared, ensure state is not an empty string. auto_resolve: - state: 'Done' + state: 'Done' + # Include ticket update as comment too. Optional (default: false). + update_in_comment: true + # File containing template definitions. Required. template: jiralert.tmpl diff --git a/pkg/config/config.go b/pkg/config/config.go index 71ba2b8..656fad4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,36 @@ func (s *Secret) UnmarshalYAML(unmarshal func(interface{}) error) error { return unmarshal((*plain)(s)) } +// NullBool will be used for boolean configuration values that should not +// default to false when undefined and the default setting is true. +type NullBool struct { + Bool bool + Valid bool // Valid is true if Bool is defined +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (nb NullBool) MarshalYAML() (interface{}, error) { + if nb.Valid { + return nb.Bool, nil + } + return false, nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface +func (nb *NullBool) UnmarshalYAML(unmarshal func(interface{}) error) error { + var b bool + + err := unmarshal(&b) + if err != nil { + return err + } + + nb.Bool = b + nb.Valid = true + + return nil +} + // Load parses the YAML input into a Config. func Load(s string) (*Config, error) { cfg := &Config{} @@ -149,6 +179,9 @@ type ReceiverConfig struct { // Label copy settings AddGroupLabels bool `yaml:"add_group_labels" json:"add_group_labels"` + // Flag to enable updates in comments. + UpdateInComment NullBool `yaml:"update_in_comment" json:"update_in_comment"` + // Flag to auto-resolve opened issue when the alert is resolved. AutoResolve *AutoResolve `yaml:"auto_resolve" json:"auto_resolve"` @@ -311,6 +344,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { if len(c.Defaults.StaticLabels) > 0 { rc.StaticLabels = append(rc.StaticLabels, c.Defaults.StaticLabels...) } + if !rc.UpdateInComment.Valid && c.Defaults.UpdateInComment.Valid { + rc.UpdateInComment = c.Defaults.UpdateInComment + } } if len(c.Receivers) == 0 { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d851f72..11448cb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -46,6 +46,7 @@ defaults: # Amount of time after being closed that an issue should be reopened, after which, a new issue is created. # Optional (default: always reopen) reopen_duration: 0h + update_in_comment: true static_labels: ["defaultlabel"] # Receiver definitions. At least one must be defined. @@ -56,6 +57,7 @@ receivers: project: AB # Copy all Prometheus labels into separate JIRA labels. Optional (default: false). add_group_labels: false + update_in_comment: false static_labels: ["somelabel"] - name: 'jira-xy' @@ -128,6 +130,7 @@ type receiverTestConfig struct { Description string `yaml:"description,omitempty"` WontFixResolution string `yaml:"wont_fix_resolution,omitempty"` AddGroupLabels bool `yaml:"add_group_labels,omitempty"` + UpdateInComment NullBool `yaml:"update_in_comment,omitempty"` StaticLabels []string `yaml:"static_labels" json:"static_labels"` AutoResolve *AutoResolve `yaml:"auto_resolve" json:"auto_resolve"` @@ -330,10 +333,12 @@ func TestReceiverOverrides(t *testing.T) { {"Description", "A nice description", "A nice description"}, {"WontFixResolution", "Won't Fix", "Won't Fix"}, {"AddGroupLabels", false, false}, + {"UpdateInComment", NullBool{Bool: false, Valid: true}, NullBool{Bool: false, Valid: true}}, + {"UpdateInComment", NullBool{Bool: true, Valid: true}, NullBool{Bool: true, Valid: true}}, {"AutoResolve", &AutoResolve{State: "Done"}, &autoResolve}, {"StaticLabels", []string{"somelabel"}, []string{"somelabel"}}, } { - optionalFields := []string{"Priority", "Description", "WontFixResolution", "AddGroupLabels", "AutoResolve", "StaticLabels"} + optionalFields := []string{"Priority", "Description", "WontFixResolution", "AddGroupLabels", "UpdateInComment", "AutoResolve", "StaticLabels"} defaultsConfig := newReceiverTestConfig(mandatoryReceiverFields(), optionalFields) receiverConfig := newReceiverTestConfig([]string{"Name"}, optionalFields) @@ -354,7 +359,7 @@ func TestReceiverOverrides(t *testing.T) { receiver := cfg.Receivers[0] configValue := reflect.ValueOf(receiver).Elem().FieldByName(test.overrideField).Interface() - require.Equal(t, configValue, test.expectedValue) + require.Equal(t, test.expectedValue, configValue) } } @@ -385,6 +390,8 @@ func newReceiverTestConfig(mandatory []string, optional []string) *receiverTestC var value reflect.Value if name == "AddGroupLabels" { value = reflect.ValueOf(true) + } else if name == "UpdateInComment" { + value = reflect.ValueOf(NullBool{Bool: false, Valid: false}) } else if name == "AutoResolve" { value = reflect.ValueOf(&AutoResolve{State: "Done"}) } else if name == "StaticLabels" { diff --git a/pkg/notify/notify.go b/pkg/notify/notify.go index 3f07a45..47288f5 100644 --- a/pkg/notify/notify.go +++ b/pkg/notify/notify.go @@ -40,6 +40,7 @@ type jiraIssueService interface { Create(issue *jira.Issue) (*jira.Issue, *jira.Response, error) UpdateWithOptions(issue *jira.Issue, opts *jira.UpdateQueryOptions) (*jira.Issue, *jira.Response, error) + AddComment(issueID string, comment *jira.Comment) (*jira.Comment, *jira.Response, error) DoTransition(ticketID, transitionID string) (*jira.Response, error) } @@ -103,6 +104,28 @@ func (r *Receiver) Notify(data *alertmanager.Data, hashJiraLabel bool, updateSum } } + if r.conf.UpdateInComment.Valid && r.conf.UpdateInComment.Bool { + numComments := 0 + if issue.Fields.Comments != nil { + numComments = len(issue.Fields.Comments.Comments) + } + if numComments > 0 && issue.Fields.Comments.Comments[(numComments-1)].Body == issueDesc { + // if the new comment is identical to the most recent comment, + // this is probably due to the prometheus repeat_interval and should not be added. + level.Debug(r.logger).Log("msg", "not adding new comment identical to last", "key", issue.Key) + } else if numComments == 0 && issue.Fields.Description == issueDesc { + // if the first comment is identical to the description, + // this is probably due to the prometheus repeat_interval and should not be added. + level.Debug(r.logger).Log("msg", "not adding comment identical to description", "key", issue.Key) + } else { + retry, err := r.addComment(issue.Key, issueDesc) + if err != nil { + return retry, err + } + } + } + + // update description after possibly adding a comment so that it's possible to detect redundant first comment if updateDescription { if issue.Fields.Description != issueDesc { retry, err := r.updateDescription(issue.Key, issueDesc) @@ -291,7 +314,7 @@ func toGroupTicketLabel(groupLabels alertmanager.KV, hashJiraLabel bool) string func (r *Receiver) search(project, issueLabel string) (*jira.Issue, bool, error) { query := fmt.Sprintf("project=\"%s\" and labels=%q order by resolutiondate desc", project, issueLabel) options := &jira.SearchOptions{ - Fields: []string{"summary", "status", "resolution", "resolutiondate"}, + Fields: []string{"summary", "status", "resolution", "resolutiondate", "description", "comment"}, MaxResults: 2, } @@ -370,6 +393,21 @@ func (r *Receiver) updateDescription(issueKey string, description string) (bool, return false, nil } +func (r *Receiver) addComment(issueKey string, content string) (bool, error) { + level.Debug(r.logger).Log("msg", "adding comment to existing issue", "key", issueKey, "content", content) + + commentDetails := &jira.Comment{ + Body: content, + } + + comment, resp, err := r.client.AddComment(issueKey, commentDetails) + if err != nil { + return handleJiraErrResponse("Issue.AddComment", resp, err, r.logger) + } + level.Debug(r.logger).Log("msg", "added comment to issue", "key", issueKey, "id", comment.ID) + return false, nil +} + func (r *Receiver) reopen(issueKey string) (bool, error) { return r.doTransition(issueKey, r.conf.ReopenState) } diff --git a/pkg/notify/notify_test.go b/pkg/notify/notify_test.go index 3390f56..044b704 100644 --- a/pkg/notify/notify_test.go +++ b/pkg/notify/notify_test.go @@ -60,6 +60,8 @@ func (f *fakeJira) Search(jql string, options *jira.SearchOptions) ([]jira.Issue switch field { case "summary": issue.Fields.Summary = f.issuesByKey[key].Fields.Summary + case "description": + issue.Fields.Description = f.issuesByKey[key].Fields.Description case "resolution": if f.issuesByKey[key].Fields.Resolution == nil { continue @@ -134,6 +136,12 @@ func (f *fakeJira) UpdateWithOptions(old *jira.Issue, _ *jira.UpdateQueryOptions return issue, nil, nil } +func (f *fakeJira) AddComment(issueID string, comment *jira.Comment) (*jira.Comment, *jira.Response, error) { + f.issuesByKey[issueID].Fields.Comments.Comments = append(f.issuesByKey[issueID].Fields.Comments.Comments, comment) + + return comment, nil, nil +} + func (f *fakeJira) DoTransition(ticketID, transitionID string) (*jira.Response, error) { issue, ok := f.issuesByKey[ticketID] if !ok { @@ -175,6 +183,19 @@ func testReceiverConfig2() *config.ReceiverConfig { } } +func testReceiverConfigAddComments() *config.ReceiverConfig { + reopen := config.Duration(1 * time.Hour) + return &config.ReceiverConfig{ + Project: "abc", + Summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}`, + ReopenDuration: &reopen, + ReopenState: "reopened", + Description: `{{ .Alerts.Firing | len }}`, + WontFixResolution: "won't-fix", + UpdateInComment: config.NullBool{Valid: true, Bool: true}, + } +} + func testReceiverConfigAutoResolve() *config.ReceiverConfig { reopen := config.Duration(1 * time.Hour) autoResolve := config.AutoResolve{State: "Done"} @@ -577,6 +598,93 @@ func TestNotify_JIRAInteraction(t *testing.T) { }, }, }, + { + name: "existing ticket, new instance firing, add comment", + inputConfig: testReceiverConfigAddComments(), + initJira: func(t *testing.T) *fakeJira { + f := newTestFakeJira() + _, _, err := f.Create(&jira.Issue{ + ID: "1", + Key: "1", + Fields: &jira.IssueFields{ + Project: jira.Project{Key: testReceiverConfigAddComments().Project}, + Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"}, + Summary: "[FIRING:2] b d ", + Description: "1", + Comments: &jira.Comments{Comments: []*jira.Comment{}}, + }, + }) + require.NoError(t, err) + return f + }, + inputAlert: &alertmanager.Data{ + Alerts: alertmanager.Alerts{ + {Status: alertmanager.AlertFiring}, + {Status: alertmanager.AlertFiring}, + }, + Status: alertmanager.AlertFiring, + GroupLabels: alertmanager.KV{"a": "b", "c": "d"}, + }, + expectedJiraIssues: map[string]*jira.Issue{ + "1": { + ID: "1", + Key: "1", + Fields: &jira.IssueFields{ + Project: jira.Project{Key: testReceiverConfigAddComments().Project}, + Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"}, + Status: &jira.Status{ + StatusCategory: jira.StatusCategory{Key: "NotDone"}, + }, + Summary: "[FIRING:2] b d ", + Description: "2", + Comments: &jira.Comments{Comments: []*jira.Comment{{Body: "2"}}}, + }, + }, + }, + }, + { + name: "existing ticket, same instance firing, no comment added", + inputConfig: testReceiverConfigAddComments(), + initJira: func(t *testing.T) *fakeJira { + f := newTestFakeJira() + _, _, err := f.Create(&jira.Issue{ + ID: "1", + Key: "1", + Fields: &jira.IssueFields{ + Project: jira.Project{Key: testReceiverConfigAddComments().Project}, + Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"}, + Summary: "[FIRING:1] b d ", + Description: "1", + Comments: &jira.Comments{Comments: []*jira.Comment{}}, + }, + }) + require.NoError(t, err) + return f + }, + inputAlert: &alertmanager.Data{ + Alerts: alertmanager.Alerts{ + {Status: alertmanager.AlertFiring}, + }, + Status: alertmanager.AlertFiring, + GroupLabels: alertmanager.KV{"a": "b", "c": "d"}, + }, + expectedJiraIssues: map[string]*jira.Issue{ + "1": { + ID: "1", + Key: "1", + Fields: &jira.IssueFields{ + Project: jira.Project{Key: testReceiverConfigAddComments().Project}, + Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"}, + Status: &jira.Status{ + StatusCategory: jira.StatusCategory{Key: "NotDone"}, + }, + Summary: "[FIRING:1] b d ", + Description: "1", + Comments: &jira.Comments{Comments: []*jira.Comment{}}, + }, + }, + }, + }, } { if ok := t.Run(tcase.name, func(t *testing.T) { fakeJira := tcase.initJira(t)