1
0
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:
Holger Waschke
2026-01-30 18:06:54 +01:00
committed by GitHub
parent 4e48a1c07b
commit f7a37c88e7
3 changed files with 185 additions and 26 deletions

View File

@@ -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)

View File

@@ -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))
})
}
}

View File

@@ -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
}