diff --git a/examples/jiralert.yml b/examples/jiralert.yml index 1a35b7a..46263c3 100644 --- a/examples/jiralert.yml +++ b/examples/jiralert.yml @@ -31,6 +31,8 @@ defaults: # (first found is used in case of duplicates) that old project's issue will be used for # alert updates instead of creating on in the main project. other_projects: ["OTHER1", "OTHER2"] + # Include ticket update as comment. Optional (default: false). + update_in_comment: false # Receiver definitions. At least one must be defined. receivers: @@ -40,6 +42,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"] @@ -63,6 +67,9 @@ receivers: # Automatically resolve jira issues when alert is resolved. Optional. If declared, ensure state is not an empty string. auto_resolve: 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 1813ec6..7fc63c4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -148,7 +148,10 @@ type ReceiverConfig struct { StaticLabels []string `yaml:"static_labels" json:"static_labels"` // Label copy settings - AddGroupLabels bool `yaml:"add_group_labels" json:"add_group_labels"` + AddGroupLabels *bool `yaml:"add_group_labels" json:"add_group_labels"` + + // Flag to enable updates in comments. + UpdateInComment *bool `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"` @@ -315,6 +318,12 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { if len(c.Defaults.OtherProjects) > 0 { rc.OtherProjects = append(rc.OtherProjects, c.Defaults.OtherProjects...) } + if rc.AddGroupLabels == nil { + rc.AddGroupLabels = c.Defaults.AddGroupLabels + } + if rc.UpdateInComment == nil { + 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..e0bd0c9 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' @@ -127,7 +129,8 @@ type receiverTestConfig struct { Priority string `yaml:"priority,omitempty"` Description string `yaml:"description,omitempty"` WontFixResolution string `yaml:"wont_fix_resolution,omitempty"` - AddGroupLabels bool `yaml:"add_group_labels,omitempty"` + AddGroupLabels *bool `yaml:"add_group_labels,omitempty"` + UpdateInComment *bool `yaml:"update_in_comment,omitempty"` StaticLabels []string `yaml:"static_labels" json:"static_labels"` AutoResolve *AutoResolve `yaml:"auto_resolve" json:"auto_resolve"` @@ -313,6 +316,10 @@ func TestReceiverOverrides(t *testing.T) { fifteenHoursToDuration, err := ParseDuration("15h") autoResolve := AutoResolve{State: "Done"} require.NoError(t, err) + addGroupLabelsTrueVal := true + addGroupLabelsFalseVal := false + updateInCommentTrueVal := true + updateInCommentFalseVal := false // We'll override one key at a time and check the value in the receiver. for _, test := range []struct { @@ -329,11 +336,14 @@ func TestReceiverOverrides(t *testing.T) { {"Priority", "Critical", "Critical"}, {"Description", "A nice description", "A nice description"}, {"WontFixResolution", "Won't Fix", "Won't Fix"}, - {"AddGroupLabels", false, false}, + {"AddGroupLabels", &addGroupLabelsFalseVal, &addGroupLabelsFalseVal}, + {"AddGroupLabels", &addGroupLabelsTrueVal, &addGroupLabelsTrueVal}, + {"UpdateInComment", &updateInCommentFalseVal, &updateInCommentFalseVal}, + {"UpdateInComment", &updateInCommentTrueVal, &updateInCommentTrueVal}, {"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 +364,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) } } @@ -367,6 +377,8 @@ func TestReceiverOverrides(t *testing.T) { // Creates a receiverTestConfig struct with default values. func newReceiverTestConfig(mandatory []string, optional []string) *receiverTestConfig { r := receiverTestConfig{} + addGroupLabelsDefaultVal := true + updateInCommentDefaultVal := true for _, name := range mandatory { var value reflect.Value @@ -384,7 +396,9 @@ func newReceiverTestConfig(mandatory []string, optional []string) *receiverTestC for _, name := range optional { var value reflect.Value if name == "AddGroupLabels" { - value = reflect.ValueOf(true) + value = reflect.ValueOf(&addGroupLabelsDefaultVal) + } else if name == "UpdateInComment" { + value = reflect.ValueOf(&updateInCommentDefaultVal) } 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 d755b17..e4b96a2 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 != nil && *r.conf.UpdateInComment { + 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 if enabled. This has to be done after comment adding logic which needs to handle redundant commentary vs description case. if updateDescription { if issue.Fields.Description != issueDesc { retry, err := r.updateDescription(issue.Key, issueDesc) @@ -192,7 +215,7 @@ func (r *Receiver) Notify(data *alertmanager.Data, hashJiraLabel bool, updateSum } } - if r.conf.AddGroupLabels { + if r.conf.AddGroupLabels != nil && *r.conf.AddGroupLabels { for k, v := range data.GroupLabels { issue.Fields.Labels = append(issue.Fields.Labels, fmt.Sprintf("%s=%.200q", k, v)) } @@ -293,7 +316,7 @@ func (r *Receiver) search(projects []string, issueLabel string) (*jira.Issue, bo projectList := "'" + strings.Join(projects, "', '") + "'" query := fmt.Sprintf("project in(%s) and labels=%q order by resolutiondate desc", projectList, issueLabel) options := &jira.SearchOptions{ - Fields: []string{"summary", "status", "resolution", "resolutiondate"}, + Fields: []string{"summary", "status", "resolution", "resolutiondate", "description", "comment"}, MaxResults: 2, } @@ -380,6 +403,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 15ea758..49d686f 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,20 @@ func testReceiverConfig2() *config.ReceiverConfig { } } +func testReceiverConfigAddComments() *config.ReceiverConfig { + reopen := config.Duration(1 * time.Hour) + updateInCommentValue := true + 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: &updateInCommentValue, + } +} + func testReceiverConfigAutoResolve() *config.ReceiverConfig { reopen := config.Duration(1 * time.Hour) autoResolve := config.AutoResolve{State: "Done"} @@ -577,6 +599,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)