diff --git a/config/notifiers.go b/config/notifiers.go index b83db4c97f..79b472e619 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -206,10 +206,14 @@ var ( NotifierConfig: NotifierConfig{ VSendResolved: true, }, - APIType: "auto", - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, - Priority: `{{ template "jira.default.priority" . }}`, + APIType: "auto", + Summary: JiraFieldConfig{ + Template: `{{ template "jira.default.summary" . }}`, + }, + Description: JiraFieldConfig{ + Template: `{{ template "jira.default.description" . }}`, + }, + Priority: `{{ template "jira.default.priority" . }}`, } DefaultMattermostConfig = MattermostConfig{ @@ -969,6 +973,13 @@ func (c *MSTeamsV2Config) UnmarshalYAML(unmarshal func(any) error) error { return nil } +type JiraFieldConfig struct { + // Template is the template string used to render the field. + Template string `yaml:"template,omitempty" json:"template,omitempty"` + // DisableUpdate indicates whether this field should be omitted when updating an existing issue. + DisableUpdate bool `yaml:"disable_update,omitempty" json:"disable_update,omitempty"` +} + type JiraConfig struct { NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` @@ -976,12 +987,12 @@ type JiraConfig struct { APIURL *URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` APIType string `yaml:"api_type,omitempty" json:"api_type,omitempty"` - Project string `yaml:"project,omitempty" json:"project,omitempty"` - Summary string `yaml:"summary,omitempty" json:"summary,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` - Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` - IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty"` + Project string `yaml:"project,omitempty" json:"project,omitempty"` + Summary JiraFieldConfig `yaml:"summary,omitempty" json:"summary,omitempty"` + Description JiraFieldConfig `yaml:"description,omitempty" json:"description,omitempty"` + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty"` ReopenTransition string `yaml:"reopen_transition,omitempty" json:"reopen_transition,omitempty"` ResolveTransition string `yaml:"resolve_transition,omitempty" json:"resolve_transition,omitempty"` @@ -991,6 +1002,27 @@ type JiraConfig struct { Fields map[string]any `yaml:"fields,omitempty" json:"custom_fields,omitempty"` } +// Supports both the legacy string and the new object form. +func (f *JiraFieldConfig) UnmarshalYAML(unmarshal func(any) error) error { + // Try simple string first (backward compatibility). + var s string + if err := unmarshal(&s); err == nil { + f.Template = s + // DisableUpdate stays false by default. + return nil + } + + // Fallback to full object form. + type plain JiraFieldConfig + var cfg plain + if err := unmarshal(&cfg); err != nil { + return err + } + + *f = JiraFieldConfig(cfg) + return nil +} + func (c *JiraConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultJiraConfig type plain JiraConfig diff --git a/docs/configuration.md b/docs/configuration.md index 8d3c29ddc3..f9bac06d9b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1159,11 +1159,23 @@ The default `jira.default.description` template only works with V2. # The project key where issues are created. project: -# Issue summary template. -[ summary: | default = '{{ template "jira.default.summary" . }}' ] - -# Issue description template. -[ description: | default = '{{ template "jira.default.description" . }}' ] +# Issue summary configuration. +[ summary: + # Template for the issue summary. + [ template: | default = '{{ template "jira.default.summary" . }}' ] + + # If true, the summary will not be updated when updating an existing issue. + [ disable_update: | default = false ] +] + +# Issue description configuration. +[ description: + # Template for the issue description. + [ template: | default = '{{ template "jira.default.description" . }}' ] + + # If true, the description will not be updated when updating an existing issue. + [ disable_update: | default = false ] +] # Labels to be added to the issue. labels: diff --git a/notify/jira/jira.go b/notify/jira/jira.go index 08897156b5..ddc20608aa 100644 --- a/notify/jira/jira.go +++ b/notify/jira/jira.go @@ -112,6 +112,15 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) return false, err } + if method == http.MethodPut && requestBody.Fields != nil { + if n.conf.Description.DisableUpdate { + requestBody.Fields.Description = nil + } + if n.conf.Summary.DisableUpdate { + requestBody.Fields.Summary = nil + } + } + _, shouldRetry, err = n.doAPIRequest(ctx, method, path, requestBody) if err != nil { return shouldRetry, fmt.Errorf("failed to %s request to %q: %w", method, path, err) @@ -121,10 +130,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) } func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc template.TemplateFunc) (issue, error) { - summary, err := tmplTextFunc(n.conf.Summary) + summary, err := tmplTextFunc(n.conf.Summary.Template) if err != nil { return issue{}, fmt.Errorf("summary template: %w", err) } + project, err := tmplTextFunc(n.conf.Project) if err != nil { return issue{}, fmt.Errorf("project template: %w", err) @@ -161,7 +171,7 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge Fields: fieldsWithStringKeys, }} - issueDescriptionString, err := tmplTextFunc(n.conf.Description) + issueDescriptionString, err := tmplTextFunc(n.conf.Description.Template) if err != nil { return issue{}, fmt.Errorf("description template: %w", err) } diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go index 115b1d8846..68f4966db7 100644 --- a/notify/jira/jira_test.go +++ b/notify/jira/jira_test.go @@ -109,8 +109,8 @@ func TestSearchExistingIssue(t *testing.T) { { title: "search existing issue with project template for firing alert", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, }, groupKey: "1", @@ -120,8 +120,8 @@ func TestSearchExistingIssue(t *testing.T) { { title: "search existing issue with reopen duration for firing alert", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, ReopenDuration: model.Duration(60 * time.Minute), ReopenTransition: "REOPEN", @@ -133,8 +133,8 @@ func TestSearchExistingIssue(t *testing.T) { { title: "search existing issue for resolved alert", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, }, groupKey: "1", @@ -349,8 +349,8 @@ func TestJiraTemplating(t *testing.T) { { title: "full-blown message with templated custom field", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Fields: map[string]any{ "customfield_14400": `{{ template "jira.host" . }}`, }, @@ -363,8 +363,8 @@ func TestJiraTemplating(t *testing.T) { title: "template project", cfg: &config.JiraConfig{ Project: `{{ .CommonLabels.lbl1 }}`, - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, }, retry: false, }, @@ -372,31 +372,31 @@ func TestJiraTemplating(t *testing.T) { title: "template issue type", cfg: &config.JiraConfig{ IssueType: `{{ .CommonLabels.lbl1 }}`, - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, }, retry: false, }, { title: "summary with templating errors", cfg: &config.JiraConfig{ - Summary: "{{ ", + Summary: config.JiraFieldConfig{Template: "{{ "}, }, errMsg: "template: :1: unclosed action", }, { title: "description with templating errors", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: "{{ ", + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: "{{ "}, }, errMsg: "template: :1: unclosed action", }, { title: "priority with templating errors", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Priority: "{{ ", }, errMsg: "template: :1: unclosed action", @@ -471,8 +471,8 @@ func TestJiraNotify(t *testing.T) { { title: "create new issue", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, @@ -510,11 +510,82 @@ func TestJiraNotify(t *testing.T) { customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "", }, + { + title: "update existing issue with disabled summary and description", + cfg: &config.JiraConfig{ + Summary: config.JiraFieldConfig{ + Template: `{{ template "jira.default.summary" . }}`, + DisableUpdate: true, + }, + Description: config.JiraFieldConfig{ + Template: `{{ template "jira.default.description" . }}`, + DisableUpdate: true, + }, + IssueType: "{{ .CommonLabels.issue_type }}", + Project: "{{ .CommonLabels.project }}", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + "project": "MONITORING", + "issue_type": "MINOR", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Issues: []issue{ + { + Key: "MONITORING-1", + Fields: &issueFields{ + Summary: "Original Summary", + Description: "Original Description", + Status: &issueStatus{ + Name: "Open", + StatusCategory: struct { + Key string `json:"key"` + }{ + Key: "open", + }, + }, + }, + }, + }, + }, + issue: issue{ + Key: "MONITORING-1", + Fields: &issueFields{ + // Summary and Description should NOT be present in the update request + Issuetype: &idNameValue{Name: "MINOR"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "MONITORING"}, + Priority: &idNameValue{Name: "High"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) { + // Verify that summary and description are NOT in the update request + _, hasSummary := issue["summary"] + _, hasDescription := issue["description"] + require.False(t, hasSummary, "summary should not be present in update request") + require.False(t, hasDescription, "description should not be present in update request") + }, + errMsg: "", + }, { title: "create new issue with template project and issue type", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "{{ .CommonLabels.issue_type }}", Project: "{{ .CommonLabels.project }}", Priority: `{{ template "jira.default.priority" . }}`, @@ -557,8 +628,8 @@ func TestJiraNotify(t *testing.T) { { title: "create new issue with custom field and too long summary", cfg: &config.JiraConfig{ - Summary: strings.Repeat("A", maxSummaryLenRunes+10), - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: strings.Repeat("A", maxSummaryLenRunes+10)}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, @@ -617,8 +688,8 @@ func TestJiraNotify(t *testing.T) { { title: "reopen issue", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, @@ -672,8 +743,8 @@ func TestJiraNotify(t *testing.T) { { title: "error resolve transition not found", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, @@ -726,8 +797,8 @@ func TestJiraNotify(t *testing.T) { { title: "error reopen transition not found", cfg: &config.JiraConfig{ - Summary: `{{ template "jira.default.summary" . }}`, - Description: `{{ template "jira.default.description" . }}`, + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, @@ -867,10 +938,28 @@ func TestJiraNotify(t *testing.T) { t.Fatalf("unexpected method %s", r.Method) } + return + case "/issue/MONITORING-1": + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + panic(err) + } + + if fields, ok := raw["fields"].(map[string]any); ok { + tc.customFieldAssetFn(t, fields) + } + + w.WriteHeader(http.StatusNoContent) return case "/issue/OPS-1": case "/issue/OPS-2": case "/issue/OPS-3": + case "/issue/OPS-4": fallthrough case "/issue": body, err := io.ReadAll(r.Body) diff --git a/notify/jira/types.go b/notify/jira/types.go index 6e4f9625ff..454e3f956a 100644 --- a/notify/jira/types.go +++ b/notify/jira/types.go @@ -30,7 +30,7 @@ type issueFields struct { Priority *idNameValue `json:"priority,omitempty"` Project *issueProject `json:"project,omitempty"` Resolution *idNameValue `json:"resolution,omitempty"` - Summary string `json:"summary"` + Summary any `json:"summary"` Status *issueStatus `json:"status,omitempty"` Fields map[string]any `json:"-"` @@ -68,11 +68,15 @@ type issueTransitions struct { // MarshalJSON merges the struct issueFields and issueFields.CustomField together. func (i issueFields) MarshalJSON() ([]byte, error) { - jsonFields := map[string]any{ - "description": i.Description, - "summary": i.Summary, + jsonFields := map[string]interface{}{} + + if i.Summary != nil { + jsonFields["summary"] = i.Summary } + if i.Description != nil { + jsonFields["description"] = i.Description + } if i.Issuetype != nil { jsonFields["issuetype"] = i.Issuetype }