Repository: prometheus/alertmanager
Pull request #4291 (2025-03-03 22:53:14)

Render Jira project template when searching for existing issues
https://github.com/prometheus/alertmanager/pull/4159 enabled templating for Jira config fields `project` and `issue_type` by rendering the templates in the request body sent to create the issue:

However, it doesn't render the value in a previous request that checks if the Jira issue already exists, throwing the error:
```
notify retry canceled due to unrecoverable error after 1 attempts:
failed to look up existing issues: HTTP request to JIRA API: 
unexpected status code 400:
{"errorMessages":["The value '{{ .CommonLabels.project }}' does not exist for the field 'project'."],"warningMessages":[]}
```

This change adds the same template function to render the `project` value in the search request as well.


Patch:
diff --git a/config/config.go b/config/config.go
index b8e48413..6f54c523 100644
--- a/config/config.go
+++ b/config/config.go
@@ -968,11 +968,7 @@ type InhibitRule struct {
 	TargetMatchers Matchers `yaml:"target_matchers,omitempty" json:"target_matchers,omitempty"`
 	// A set of labels that must be equal between the source and target alert
 	// for them to be a match.
-	Equal model.LabelNames `yaml:"-" json:"-"`
-	// EqualStr allows us to validate the label depending on whether UTF-8 is
-	// enabled or disabled. It should be removed when Alertmanager is updated
-	// to use the validation modes in recent versions of prometheus/common.
-	EqualStr []string `yaml:"equal,omitempty" json:"equal,omitempty"`
+	Equal []string `yaml:"equal,omitempty" json:"equal,omitempty"`
 }
 
 // UnmarshalYAML implements the yaml.Unmarshaler interface for InhibitRule.
@@ -994,12 +990,11 @@ func (r *InhibitRule) UnmarshalYAML(unmarshal func(interface{}) error) error {
 		}
 	}
 
-	for _, l := range r.EqualStr {
+	for _, l := range r.Equal {
 		labelName := model.LabelName(l)
 		if !compat.IsValidLabelName(labelName) {
 			return fmt.Errorf("invalid label name %q in equal list", l)
 		}
-		r.Equal = append(r.Equal, labelName)
 	}
 
 	return nil
diff --git a/inhibit/inhibit.go b/inhibit/inhibit.go
index eb1f7752..458367cb 100644
--- a/inhibit/inhibit.go
+++ b/inhibit/inhibit.go
@@ -214,7 +214,7 @@ func NewInhibitRule(cr config.InhibitRule) *InhibitRule {
 
 	equal := map[model.LabelName]struct{}{}
 	for _, ln := range cr.Equal {
-		equal[ln] = struct{}{}
+		equal[model.LabelName(ln)] = struct{}{}
 	}
 
 	return &InhibitRule{
diff --git a/notify/jira/jira.go b/notify/jira/jira.go
index 02c75295..259a5e62 100644
--- a/notify/jira/jira.go
+++ b/notify/jira/jira.go
@@ -87,7 +87,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
 		method = http.MethodPost
 	)
 
-	existingIssue, shouldRetry, err := n.searchExistingIssue(ctx, logger, key.Hash(), alerts.HasFiring())
+	existingIssue, shouldRetry, err := n.searchExistingIssue(ctx, logger, key.Hash(), alerts.HasFiring(), tmplTextFunc)
 	if err != nil {
 		return shouldRetry, fmt.Errorf("failed to look up existing issues: %w", err)
 	}
@@ -194,7 +194,7 @@ func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger *slog.Log
 	return requestBody, nil
 }
 
-func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool) (*issue, bool, error) {
+func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc templateFunc) (*issue, bool, error) {
 	jql := strings.Builder{}
 
 	if n.conf.WontFixResolution != "" {
@@ -214,7 +214,11 @@ func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger,
 	}
 
 	alertLabel := fmt.Sprintf("ALERT{%s}", groupID)
-	jql.WriteString(fmt.Sprintf(`project=%q and labels=%q order by status ASC,resolutiondate DESC`, n.conf.Project, alertLabel))
+	project, err := tmplTextFunc(n.conf.Project)
+	if err != nil {
+		return nil, false, fmt.Errorf("invalid project template or value: %w", err)
+	}
+	jql.WriteString(fmt.Sprintf(`project=%q and labels=%q order by status ASC,resolutiondate DESC`, project, alertLabel))
 
 	requestBody := issueSearch{
 		JQL:        jql.String(),


Test patch:
diff --git a/config/config_test.go b/config/config_test.go
index 99984827..728d7670 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -1461,7 +1461,7 @@ func TestInhibitRuleEqual(t *testing.T) {
 	// The inhibition rule should have the expected equal labels.
 	require.Len(t, c.InhibitRules, 1)
 	r := c.InhibitRules[0]
-	require.Equal(t, model.LabelNames{"qux", "corge"}, r.Equal)
+	require.Equal(t, []string{"qux", "corge"}, r.Equal)
 
 	// Should not be able to load configuration with UTF-8 in equals list.
 	_, err = LoadFile("testdata/conf.inhibit-equal-utf8.yml")
@@ -1484,7 +1484,7 @@ func TestInhibitRuleEqual(t *testing.T) {
 	// The inhibition rule should have the expected equal labels.
 	require.Len(t, c.InhibitRules, 1)
 	r = c.InhibitRules[0]
-	require.Equal(t, model.LabelNames{"qux", "corge"}, r.Equal)
+	require.Equal(t, []string{"qux", "corge"}, r.Equal)
 
 	// Should also be able to load configuration with UTF-8 in equals list.
 	c, err = LoadFile("testdata/conf.inhibit-equal-utf8.yml")
@@ -1493,5 +1493,5 @@ func TestInhibitRuleEqual(t *testing.T) {
 	// The inhibition rule should have the expected equal labels.
 	require.Len(t, c.InhibitRules, 1)
 	r = c.InhibitRules[0]
-	require.Equal(t, model.LabelNames{"qux🙂", "corge"}, r.Equal)
+	require.Equal(t, []string{"qux🙂", "corge"}, r.Equal)
 }
diff --git a/inhibit/inhibit_test.go b/inhibit/inhibit_test.go
index 1f6f4ef2..59ff52b1 100644
--- a/inhibit/inhibit_test.go
+++ b/inhibit/inhibit_test.go
@@ -144,12 +144,12 @@ func TestInhibitRuleMatches(t *testing.T) {
 	rule1 := config.InhibitRule{
 		SourceMatch: map[string]string{"s1": "1"},
 		TargetMatch: map[string]string{"t1": "1"},
-		Equal:       model.LabelNames{"e"},
+		Equal:       []string{"e"},
 	}
 	rule2 := config.InhibitRule{
 		SourceMatch: map[string]string{"s2": "1"},
 		TargetMatch: map[string]string{"t2": "1"},
-		Equal:       model.LabelNames{"e"},
+		Equal:       []string{"e"},
 	}
 
 	m := types.NewMarker(prometheus.NewRegistry())
@@ -240,12 +240,12 @@ func TestInhibitRuleMatchers(t *testing.T) {
 	rule1 := config.InhibitRule{
 		SourceMatchers: config.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "s1", Value: "1"}},
 		TargetMatchers: config.Matchers{&labels.Matcher{Type: labels.MatchNotEqual, Name: "t1", Value: "1"}},
-		Equal:          model.LabelNames{"e"},
+		Equal:          []string{"e"},
 	}
 	rule2 := config.InhibitRule{
 		SourceMatchers: config.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "s2", Value: "1"}},
 		TargetMatchers: config.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "t2", Value: "1"}},
-		Equal:          model.LabelNames{"e"},
+		Equal:          []string{"e"},
 	}
 
 	m := types.NewMarker(prometheus.NewRegistry())
@@ -374,7 +374,7 @@ func TestInhibit(t *testing.T) {
 		return config.InhibitRule{
 			SourceMatch: map[string]string{"s": "1"},
 			TargetMatch: map[string]string{"t": "1"},
-			Equal:       model.LabelNames{"e"},
+			Equal:       []string{"e"},
 		}
 	}
 	// alertOne is muted by alertTwo when it is active.
diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go
index 1c728b6c..9b5868aa 100644
--- a/notify/jira/jira_test.go
+++ b/notify/jira/jira_test.go
@@ -62,6 +62,104 @@ func TestJiraRetry(t *testing.T) {
 	}
 }
 
+func TestSearchExistingIssue(t *testing.T) {
+	expectedJQL := ""
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/search":
+			body, err := io.ReadAll(r.Body)
+			if err != nil {
+				http.Error(w, "Error reading request body", http.StatusBadRequest)
+				return
+			}
+			defer r.Body.Close()
+
+			// Unmarshal the JSON data into the struct
+			var data issueSearch
+			err = json.Unmarshal(body, &data)
+			if err != nil {
+				http.Error(w, "Error unmarshaling JSON", http.StatusBadRequest)
+				return
+			}
+			require.Equal(t, expectedJQL, data.JQL)
+			w.Write([]byte(`{"total": 0, "issues": []}`))
+			return
+		default:
+			dec := json.NewDecoder(r.Body)
+			out := make(map[string]any)
+			err := dec.Decode(&out)
+			if err != nil {
+				panic(err)
+			}
+		}
+	}))
+
+	defer srv.Close()
+	u, _ := url.Parse(srv.URL)
+
+	for _, tc := range []struct {
+		title         string
+		cfg           *config.JiraConfig
+		groupKey      string
+		expectedJQL   string
+		expectedIssue *issue
+		expectedErr   bool
+		expectedRetry bool
+	}{
+		{
+			title: "search existing issue with project template",
+			cfg: &config.JiraConfig{
+				Summary:     `{{ template "jira.default.summary" . }}`,
+				Description: `{{ template "jira.default.description" . }}`,
+				Project:     `{{ .CommonLabels.project }}`,
+			},
+			groupKey:    "1",
+			expectedJQL: `statusCategory != Done and project="PROJ" and labels="ALERT{1}" order by status ASC,resolutiondate DESC`,
+		},
+	} {
+		tc := tc
+		t.Run(tc.title, func(t *testing.T) {
+			expectedJQL = tc.expectedJQL
+			tc.cfg.APIURL = &config.URL{URL: u}
+			tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
+
+			as := []*types.Alert{
+				{
+					Alert: model.Alert{
+						Labels: model.LabelSet{
+							"project": "PROJ",
+						},
+						StartsAt: time.Now(),
+						EndsAt:   time.Now().Add(time.Hour),
+					},
+				},
+			}
+
+			pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
+			require.NoError(t, err)
+			logger := pd.logger.With("group_key", tc.groupKey)
+
+			ctx := notify.WithGroupKey(context.Background(), tc.groupKey)
+			data := notify.GetTemplateData(ctx, pd.tmpl, as, logger)
+
+			var tmplTextErr error
+			tmplText := notify.TmplText(pd.tmpl, data, &tmplTextErr)
+			tmplTextFunc := func(tmpl string) (string, error) {
+				return tmplText(tmpl), tmplTextErr
+			}
+
+			issue, retry, err := pd.searchExistingIssue(ctx, logger, tc.groupKey, true, tmplTextFunc)
+			if tc.expectedErr {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+			require.EqualValues(t, tc.expectedIssue, issue)
+			require.Equal(t, tc.expectedRetry, retry)
+		})
+	}
+}
+
 func TestJiraTemplating(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		switch r.URL.Path {