1
0
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:
Chris Jedro
2025-09-17 02:32:47 +02:00
committed by GitHub
parent 117eb83c67
commit cfd70e5047
5 changed files with 186 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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