mirror of
https://github.com/prometheus/alertmanager.git
synced 2026-02-05 15:45:34 +01:00
Add new Jira search endpoint with new api_type option and auto detect (#4542)
* Add new Jira search endpoint with new api_type option and auto detect Signed-off-by: christianjedro <cj@cloudeteer.de> * adding recommendations, update doku, add comments Signed-off-by: christianjedro <cj@cloudeteer.de> * remove global api type (recommended by @jkroepke), add default value and remove test + implementation for empty string Signed-off-by: christianjedro <cj@cloudeteer.de> * dco Signed-off-by: christianjedro <cj@cloudeteer.de> * add better explanation Signed-off-by: christianjedro <cj@cloudeteer.de> --------- Signed-off-by: christianjedro <cj@cloudeteer.de>
This commit is contained in:
@@ -199,6 +199,7 @@ var (
|
||||
NotifierConfig: NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
APIType: "auto",
|
||||
Summary: `{{ template "jira.default.summary" . }}`,
|
||||
Description: `{{ template "jira.default.description" . }}`,
|
||||
Priority: `{{ template "jira.default.priority" . }}`,
|
||||
@@ -900,7 +901,8 @@ type JiraConfig struct {
|
||||
NotifierConfig `yaml:",inline" json:",inline"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
|
||||
APIURL *URL `yaml:"api_url,omitempty" json:"api_url,omitempty"`
|
||||
APIURL *URL `yaml:"api_url,omitempty" json:"api_url,omitempty"`
|
||||
APIType string `yaml:"api_type,omitempty" json:"api_type,omitempty"`
|
||||
|
||||
Project string `yaml:"project,omitempty" json:"project,omitempty"`
|
||||
Summary string `yaml:"summary,omitempty" json:"summary,omitempty"`
|
||||
@@ -930,6 +932,11 @@ func (c *JiraConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
if c.IssueType == "" {
|
||||
return errors.New("missing issue_type in jira_config")
|
||||
}
|
||||
if c.APIType != "auto" &&
|
||||
c.APIType != "cloud" &&
|
||||
c.APIType != "datacenter" {
|
||||
return errors.New("unknown api_type on jira_config, must be auto, cloud or datacenter")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1057,6 +1057,10 @@ The default `jira.default.description` template only works with V2.
|
||||
# Example: https://company.atlassian.net/rest/api/2/
|
||||
[ api_url: <string> | default = global.jira_api_url ]
|
||||
|
||||
# The API Type to use for search requests, can be either auto, cloud or datacenter
|
||||
# Example: cloud
|
||||
[ api_type: <string> | default = auto ]
|
||||
|
||||
# The project key where issues are created.
|
||||
project: <string>
|
||||
|
||||
|
||||
@@ -221,16 +221,11 @@ func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger,
|
||||
}
|
||||
jql.WriteString(fmt.Sprintf(`project=%q and labels=%q order by status ASC,resolutiondate DESC`, project, alertLabel))
|
||||
|
||||
requestBody := issueSearch{
|
||||
JQL: jql.String(),
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
Expand: []string{},
|
||||
}
|
||||
requestBody, searchPath := n.prepareSearchRequest(jql.String())
|
||||
|
||||
logger.Debug("search for recent issues", "jql", requestBody.JQL)
|
||||
logger.Debug("search for recent issues", "jql", jql.String())
|
||||
|
||||
responseBody, shouldRetry, err := n.doAPIRequest(ctx, http.MethodPost, "search", requestBody)
|
||||
responseBody, shouldRetry, err := n.doAPIRequestFullPath(ctx, http.MethodPost, searchPath, requestBody)
|
||||
if err != nil {
|
||||
return nil, shouldRetry, fmt.Errorf("HTTP request to JIRA API: %w", err)
|
||||
}
|
||||
@@ -253,6 +248,39 @@ func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger,
|
||||
return &issueSearchResult.Issues[0], false, nil
|
||||
}
|
||||
|
||||
// prepareSearchRequest builds the request body and search path for Jira issue search.
|
||||
//
|
||||
// Atlassian announced (see https://developer.atlassian.com/changelog/#CHANGE-2046) that
|
||||
// the legacy /search endpoint is no longer available on Jira Cloud. The replacement
|
||||
// endpoint (/rest/api/3/search/jql) is currently not available in Jira Data Center.
|
||||
//
|
||||
// Selection logic:
|
||||
// - If APIType is "datacenter", always use the v2 /search endpoint.
|
||||
// - If APIType is "cloud", or if APIType is "auto" and the host ends with
|
||||
// "atlassian.net", use the v3 /search/jql endpoint.
|
||||
// - Otherwise (APIType is "auto" without an atlassian.net host),
|
||||
// use the v2 /search endpoint.
|
||||
func (n *Notifier) prepareSearchRequest(jql string) (issueSearch, string) {
|
||||
requestBody := issueSearch{
|
||||
JQL: jql,
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
}
|
||||
|
||||
if n.conf.APIType == "datacenter" {
|
||||
searchPath := n.conf.APIURL.JoinPath("/search").String()
|
||||
return requestBody, searchPath
|
||||
}
|
||||
|
||||
if n.conf.APIType == "cloud" || n.conf.APIType == "auto" && strings.HasSuffix(n.conf.APIURL.Host, "atlassian.net") {
|
||||
searchPath := strings.Replace(n.conf.APIURL.JoinPath("/search/jql").String(), "/2", "/3", 1)
|
||||
return requestBody, searchPath
|
||||
}
|
||||
|
||||
searchPath := n.conf.APIURL.JoinPath("/search").String()
|
||||
return requestBody, searchPath
|
||||
}
|
||||
|
||||
func (n *Notifier) getIssueTransitionByName(ctx context.Context, issueKey, transitionName string) (string, bool, error) {
|
||||
path := fmt.Sprintf("issue/%s/transitions", issueKey)
|
||||
|
||||
@@ -316,6 +344,11 @@ func (n *Notifier) transitionIssue(ctx context.Context, logger *slog.Logger, i *
|
||||
}
|
||||
|
||||
func (n *Notifier) doAPIRequest(ctx context.Context, method, path string, requestBody any) ([]byte, bool, error) {
|
||||
url := n.conf.APIURL.JoinPath(path)
|
||||
return n.doAPIRequestFullPath(ctx, method, url.String(), requestBody)
|
||||
}
|
||||
|
||||
func (n *Notifier) doAPIRequestFullPath(ctx context.Context, method, path string, requestBody any) ([]byte, bool, error) {
|
||||
var body io.Reader
|
||||
if requestBody != nil {
|
||||
var buf bytes.Buffer
|
||||
@@ -326,8 +359,7 @@ func (n *Notifier) doAPIRequest(ctx context.Context, method, path string, reques
|
||||
body = &buf
|
||||
}
|
||||
|
||||
url := n.conf.APIURL.JoinPath(path)
|
||||
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
|
||||
req, err := http.NewRequestWithContext(ctx, method, path, body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -185,6 +185,138 @@ func TestSearchExistingIssue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSearchRequest(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
cfg *config.JiraConfig
|
||||
jql string
|
||||
expectedBody any
|
||||
expectedURL string
|
||||
expectedURLPath string
|
||||
}{
|
||||
{
|
||||
title: "cloud API type",
|
||||
cfg: &config.JiraConfig{
|
||||
APIType: "cloud",
|
||||
APIURL: &config.URL{
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.atlassian.net",
|
||||
Path: "/rest/api/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
jql: "project=TEST and labels=\"ALERT{123}\"",
|
||||
expectedBody: issueSearch{
|
||||
JQL: "project=TEST and labels=\"ALERT{123}\"",
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
},
|
||||
expectedURL: "https://example.atlassian.net/rest/api/3/search/jql",
|
||||
expectedURLPath: "/rest/api/2",
|
||||
},
|
||||
{
|
||||
title: "auto API type with atlassian.net url",
|
||||
cfg: &config.JiraConfig{
|
||||
APIType: "auto",
|
||||
APIURL: &config.URL{
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.atlassian.net",
|
||||
Path: "/rest/api/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
jql: "project=TEST and labels=\"ALERT{123}\"",
|
||||
expectedBody: issueSearch{
|
||||
JQL: "project=TEST and labels=\"ALERT{123}\"",
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
},
|
||||
expectedURL: "https://example.atlassian.net/rest/api/3/search/jql",
|
||||
expectedURLPath: "/rest/api/2",
|
||||
},
|
||||
{
|
||||
title: "auto API type without atlassian.net url",
|
||||
cfg: &config.JiraConfig{
|
||||
APIType: "auto",
|
||||
APIURL: &config.URL{
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "jira.example.com",
|
||||
Path: "/rest/api/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
jql: "project=TEST and labels=\"ALERT{123}\"",
|
||||
expectedBody: issueSearch{
|
||||
JQL: "project=TEST and labels=\"ALERT{123}\"",
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
},
|
||||
expectedURL: "https://jira.example.com/rest/api/2/search",
|
||||
expectedURLPath: "/rest/api/2",
|
||||
},
|
||||
{
|
||||
title: "atlassian.net URL suffix but datacenter api type",
|
||||
cfg: &config.JiraConfig{
|
||||
APIType: "datacenter",
|
||||
APIURL: &config.URL{
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.atlassian.net",
|
||||
Path: "/rest/api/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
jql: "project=TEST and labels=\"ALERT{123}\"",
|
||||
expectedBody: issueSearch{
|
||||
JQL: "project=TEST and labels=\"ALERT{123}\"",
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
},
|
||||
expectedURL: "https://example.atlassian.net/rest/api/2/search",
|
||||
expectedURLPath: "/rest/api/2",
|
||||
},
|
||||
{
|
||||
title: "datacenter API type",
|
||||
cfg: &config.JiraConfig{
|
||||
APIType: "datacenter",
|
||||
APIURL: &config.URL{
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "jira.example.com",
|
||||
Path: "/rest/api/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
jql: "project=TEST and labels=\"ALERT{123}\"",
|
||||
expectedBody: issueSearch{
|
||||
JQL: "project=TEST and labels=\"ALERT{123}\"",
|
||||
MaxResults: 2,
|
||||
Fields: []string{"status"},
|
||||
},
|
||||
expectedURL: "https://jira.example.com/rest/api/2/search",
|
||||
expectedURLPath: "/rest/api/2",
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
|
||||
notifier, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
requestBody, searchURL := notifier.prepareSearchRequest(tc.jql)
|
||||
|
||||
require.Equal(t, tc.expectedURL, searchURL)
|
||||
require.Equal(t, tc.expectedBody, requestBody)
|
||||
// Verify that the original APIURL.Path is not modified
|
||||
require.Equal(t, tc.expectedURLPath, notifier.conf.APIURL.Path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJiraTemplating(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
|
||||
@@ -55,11 +55,9 @@ type issueStatus struct {
|
||||
}
|
||||
|
||||
type issueSearch struct {
|
||||
Expand []string `json:"expand"`
|
||||
Fields []string `json:"fields"`
|
||||
JQL string `json:"jql"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
StartAt int `json:"startAt"`
|
||||
}
|
||||
|
||||
type issueSearchResult struct {
|
||||
|
||||
Reference in New Issue
Block a user