mirror of
https://github.com/prometheus/alertmanager.git
synced 2026-02-05 06:45:45 +01:00
Jira Integration: fix for handling jira api v3 with ADF (#4756)
Signed-off-by: Holger Waschke <waschkester@gmail.com>
This commit is contained in:
@@ -173,13 +173,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)
|
||||
|
||||
@@ -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"},
|
||||
@@ -673,7 +677,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"},
|
||||
@@ -739,7 +743,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"},
|
||||
@@ -794,7 +798,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"},
|
||||
@@ -848,7 +852,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"},
|
||||
@@ -1241,3 +1245,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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user