Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Jira integration #280

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
be080af
add config.go and tests
santihernandezc Feb 11, 2025
3f7d451
custom_fields -> fields, tidy up test
santihernandezc Feb 11, 2025
ce2e229
Add JIRA notification support as a new receiver
yuri-tceretian Feb 11, 2025
0fc7267
add token, update tests
santihernandezc Feb 12, 2025
053c3b9
fall back to default description if length exceeds max runes
santihernandezc Feb 12, 2025
6408469
WIP tests
santihernandezc Feb 12, 2025
43b8008
add authorzation via token
yuri-tceretian Feb 12, 2025
1578f74
better handling of description
yuri-tceretian Feb 12, 2025
ea0e528
cleanup
yuri-tceretian Feb 12, 2025
b52aeaa
add support for custom dedup field and search by it
yuri-tceretian Feb 12, 2025
82a8ce1
refactor building search query to a function and add tests
yuri-tceretian Feb 12, 2025
09b057f
Add enhanced Jira config parsing and comprehensive tests
yuri-tceretian Feb 13, 2025
534cc30
add safety
yuri-tceretian Feb 13, 2025
7a7d1b5
fix templates to not have so many new lines
yuri-tceretian Feb 14, 2025
8b60eb9
refactor
yuri-tceretian Feb 14, 2025
0ea6e6f
do not use error
yuri-tceretian Feb 14, 2025
cff1e7a
better typing
yuri-tceretian Feb 14, 2025
aee8576
tests
yuri-tceretian Feb 14, 2025
3d88234
escape the key to always be a valid path
yuri-tceretian Feb 14, 2025
4ca9f4c
extend getSearchJql tests
yuri-tceretian Feb 14, 2025
d9b37ab
fix transition to exit if config is empty
yuri-tceretian Feb 14, 2025
b20e66c
finish tests
yuri-tceretian Feb 14, 2025
1acedb9
trim / at the end
yuri-tceretian Feb 14, 2025
449c192
temporary use custom path for GET requests
yuri-tceretian Feb 14, 2025
380f800
lint
yuri-tceretian Feb 14, 2025
7690ab0
fix test
yuri-tceretian Feb 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions http/webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package http

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"

"github.com/grafana/alerting/receivers"
)

type ForkedSender struct {
cli receivers.WebhookSender
}

func NewForkedSender(cli receivers.WebhookSender) *ForkedSender {
return &ForkedSender{cli: cli}
}

func (f ForkedSender) SendWebhook(ctx context.Context, cmd *receivers.SendWebhookSettings) error {
if cmd.HTTPMethod != "GET" {
return f.cli.SendWebhook(ctx, cmd)
}

request, err := http.NewRequestWithContext(ctx, cmd.HTTPMethod, cmd.URL, nil)
if err != nil {
return err
}
_, err = url.Parse(cmd.URL)
if err != nil {
// Should not be possible - NewRequestWithContext should also err if the URL is bad.
return err
}

request.Header.Set("User-Agent", "Grafana")

if cmd.User != "" && cmd.Password != "" {
request.Header.Set("Authorization", GetBasicAuthHeader(cmd.User, cmd.Password))
}

for k, v := range cmd.HTTPHeader {
request.Header.Set(k, v)
}

resp, err := receivers.NewTLSClient(cmd.TLSConfig).Do(request)
if err != nil {
return redactURL(err)
}
defer func() {
_ = resp.Body.Close()
}()

body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}

if cmd.Validation != nil {
err := cmd.Validation(body, resp.StatusCode)
if err != nil {
return fmt.Errorf("webhook failed validation: %w", err)
}
}

if resp.StatusCode/100 == 2 {
return nil
}

return fmt.Errorf("webhook response status %v", resp.Status)
}

func GetBasicAuthHeader(user string, password string) string {
var userAndPass = user + ":" + password
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userAndPass))
}

func redactURL(err error) error {
var e *url.Error
if !errors.As(err, &e) {
return err
}
e.URL = "<redacted>"
return e
}
5 changes: 5 additions & 0 deletions notify/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"

"github.com/grafana/alerting/http"
"github.com/grafana/alerting/images"
"github.com/grafana/alerting/logging"
"github.com/grafana/alerting/receivers"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/grafana/alerting/receivers/discord"
"github.com/grafana/alerting/receivers/email"
"github.com/grafana/alerting/receivers/googlechat"
"github.com/grafana/alerting/receivers/jira"
"github.com/grafana/alerting/receivers/kafka"
"github.com/grafana/alerting/receivers/line"
"github.com/grafana/alerting/receivers/mqtt"
Expand Down Expand Up @@ -91,6 +93,9 @@ func BuildReceiverIntegrations(
for i, cfg := range receiver.GooglechatConfigs {
ci(i, cfg.Metadata, googlechat.New(cfg.Settings, cfg.Metadata, tmpl, nw(cfg.Metadata), img, nl(cfg.Metadata), version))
}
for i, cfg := range receiver.JiraConfigs {
ci(i, cfg.Metadata, jira.New(cfg.Settings, cfg.Metadata, tmpl, http.NewForkedSender(nw(cfg.Metadata)), nl(cfg.Metadata)))
}
for i, cfg := range receiver.KafkaConfigs {
ci(i, cfg.Metadata, kafka.New(cfg.Settings, cfg.Metadata, tmpl, nw(cfg.Metadata), img, nl(cfg.Metadata)))
}
Expand Down
2 changes: 1 addition & 1 deletion notify/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestBuildReceiverIntegrations(t *testing.T) {
require.Len(t, loggerNames, qty)
})
t.Run("should call webhook factory for each config that needs it", func(t *testing.T) {
require.Len(t, webhooks, 17) // we have 17 notifiers that support webhook
require.Len(t, webhooks, 18) // we have 18 notifiers that support webhook
})
t.Run("should call email factory for each config that needs it", func(t *testing.T) {
require.Len(t, emails, 1) // we have only email notifier that needs sender
Expand Down
8 changes: 8 additions & 0 deletions notify/receivers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/grafana/alerting/receivers/discord"
"github.com/grafana/alerting/receivers/email"
"github.com/grafana/alerting/receivers/googlechat"
"github.com/grafana/alerting/receivers/jira"
"github.com/grafana/alerting/receivers/kafka"
"github.com/grafana/alerting/receivers/line"
"github.com/grafana/alerting/receivers/mqtt"
Expand Down Expand Up @@ -188,6 +189,7 @@ type GrafanaReceiverConfig struct {
DiscordConfigs []*NotifierConfig[discord.Config]
EmailConfigs []*NotifierConfig[email.Config]
GooglechatConfigs []*NotifierConfig[googlechat.Config]
JiraConfigs []*NotifierConfig[jira.Config]
KafkaConfigs []*NotifierConfig[kafka.Config]
LineConfigs []*NotifierConfig[line.Config]
OpsgenieConfigs []*NotifierConfig[opsgenie.Config]
Expand Down Expand Up @@ -316,6 +318,12 @@ func parseNotifier(ctx context.Context, result *GrafanaReceiverConfig, receiver
return err
}
result.GooglechatConfigs = append(result.GooglechatConfigs, newNotifierConfig(receiver, cfg))
case "jira":
cfg, err := jira.NewConfig(receiver.Settings, decryptFn)
if err != nil {
return err
}
result.JiraConfigs = append(result.JiraConfigs, newNotifierConfig(receiver, cfg))
case "kafka":
cfg, err := kafka.NewConfig(receiver.Settings, decryptFn)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions notify/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/grafana/alerting/receivers/discord"
"github.com/grafana/alerting/receivers/email"
"github.com/grafana/alerting/receivers/googlechat"
"github.com/grafana/alerting/receivers/jira"
"github.com/grafana/alerting/receivers/kafka"
"github.com/grafana/alerting/receivers/line"
"github.com/grafana/alerting/receivers/mqtt"
Expand Down Expand Up @@ -139,6 +140,10 @@ var AllKnownConfigsForTesting = map[string]NotifierConfigTest{
Config: googlechat.FullValidConfigForTesting,
Secrets: googlechat.FullValidSecretsForTesting,
},
"jira": {NotifierType: "jira",
Config: jira.FullValidConfigForTesting,
Secrets: jira.FullValidSecretsForTesting,
},
"kafka": {NotifierType: "kafka",
Config: kafka.FullValidConfigForTesting,
Secrets: kafka.FullValidSecretsForTesting,
Expand Down
166 changes: 166 additions & 0 deletions receivers/jira/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package jira

import (
"encoding/json"
"fmt"
"net/url"
"regexp"

"github.com/pkg/errors"
"github.com/prometheus/common/model"

"github.com/grafana/alerting/receivers"
)

var (
DefaultSummary = `{{ template "jira.default.summary" . }}`
DefaultDescription = `{{ template "jira.default.description" . }}`
DefaultPriority = `{{ template "jira.default.priority" . }}`
)

type Config struct {
URL *url.URL

Project string
Summary string
Description string
Labels []string
Priority string
IssueType string

ReopenTransition string
ResolveTransition string
WontFixResolution string
ReopenDuration model.Duration

DedupKeyFieldName string
Fields map[string]any

User string
Password string
Token string
}

func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Config, error) {
type raw struct {
URL string `yaml:"api_url,omitempty" json:"api_url,omitempty"`
Project string `yaml:"project,omitempty" json:"project,omitempty"`
Summary string `yaml:"summary,omitempty" json:"summary,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty"`
ReopenTransition string `yaml:"reopen_transition,omitempty" json:"reopen_transition,omitempty"`
ResolveTransition string `yaml:"resolve_transition,omitempty" json:"resolve_transition,omitempty"`
WontFixResolution string `yaml:"wont_fix_resolution,omitempty" json:"wont_fix_resolution,omitempty"`
ReopenDuration string `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty"`
// allows to store group key identifier in a custom field instead of a label
DedupKeyFieldName string `yaml:"dedup_key_field,omitempty" json:"dedup_key_field,omitempty"`
Fields map[string]any `yaml:"fields,omitempty" json:"fields,omitempty"`
// This is user (email) and password - api token from https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/.
// See https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/#basic-auth-for-rest-apis
User string `yaml:"user,omitempty" json:"user,omitempty"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
// This is PAT token https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html
Token string `yaml:"api_token,omitempty" json:"api_token,omitempty"`
}

settings := raw{}
err := json.Unmarshal(jsonData, &settings)
if err != nil {
return Config{}, fmt.Errorf("failed to unmarshal settings: %w", err)
}

if settings.URL == "" {
return Config{}, errors.New("could not find api_url property in settings")
}
u, err := url.Parse(settings.URL)
if err != nil {
return Config{}, fmt.Errorf("field api_url is not a valid URL: %w", err)
}

var d model.Duration
if settings.ReopenDuration != "" {
d, err = model.ParseDuration(settings.ReopenDuration)
if err != nil {
return Config{}, fmt.Errorf("field reopen_duration is not a valid duration: %w", err)
}
}

if settings.Project == "" {
return Config{}, fmt.Errorf("missing project in jira_config")
}
if settings.IssueType == "" {
return Config{}, fmt.Errorf("missing issue_type in jira_config")
}

if settings.Summary == "" {
settings.Summary = DefaultSummary
}
if settings.Description == "" {
settings.Description = DefaultDescription
}
if settings.Priority == "" {
settings.Priority = DefaultPriority
}

settings.User = decryptFn("user", settings.User)
settings.Password = decryptFn("password", settings.Password)
settings.Token = decryptFn("api_token", settings.Token)
if settings.Token == "" && (settings.User == "" || settings.Password == "") {
return Config{}, errors.New("either token or both user and password must be set")
}
if settings.Token != "" && (settings.User != "" || settings.Password != "") {
return Config{}, errors.New("provided both token and user/password, only one is allowed at a time")
}

if settings.DedupKeyFieldName != "" {
matched, err := regexp.MatchString(`^[0-9]+$`, settings.DedupKeyFieldName)
if err != nil {
return Config{}, fmt.Errorf("failed to validate dedup_key_field: %w", err)
}
if !matched {
return Config{}, errors.New("dedup_key_field must match the format [0-9]+")
}
}

var fields map[string]any
if len(settings.Fields) > 0 {
fields = make(map[string]any, len(settings.Fields))
for k, v := range settings.Fields {
val := v
// The current UI does not support complex structures and therefore all values are strings.
// However, it's not the case in provisioning or if integration was created via API.
// Here we check if the value is string and it's a valid JSON, and then parse it and assign to the key
if strVal, ok := v.(string); ok {
var jsonData any
if json.Valid([]byte(strVal)) {
err := json.Unmarshal([]byte(strVal), &jsonData)
if err == nil {
val = jsonData
}
}
}
fields[k] = val
}
}

return Config{
URL: u,
Project: settings.Project,
Summary: settings.Summary,
Description: settings.Description,
Labels: settings.Labels,
Priority: settings.Priority,
IssueType: settings.IssueType,
ReopenTransition: settings.ReopenTransition,
ResolveTransition: settings.ResolveTransition,
WontFixResolution: settings.WontFixResolution,
ReopenDuration: d,
Fields: fields,
User: settings.User,
Password: settings.Password,
Token: settings.Token,
DedupKeyFieldName: settings.DedupKeyFieldName,
}, nil
}
Loading