Skip to content

Commit a0f0e80

Browse files
Add support to auto resolve jira issue when alert is resolved. (#117)
* Add initial code to support auto resolve feature Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * use constant Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Resolve alert using transition Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Update configuration tests Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Update example configuration Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Update README Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * move valid config check to receivers section Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Use different configuration option for simplicity Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * remove old config options Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * add docstring and update readme Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * reuse AutoResolve struct in tests Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * add docstring for AutoResolve Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * Apply suggestions from code review Co-authored-by: Bartlomiej Plotka <bwplotka@gmail.com> Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * keep the reasoning why defaults is not auto-resolve Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * fix lint errors Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * keep newline in configuration yaml Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> * fix readme Signed-off-by: Bhushan Thakur <bhthakur@redhat.com> Co-authored-by: Bartlomiej Plotka <bwplotka@gmail.com>
1 parent 0c7c40f commit a0f0e80

File tree

7 files changed

+168
-21
lines changed

7 files changed

+168
-21
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
## Overview
99

10-
JIRAlert implements Alertmanager's webhook HTTP API and connects to one or more JIRA instances to create highly configurable JIRA issues. One issue is created per distinct group key — as defined by the [`group_by`](https://prometheus.io/docs/alerting/configuration/#<route>) parameter of Alertmanager's `route` configuration section — but not closed when the alert is resolved. The expectation is that a human will look at the issue, take any necessary action, then close it. If no human interaction is necessary then it should probably not alert in the first place.
10+
JIRAlert implements Alertmanager's webhook HTTP API and connects to one or more JIRA instances to create highly configurable JIRA issues. One issue is created per distinct group key — as defined by the [`group_by`](https://prometheus.io/docs/alerting/configuration/#<route>) parameter of Alertmanager's `route` configuration section — but not closed when the alert is resolved. The expectation is that a human will look at the issue, take any necessary action, then close it. If no human interaction is necessary then it should probably not alert in the first place. This behavior however can be modified by setting `auto_resolve` section, which will resolve the jira issue with required state.
1111

1212
If a corresponding JIRA issue already exists but is resolved, it is reopened. A JIRA transition must exist between the resolved state and the reopened state — as defined by `reopen_state` — or reopening will fail. Optionally a "won't fix" resolution — defined by `wont_fix_resolution` — may be defined: a JIRA issue with this resolution will not be reopened by JIRAlert.
1313

examples/jiralert.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ receivers:
4747
customfield_10002: {"value": "red"}
4848
# MultiSelect
4949
customfield_10003: [{"value": "red"}, {"value": "blue"}, {"value": "green"}]
50+
#
51+
# Automatically resolve jira issues when alert is resolved. Optional. If declared, ensure state is not an empty string.
52+
auto_resolve:
53+
state: 'Done'
5054

5155
# File containing template definitions. Required.
5256
template: jiralert.tmpl

pkg/alertmanager/alertmanager.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const (
2727

2828
// AlertFiring is the status value for a firing alert.
2929
AlertFiring = "firing"
30+
31+
// AlertResolved is the status value for a resolved alert.
32+
AlertResolved = "resolved"
3033
)
3134

3235
// Pair is a key/value string pair.

pkg/config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ func resolveFilepaths(baseDir string, cfg *Config, logger log.Logger) {
8888
cfg.Template = join(cfg.Template)
8989
}
9090

91+
// AutoResolve is the struct used for defining jira resolution state when alert is resolved.
92+
type AutoResolve struct {
93+
State string `yaml:"state" json:"state"`
94+
}
95+
9196
// ReceiverConfig is the configuration for one receiver. It has a unique name and includes API access fields (url and
9297
// auth) and issue fields (required -- e.g. project, issue type -- and optional -- e.g. priority).
9398
type ReceiverConfig struct {
@@ -116,6 +121,9 @@ type ReceiverConfig struct {
116121
// Label copy settings
117122
AddGroupLabels bool `yaml:"add_group_labels" json:"add_group_labels"`
118123

124+
// Flag to auto-resolve opened issue when the alert is resolved.
125+
AutoResolve *AutoResolve `yaml:"auto_resolve" json:"auto_resolve"`
126+
119127
// Catches all undefined fields and must be empty after parsing.
120128
XXX map[string]interface{} `yaml:",inline" json:"-"`
121129
}
@@ -171,6 +179,12 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
171179
return fmt.Errorf("bad auth config in defaults section: user/password and PAT authentication are mutually exclusive")
172180
}
173181

182+
if c.Defaults.AutoResolve != nil {
183+
if c.Defaults.AutoResolve.State == "" {
184+
return fmt.Errorf("bad config in defaults section: state cannot be empty")
185+
}
186+
}
187+
174188
for _, rc := range c.Receivers {
175189
if rc.Name == "" {
176190
return fmt.Errorf("missing name for receiver %+v", rc)
@@ -251,6 +265,14 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
251265
if rc.WontFixResolution == "" && c.Defaults.WontFixResolution != "" {
252266
rc.WontFixResolution = c.Defaults.WontFixResolution
253267
}
268+
if rc.AutoResolve != nil {
269+
if rc.AutoResolve.State == "" {
270+
return fmt.Errorf("bad config in receiver %q, 'auto_resolve' was defined with empty 'state' field", rc.Name)
271+
}
272+
}
273+
if rc.AutoResolve == nil && c.Defaults.AutoResolve != nil {
274+
rc.AutoResolve = c.Defaults.AutoResolve
275+
}
254276
if len(c.Defaults.Fields) > 0 {
255277
for key, value := range c.Defaults.Fields {
256278
if _, ok := rc.Fields[key]; !ok {

pkg/config/config_test.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ type receiverTestConfig struct {
111111
WontFixResolution string `yaml:"wont_fix_resolution,omitempty"`
112112
AddGroupLabels bool `yaml:"add_group_labels,omitempty"`
113113

114+
AutoResolve *AutoResolve `yaml:"auto_resolve" json:"auto_resolve"`
115+
114116
// TODO(rporres): Add support for these.
115117
// Fields map[string]interface{} `yaml:"fields,omitempty"`
116118
// Components []string `yaml:"components,omitempty"`
@@ -290,6 +292,7 @@ func TestAuthKeysOverrides(t *testing.T) {
290292
// No tests for auth keys here. They will be handled separately
291293
func TestReceiverOverrides(t *testing.T) {
292294
fifteenHoursToDuration, err := ParseDuration("15h")
295+
autoResolve := AutoResolve{State: "Done"}
293296
require.NoError(t, err)
294297

295298
// We'll override one key at a time and check the value in the receiver.
@@ -308,8 +311,9 @@ func TestReceiverOverrides(t *testing.T) {
308311
{"Description", "A nice description", "A nice description"},
309312
{"WontFixResolution", "Won't Fix", "Won't Fix"},
310313
{"AddGroupLabels", false, false},
314+
{"AutoResolve", &AutoResolve{State: "Done"}, &autoResolve},
311315
} {
312-
optionalFields := []string{"Priority", "Description", "WontFixResolution", "AddGroupLabels"}
316+
optionalFields := []string{"Priority", "Description", "WontFixResolution", "AddGroupLabels", "AutoResolve"}
313317
defaultsConfig := newReceiverTestConfig(mandatoryReceiverFields(), optionalFields)
314318
receiverConfig := newReceiverTestConfig([]string{"Name"}, optionalFields)
315319

@@ -361,6 +365,8 @@ func newReceiverTestConfig(mandatory []string, optional []string) *receiverTestC
361365
var value reflect.Value
362366
if name == "AddGroupLabels" {
363367
value = reflect.ValueOf(true)
368+
} else if name == "AutoResolve" {
369+
value = reflect.ValueOf(&AutoResolve{State: "Done"})
364370
} else {
365371
value = reflect.ValueOf(name)
366372
}
@@ -399,3 +405,41 @@ func mandatoryReceiverFields() []string {
399405
return []string{"Name", "APIURL", "User", "Password", "Project",
400406
"IssueType", "Summary", "ReopenState", "ReopenDuration"}
401407
}
408+
409+
func TestAutoResolveConfigReceiver(t *testing.T) {
410+
mandatory := mandatoryReceiverFields()
411+
minimalReceiverTestConfig := &receiverTestConfig{
412+
Name: "test",
413+
AutoResolve: &AutoResolve{
414+
State: "",
415+
},
416+
}
417+
418+
defaultsConfig := newReceiverTestConfig(mandatory, []string{})
419+
config := testConfig{
420+
Defaults: defaultsConfig,
421+
Receivers: []*receiverTestConfig{minimalReceiverTestConfig},
422+
Template: "jiralert.tmpl",
423+
}
424+
425+
configErrorTestRunner(t, config, "bad config in receiver \"test\", 'auto_resolve' was defined with empty 'state' field")
426+
427+
}
428+
429+
func TestAutoResolveConfigDefault(t *testing.T) {
430+
mandatory := mandatoryReceiverFields()
431+
minimalReceiverTestConfig := newReceiverTestConfig([]string{"Name"}, []string{"AutoResolve"})
432+
433+
defaultsConfig := newReceiverTestConfig(mandatory, []string{})
434+
defaultsConfig.AutoResolve = &AutoResolve{
435+
State: "",
436+
}
437+
config := testConfig{
438+
Defaults: defaultsConfig,
439+
Receivers: []*receiverTestConfig{minimalReceiverTestConfig},
440+
Template: "jiralert.tmpl",
441+
}
442+
443+
configErrorTestRunner(t, config, "bad config in defaults section: state cannot be empty")
444+
445+
}

pkg/notify/notify.go

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ func (r *Receiver) Notify(data *alertmanager.Data, hashJiraLabel bool) (bool, er
101101
}
102102

103103
if len(data.Alerts.Firing()) == 0 {
104+
if r.conf.AutoResolve != nil {
105+
level.Debug(r.logger).Log("msg", "no firing alert; resolving issue", "key", issue.Key, "label", issueGroupLabel)
106+
retry, err := r.resolveIssue(issue.Key)
107+
if err != nil {
108+
return retry, err
109+
}
110+
return false, nil
111+
}
112+
104113
level.Debug(r.logger).Log("msg", "no firing alert; summary checked, nothing else to do.", "key", issue.Key, "label", issueGroupLabel)
105114
return false, nil
106115
}
@@ -343,24 +352,7 @@ func (r *Receiver) updateDescription(issueKey string, description string) (bool,
343352
}
344353

345354
func (r *Receiver) reopen(issueKey string) (bool, error) {
346-
transitions, resp, err := r.client.GetTransitions(issueKey)
347-
if err != nil {
348-
return handleJiraErrResponse("Issue.GetTransitions", resp, err, r.logger)
349-
}
350-
351-
for _, t := range transitions {
352-
if t.Name == r.conf.ReopenState {
353-
level.Debug(r.logger).Log("msg", "transition (reopen)", "key", issueKey, "transitionID", t.ID)
354-
resp, err = r.client.DoTransition(issueKey, t.ID)
355-
if err != nil {
356-
return handleJiraErrResponse("Issue.DoTransition", resp, err, r.logger)
357-
}
358-
359-
level.Debug(r.logger).Log("msg", "reopened", "key", issueKey)
360-
return false, nil
361-
}
362-
}
363-
return false, errors.Errorf("JIRA state %q does not exist or no transition possible for %s", r.conf.ReopenState, issueKey)
355+
return r.doTransition(issueKey, r.conf.ReopenState)
364356
}
365357

366358
func (r *Receiver) create(issue *jira.Issue) (bool, error) {
@@ -390,3 +382,29 @@ func handleJiraErrResponse(api string, resp *jira.Response, err error, logger lo
390382
}
391383
return false, errors.Wrapf(err, "JIRA request %s failed", api)
392384
}
385+
386+
func (r *Receiver) resolveIssue(issueKey string) (bool, error) {
387+
return r.doTransition(issueKey, r.conf.AutoResolve.State)
388+
}
389+
390+
func (r *Receiver) doTransition(issueKey string, transitionState string) (bool, error) {
391+
transitions, resp, err := r.client.GetTransitions(issueKey)
392+
if err != nil {
393+
return handleJiraErrResponse("Issue.GetTransitions", resp, err, r.logger)
394+
}
395+
396+
for _, t := range transitions {
397+
if t.Name == transitionState {
398+
level.Debug(r.logger).Log("msg", fmt.Sprintf("transition %s", transitionState), "key", issueKey, "transitionID", t.ID)
399+
resp, err = r.client.DoTransition(issueKey, t.ID)
400+
if err != nil {
401+
return handleJiraErrResponse("Issue.DoTransition", resp, err, r.logger)
402+
}
403+
404+
level.Debug(r.logger).Log("msg", transitionState, "key", issueKey)
405+
return false, nil
406+
}
407+
}
408+
return false, errors.Errorf("JIRA state %q does not exist or no transition possible for %s", r.conf.ReopenState, issueKey)
409+
410+
}

pkg/notify/notify_test.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type fakeJira struct {
4646
func newTestFakeJira() *fakeJira {
4747
return &fakeJira{
4848
issuesByKey: map[string]*jira.Issue{},
49-
transitionsByID: map[string]jira.Transition{},
49+
transitionsByID: map[string]jira.Transition{"1234": {ID: "1234", Name: "Done"}},
5050
keysByQuery: map[string][]string{},
5151
}
5252
}
@@ -174,6 +174,19 @@ func testReceiverConfig2() *config.ReceiverConfig {
174174
}
175175
}
176176

177+
func testReceiverConfigAutoResolve() *config.ReceiverConfig {
178+
reopen := config.Duration(1 * time.Hour)
179+
autoResolve := config.AutoResolve{State: "Done"}
180+
return &config.ReceiverConfig{
181+
Project: "abc",
182+
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 }}`,
183+
ReopenDuration: &reopen,
184+
ReopenState: "reopened",
185+
WontFixResolution: "won't-fix",
186+
AutoResolve: &autoResolve,
187+
}
188+
}
189+
177190
func TestNotify_JIRAInteraction(t *testing.T) {
178191
testNowTime := time.Now()
179192

@@ -479,6 +492,49 @@ func TestNotify_JIRAInteraction(t *testing.T) {
479492
},
480493
},
481494
},
495+
{
496+
name: "auto resolve alert",
497+
inputConfig: testReceiverConfigAutoResolve(),
498+
inputAlert: &alertmanager.Data{
499+
Alerts: alertmanager.Alerts{
500+
{Status: "resolved"},
501+
},
502+
Status: alertmanager.AlertResolved,
503+
GroupLabels: alertmanager.KV{"a": "b", "c": "d"},
504+
},
505+
initJira: func(t *testing.T) *fakeJira {
506+
f := newTestFakeJira()
507+
_, _, err := f.Create(&jira.Issue{
508+
ID: "1",
509+
Key: "1",
510+
Fields: &jira.IssueFields{
511+
Project: jira.Project{Key: testReceiverConfigAutoResolve().Project},
512+
Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"},
513+
Unknowns: tcontainer.MarshalMap{},
514+
Summary: "[FIRING:2] b d ",
515+
Description: "1",
516+
},
517+
})
518+
require.NoError(t, err)
519+
return f
520+
},
521+
expectedJiraIssues: map[string]*jira.Issue{
522+
"1": {
523+
ID: "1",
524+
Key: "1",
525+
Fields: &jira.IssueFields{
526+
Project: jira.Project{Key: testReceiverConfigAutoResolve().Project},
527+
Labels: []string{"JIRALERT{819ba5ecba4ea5946a8d17d285cb23f3bb6862e08bb602ab08fd231cd8e1a83a1d095b0208a661787e9035f0541817634df5a994d1b5d4200d6c68a7663c97f5}"},
528+
Status: &jira.Status{
529+
StatusCategory: jira.StatusCategory{Key: "Done"},
530+
},
531+
Unknowns: tcontainer.MarshalMap{},
532+
Summary: "[RESOLVED] b d ", // Title changed.
533+
Description: "1",
534+
},
535+
},
536+
},
537+
},
482538
} {
483539
if ok := t.Run(tcase.name, func(t *testing.T) {
484540
fakeJira := tcase.initJira(t)

0 commit comments

Comments
 (0)