diff --git a/notify/jira/jira.go b/notify/jira/jira.go index e2ecd9dac5..04d58b4972 100644 --- a/notify/jira/jira.go +++ b/notify/jira/jira.go @@ -180,13 +180,25 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge logger.Warn("Truncated description", "max_runes", maxDescriptionLenRunes) } + var description *jiraDescription descriptionCopy := issueDescriptionString if isAPIv3Path(n.conf.APIURL.Path) { - if !json.Valid([]byte(descriptionCopy)) { - return issue{}, fmt.Errorf("description template: invalid JSON for API v3") + descriptionCopy = strings.TrimSpace(descriptionCopy) + if descriptionCopy != "" { + if !json.Valid([]byte(descriptionCopy)) { + return issue{}, fmt.Errorf("description template: invalid JSON for API v3") + } + raw := json.RawMessage(descriptionCopy) + description = &jiraDescription{ + RawJSONDescription: append(json.RawMessage(nil), raw...), + } } + } else if descriptionCopy != "" { + desc := descriptionCopy + description = &jiraDescription{StringDescription: &desc} } - requestBody.Fields.Description = &descriptionCopy + + requestBody.Fields.Description = description for i, label := range n.conf.Labels { label, err = tmplTextFunc(label) diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go index ac1b308eb6..5f17434611 100644 --- a/notify/jira/jira_test.go +++ b/notify/jira/jira_test.go @@ -36,6 +36,10 @@ import ( "github.com/prometheus/alertmanager/types" ) +func jiraStringDescription(v string) *jiraDescription { + return &jiraDescription{StringDescription: stringPtr(v)} +} + func stringPtr(v string) *string { return &v } @@ -504,7 +508,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1 critical)"), - Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), + Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, @@ -553,7 +557,7 @@ func TestJiraNotify(t *testing.T) { Key: "MONITORING-1", Fields: &issueFields{ Summary: stringPtr("Original Summary"), - Description: stringPtr("Original Description"), + Description: jiraStringDescription("Original Description"), Status: &issueStatus{ Name: "Open", StatusCategory: struct { @@ -619,7 +623,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1 MINOR MONITORING critical)"), - Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - issue_type = MINOR\n - project = MONITORING\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), + Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - issue_type = MINOR\n - project = MONITORING\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "MINOR"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "MONITORING"}, @@ -671,7 +675,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr(strings.Repeat("A", maxSummaryLenRunes-1) + "…"), - Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), + Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, @@ -734,7 +738,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1)"), - Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), + Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, @@ -789,7 +793,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr("[RESOLVED] test (vm1)"), - Description: stringPtr("\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n"), + Description: jiraStringDescription("\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, @@ -843,7 +847,7 @@ func TestJiraNotify(t *testing.T) { Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1)"), - Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), + Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, @@ -1236,3 +1240,83 @@ func TestJiraPriority(t *testing.T) { }) } } + +func TestPrepareIssueRequestBodyAPIv3DescriptionValidation(t *testing.T) { + for _, tc := range []struct { + name string + descriptionTemplate string + expectErrSubstring string + }{ + { + name: "valid JSON description", + descriptionTemplate: `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, + }, + { + name: "invalid JSON description", + descriptionTemplate: `not-json`, + expectErrSubstring: "invalid JSON for API v3", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.JiraConfig{ + Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, + Description: config.JiraFieldConfig{Template: tc.descriptionTemplate}, + IssueType: "Incident", + Project: "OPS", + Labels: []string{"alertmanager"}, + Priority: `{{ template "jira.default.priority" . }}`, + APIURL: &config.URL{ + URL: &url.URL{ + Scheme: "https", + Host: "example.atlassian.net", + Path: "/rest/api/3", + }, + }, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + + notifier, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) + require.NoError(t, err) + + alert := &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + } + + ctx := context.Background() + groupID := "1" + ctx = notify.WithGroupKey(ctx, groupID) + ctx = notify.WithGroupLabels(ctx, alert.Labels) + + alerts := []*types.Alert{alert} + logger := notifier.logger.With("group_key", groupID) + data := notify.GetTemplateData(ctx, notifier.tmpl, alerts, logger) + + var tmplErr error + tmplText := notify.TmplText(notifier.tmpl, data, &tmplErr) + tmplTextFunc := func(tmpl string) (string, error) { + return tmplText(tmpl), tmplErr + } + + issue, err := notifier.prepareIssueRequestBody(ctx, logger, groupID, tmplTextFunc) + if tc.expectErrSubstring != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectErrSubstring) + return + } + + require.NoError(t, err) + require.NotNil(t, issue.Fields) + + require.NotNil(t, issue.Fields.Description) + require.JSONEq(t, tc.descriptionTemplate, string(issue.Fields.Description.RawJSONDescription)) + }) + } +} diff --git a/notify/jira/types.go b/notify/jira/types.go index ac933f81dd..4d42097740 100644 --- a/notify/jira/types.go +++ b/notify/jira/types.go @@ -14,10 +14,12 @@ package jira import ( + "bytes" "encoding/json" "maps" ) +// issue represents a Jira issue wrapper. type issue struct { Key string `json:"key,omitempty"` Fields *issueFields `json:"fields,omitempty"` @@ -25,14 +27,14 @@ type issue struct { } type issueFields struct { - Description *string `json:"description,omitempty"` - Issuetype *idNameValue `json:"issuetype,omitempty"` - Labels []string `json:"labels,omitempty"` - Priority *idNameValue `json:"priority,omitempty"` - Project *issueProject `json:"project,omitempty"` - Resolution *idNameValue `json:"resolution,omitempty"` - Summary *string `json:"summary,omitempty"` - Status *issueStatus `json:"status,omitempty"` + Description *jiraDescription `json:"description,omitempty"` + Issuetype *idNameValue `json:"issuetype,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority *idNameValue `json:"priority,omitempty"` + Project *issueProject `json:"project,omitempty"` + Resolution *idNameValue `json:"resolution,omitempty"` + Summary *string `json:"summary,omitempty"` + Status *issueStatus `json:"status,omitempty"` Fields map[string]any `json:"-"` } @@ -75,34 +77,95 @@ func (i issueFields) MarshalJSON() ([]byte, error) { jsonFields["summary"] = *i.Summary } - if i.Description != nil { - jsonFields["description"] = *i.Description + // Only include description when it has content. + if i.Description != nil && !i.Description.IsEmpty() { + jsonFields["description"] = i.Description } + if i.Issuetype != nil { jsonFields["issuetype"] = i.Issuetype } - if i.Labels != nil { jsonFields["labels"] = i.Labels } - if i.Priority != nil { jsonFields["priority"] = i.Priority } - if i.Project != nil { jsonFields["project"] = i.Project } - if i.Resolution != nil { jsonFields["resolution"] = i.Resolution } - if i.Status != nil { jsonFields["status"] = i.Status } - maps.Copy(jsonFields, i.Fields) + // copy custom/unknown fields into the outgoing map + if i.Fields != nil { + maps.Copy(jsonFields, i.Fields) + } return json.Marshal(jsonFields) } + +// jiraDescription holds either a plain string (v2 API) description or ADF (Atlassian Document Format) JSON (v3 API). +type jiraDescription struct { + StringDescription *string // non-nil if the description is a simple string + RawJSONDescription json.RawMessage // non-empty if the description is structured JSON +} + +func (jd jiraDescription) MarshalJSON() ([]byte, error) { + // If there's a structured JSON payload, return it as-is. + if len(jd.RawJSONDescription) > 0 { + out := make([]byte, len(jd.RawJSONDescription)) + copy(out, jd.RawJSONDescription) + return out, nil + } + + // If we have a string representation, let json.Marshal quote it properly. + if jd.StringDescription != nil { + return json.Marshal(*jd.StringDescription) + } + + // No value: represent as JSON null. + return []byte("null"), nil +} + +func (jd *jiraDescription) UnmarshalJSON(data []byte) error { + // Reset current state + jd.StringDescription = nil + jd.RawJSONDescription = nil + + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + // nothing to do (leave both fields nil/empty) + return nil + } + + // If it starts with object or array token, treat as structured JSON and keep raw bytes. + switch trimmed[0] { + case '{', '[': + // store a copy of the raw JSON + jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...) + return nil + default: + // otherwise try to unmarshal as string (expected for Jira v2) + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + // fallback: if it's not a string but also not an object/array, keep raw bytes + jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...) + return nil + } + jd.StringDescription = &s + return nil + } +} + +// IsEmpty reports whether the jiraDescription contains no useful value. +func (jd *jiraDescription) IsEmpty() bool { + if jd == nil { + return true + } + return jd.StringDescription == nil && len(jd.RawJSONDescription) == 0 +}