Skip to content

Commit

Permalink
Add ability to update in comments which can be configured differently…
Browse files Browse the repository at this point in the history
… for

each Receiver.

Signed-off-by: Jason Wells <[email protected]>
  • Loading branch information
twotired committed Feb 3, 2024
1 parent 15cff9b commit 4ebe68b
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 3 deletions.
7 changes: 7 additions & 0 deletions examples/jiralert.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"]

Expand All @@ -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
36 changes: 36 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -150,6 +180,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"`

Expand Down Expand Up @@ -315,6 +348,9 @@ 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.UpdateInComment.Valid && c.Defaults.UpdateInComment.Valid {
rc.UpdateInComment = c.Defaults.UpdateInComment
}
}

if len(c.Receivers) == 0 {
Expand Down
11 changes: 9 additions & 2 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}

}
Expand Down Expand Up @@ -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" {
Expand Down
40 changes: 39 additions & 1 deletion pkg/notify/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)
}
Expand Down
108 changes: 108 additions & 0 deletions pkg/notify/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 4ebe68b

Please sign in to comment.