Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions notify/jira/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
98 changes: 91 additions & 7 deletions notify/jira/jira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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))
})
}
}
95 changes: 79 additions & 16 deletions notify/jira/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,27 @@
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"`
Transition *idNameValue `json:"transition,omitempty"`
}

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:"-"`
}
Expand Down Expand Up @@ -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
}
Loading