Skip to content

Commit c03adf8

Browse files
Jira Integration: fix for handling jira api v3 with ADF
Signed-off-by: Holger Waschke <waschkester@gmail.com>
1 parent 2da9906 commit c03adf8

File tree

3 files changed

+185
-26
lines changed

3 files changed

+185
-26
lines changed

notify/jira/jira.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,25 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge
180180
logger.Warn("Truncated description", "max_runes", maxDescriptionLenRunes)
181181
}
182182

183+
var description *jiraDescription
183184
descriptionCopy := issueDescriptionString
184185
if isAPIv3Path(n.conf.APIURL.Path) {
185-
if !json.Valid([]byte(descriptionCopy)) {
186-
return issue{}, fmt.Errorf("description template: invalid JSON for API v3")
186+
descriptionCopy = strings.TrimSpace(descriptionCopy)
187+
if descriptionCopy != "" {
188+
if !json.Valid([]byte(descriptionCopy)) {
189+
return issue{}, fmt.Errorf("description template: invalid JSON for API v3")
190+
}
191+
raw := json.RawMessage(descriptionCopy)
192+
description = &jiraDescription{
193+
RawJSONDescription: append(json.RawMessage(nil), raw...),
194+
}
187195
}
196+
} else if descriptionCopy != "" {
197+
desc := descriptionCopy
198+
description = &jiraDescription{StringDescription: &desc}
188199
}
189-
requestBody.Fields.Description = &descriptionCopy
200+
201+
requestBody.Fields.Description = description
190202

191203
for i, label := range n.conf.Labels {
192204
label, err = tmplTextFunc(label)

notify/jira/jira_test.go

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ import (
3636
"github.com/prometheus/alertmanager/types"
3737
)
3838

39+
func jiraStringDescription(v string) *jiraDescription {
40+
return &jiraDescription{StringDescription: stringPtr(v)}
41+
}
42+
3943
func stringPtr(v string) *string {
4044
return &v
4145
}
@@ -504,7 +508,7 @@ func TestJiraNotify(t *testing.T) {
504508
Key: "",
505509
Fields: &issueFields{
506510
Summary: stringPtr("[FIRING:1] test (vm1 critical)"),
507-
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"),
511+
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"),
508512
Issuetype: &idNameValue{Name: "Incident"},
509513
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
510514
Project: &issueProject{Key: "OPS"},
@@ -553,7 +557,7 @@ func TestJiraNotify(t *testing.T) {
553557
Key: "MONITORING-1",
554558
Fields: &issueFields{
555559
Summary: stringPtr("Original Summary"),
556-
Description: stringPtr("Original Description"),
560+
Description: jiraStringDescription("Original Description"),
557561
Status: &issueStatus{
558562
Name: "Open",
559563
StatusCategory: struct {
@@ -619,7 +623,7 @@ func TestJiraNotify(t *testing.T) {
619623
Key: "",
620624
Fields: &issueFields{
621625
Summary: stringPtr("[FIRING:1] test (vm1 MINOR MONITORING critical)"),
622-
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"),
626+
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"),
623627
Issuetype: &idNameValue{Name: "MINOR"},
624628
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
625629
Project: &issueProject{Key: "MONITORING"},
@@ -671,7 +675,7 @@ func TestJiraNotify(t *testing.T) {
671675
Key: "",
672676
Fields: &issueFields{
673677
Summary: stringPtr(strings.Repeat("A", maxSummaryLenRunes-1) + "…"),
674-
Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
678+
Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
675679
Issuetype: &idNameValue{Name: "Incident"},
676680
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
677681
Project: &issueProject{Key: "OPS"},
@@ -734,7 +738,7 @@ func TestJiraNotify(t *testing.T) {
734738
Key: "",
735739
Fields: &issueFields{
736740
Summary: stringPtr("[FIRING:1] test (vm1)"),
737-
Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
741+
Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
738742
Issuetype: &idNameValue{Name: "Incident"},
739743
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
740744
Project: &issueProject{Key: "OPS"},
@@ -789,7 +793,7 @@ func TestJiraNotify(t *testing.T) {
789793
Key: "",
790794
Fields: &issueFields{
791795
Summary: stringPtr("[RESOLVED] test (vm1)"),
792-
Description: stringPtr("\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n"),
796+
Description: jiraStringDescription("\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n"),
793797
Issuetype: &idNameValue{Name: "Incident"},
794798
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
795799
Project: &issueProject{Key: "OPS"},
@@ -843,7 +847,7 @@ func TestJiraNotify(t *testing.T) {
843847
Key: "",
844848
Fields: &issueFields{
845849
Summary: stringPtr("[FIRING:1] test (vm1)"),
846-
Description: stringPtr("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
850+
Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"),
847851
Issuetype: &idNameValue{Name: "Incident"},
848852
Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"},
849853
Project: &issueProject{Key: "OPS"},
@@ -1236,3 +1240,83 @@ func TestJiraPriority(t *testing.T) {
12361240
})
12371241
}
12381242
}
1243+
1244+
func TestPrepareIssueRequestBodyAPIv3DescriptionValidation(t *testing.T) {
1245+
for _, tc := range []struct {
1246+
name string
1247+
descriptionTemplate string
1248+
expectErrSubstring string
1249+
}{
1250+
{
1251+
name: "valid JSON description",
1252+
descriptionTemplate: `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`,
1253+
},
1254+
{
1255+
name: "invalid JSON description",
1256+
descriptionTemplate: `not-json`,
1257+
expectErrSubstring: "invalid JSON for API v3",
1258+
},
1259+
} {
1260+
t.Run(tc.name, func(t *testing.T) {
1261+
cfg := &config.JiraConfig{
1262+
Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`},
1263+
Description: config.JiraFieldConfig{Template: tc.descriptionTemplate},
1264+
IssueType: "Incident",
1265+
Project: "OPS",
1266+
Labels: []string{"alertmanager"},
1267+
Priority: `{{ template "jira.default.priority" . }}`,
1268+
APIURL: &config.URL{
1269+
URL: &url.URL{
1270+
Scheme: "https",
1271+
Host: "example.atlassian.net",
1272+
Path: "/rest/api/3",
1273+
},
1274+
},
1275+
HTTPConfig: &commoncfg.HTTPClientConfig{},
1276+
}
1277+
1278+
notifier, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger())
1279+
require.NoError(t, err)
1280+
1281+
alert := &types.Alert{
1282+
Alert: model.Alert{
1283+
Labels: model.LabelSet{
1284+
"alertname": "test",
1285+
"instance": "vm1",
1286+
"severity": "critical",
1287+
},
1288+
StartsAt: time.Now(),
1289+
EndsAt: time.Now().Add(time.Hour),
1290+
},
1291+
}
1292+
1293+
ctx := context.Background()
1294+
groupID := "1"
1295+
ctx = notify.WithGroupKey(ctx, groupID)
1296+
ctx = notify.WithGroupLabels(ctx, alert.Labels)
1297+
1298+
alerts := []*types.Alert{alert}
1299+
logger := notifier.logger.With("group_key", groupID)
1300+
data := notify.GetTemplateData(ctx, notifier.tmpl, alerts, logger)
1301+
1302+
var tmplErr error
1303+
tmplText := notify.TmplText(notifier.tmpl, data, &tmplErr)
1304+
tmplTextFunc := func(tmpl string) (string, error) {
1305+
return tmplText(tmpl), tmplErr
1306+
}
1307+
1308+
issue, err := notifier.prepareIssueRequestBody(ctx, logger, groupID, tmplTextFunc)
1309+
if tc.expectErrSubstring != "" {
1310+
require.Error(t, err)
1311+
require.ErrorContains(t, err, tc.expectErrSubstring)
1312+
return
1313+
}
1314+
1315+
require.NoError(t, err)
1316+
require.NotNil(t, issue.Fields)
1317+
1318+
require.NotNil(t, issue.Fields.Description)
1319+
require.JSONEq(t, tc.descriptionTemplate, string(issue.Fields.Description.RawJSONDescription))
1320+
})
1321+
}
1322+
}

notify/jira/types.go

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,27 @@
1414
package jira
1515

1616
import (
17+
"bytes"
1718
"encoding/json"
1819
"maps"
1920
)
2021

22+
// issue represents a Jira issue wrapper.
2123
type issue struct {
2224
Key string `json:"key,omitempty"`
2325
Fields *issueFields `json:"fields,omitempty"`
2426
Transition *idNameValue `json:"transition,omitempty"`
2527
}
2628

2729
type issueFields struct {
28-
Description *string `json:"description,omitempty"`
29-
Issuetype *idNameValue `json:"issuetype,omitempty"`
30-
Labels []string `json:"labels,omitempty"`
31-
Priority *idNameValue `json:"priority,omitempty"`
32-
Project *issueProject `json:"project,omitempty"`
33-
Resolution *idNameValue `json:"resolution,omitempty"`
34-
Summary *string `json:"summary,omitempty"`
35-
Status *issueStatus `json:"status,omitempty"`
30+
Description *jiraDescription `json:"description,omitempty"`
31+
Issuetype *idNameValue `json:"issuetype,omitempty"`
32+
Labels []string `json:"labels,omitempty"`
33+
Priority *idNameValue `json:"priority,omitempty"`
34+
Project *issueProject `json:"project,omitempty"`
35+
Resolution *idNameValue `json:"resolution,omitempty"`
36+
Summary *string `json:"summary,omitempty"`
37+
Status *issueStatus `json:"status,omitempty"`
3638

3739
Fields map[string]any `json:"-"`
3840
}
@@ -75,34 +77,95 @@ func (i issueFields) MarshalJSON() ([]byte, error) {
7577
jsonFields["summary"] = *i.Summary
7678
}
7779

78-
if i.Description != nil {
79-
jsonFields["description"] = *i.Description
80+
// Only include description when it has content.
81+
if i.Description != nil && !i.Description.IsEmpty() {
82+
jsonFields["description"] = i.Description
8083
}
84+
8185
if i.Issuetype != nil {
8286
jsonFields["issuetype"] = i.Issuetype
8387
}
84-
8588
if i.Labels != nil {
8689
jsonFields["labels"] = i.Labels
8790
}
88-
8991
if i.Priority != nil {
9092
jsonFields["priority"] = i.Priority
9193
}
92-
9394
if i.Project != nil {
9495
jsonFields["project"] = i.Project
9596
}
96-
9797
if i.Resolution != nil {
9898
jsonFields["resolution"] = i.Resolution
9999
}
100-
101100
if i.Status != nil {
102101
jsonFields["status"] = i.Status
103102
}
104103

105-
maps.Copy(jsonFields, i.Fields)
104+
// copy custom/unknown fields into the outgoing map
105+
if i.Fields != nil {
106+
maps.Copy(jsonFields, i.Fields)
107+
}
106108

107109
return json.Marshal(jsonFields)
108110
}
111+
112+
// jiraDescription holds either a plain string (v2 API) description or ADF (Atlassian Document Format) JSON (v3 API).
113+
type jiraDescription struct {
114+
StringDescription *string // non-nil if the description is a simple string
115+
RawJSONDescription json.RawMessage // non-empty if the description is structured JSON
116+
}
117+
118+
func (jd jiraDescription) MarshalJSON() ([]byte, error) {
119+
// If there's a structured JSON payload, return it as-is.
120+
if len(jd.RawJSONDescription) > 0 {
121+
out := make([]byte, len(jd.RawJSONDescription))
122+
copy(out, jd.RawJSONDescription)
123+
return out, nil
124+
}
125+
126+
// If we have a string representation, let json.Marshal quote it properly.
127+
if jd.StringDescription != nil {
128+
return json.Marshal(*jd.StringDescription)
129+
}
130+
131+
// No value: represent as JSON null.
132+
return []byte("null"), nil
133+
}
134+
135+
func (jd *jiraDescription) UnmarshalJSON(data []byte) error {
136+
// Reset current state
137+
jd.StringDescription = nil
138+
jd.RawJSONDescription = nil
139+
140+
trimmed := bytes.TrimSpace(data)
141+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
142+
// nothing to do (leave both fields nil/empty)
143+
return nil
144+
}
145+
146+
// If it starts with object or array token, treat as structured JSON and keep raw bytes.
147+
switch trimmed[0] {
148+
case '{', '[':
149+
// store a copy of the raw JSON
150+
jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...)
151+
return nil
152+
default:
153+
// otherwise try to unmarshal as string (expected for Jira v2)
154+
var s string
155+
if err := json.Unmarshal(trimmed, &s); err != nil {
156+
// fallback: if it's not a string but also not an object/array, keep raw bytes
157+
jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...)
158+
return nil
159+
}
160+
jd.StringDescription = &s
161+
return nil
162+
}
163+
}
164+
165+
// IsEmpty reports whether the jiraDescription contains no useful value.
166+
func (jd *jiraDescription) IsEmpty() bool {
167+
if jd == nil {
168+
return true
169+
}
170+
return jd.StringDescription == nil && len(jd.RawJSONDescription) == 0
171+
}

0 commit comments

Comments
 (0)