Alerting: Add Jira integration to cloud AMs (#100482)

* Add Jira integration to cloud AMs

* Add alertingJiraIntegration feature toggle for jira integration

* Update integration name to Jira Service Management

* address pr comments

* gen ff

* add project to the getReceiverDescription for jira

* Update getReceiverDescription for jira

* update text

* update texts and add required option

* Add conversion for fields jira integration to JSON format in the dto and viceversa

* add tests

* Add translation for jira receiver summary

* Add placeholder for Jira duration option

* move logic cheking integrtion type outside the conversion method

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar 2025-02-14 13:22:04 +01:00 committed by GitHub
parent 101c590f34
commit af8cab9210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 255 additions and 3 deletions

View File

@ -259,4 +259,5 @@ export interface FeatureToggles {
newLogsPanel?: boolean;
grafanaconThemes?: boolean;
pluginsCDNSyncLoader?: boolean;
alertingJiraIntegration?: boolean;
}

View File

@ -1808,6 +1808,14 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
},
{
Name: "alertingJiraIntegration",
Description: "Enables the new Jira integration for contact points in cloud alert managers.",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
FrontendOnly: true,
HideFromDocs: true,
},
}
)

View File

@ -240,3 +240,4 @@ alertingAlertmanagerExtraDedupStageStopPipeline,experimental,@grafana/alerting-s
newLogsPanel,experimental,@grafana/observability-logs,false,false,true
grafanaconThemes,experimental,@grafana/grafana-frontend-platform,false,true,false
pluginsCDNSyncLoader,experimental,@grafana/plugins-platform-backend,false,false,false
alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
240 newLogsPanel experimental @grafana/observability-logs false false true
241 grafanaconThemes experimental @grafana/grafana-frontend-platform false true false
242 pluginsCDNSyncLoader experimental @grafana/plugins-platform-backend false false false
243 alertingJiraIntegration experimental @grafana/alerting-squad false false true

View File

@ -970,4 +970,8 @@ const (
// FlagPluginsCDNSyncLoader
// Load plugins from CDN synchronously
FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader"
// FlagAlertingJiraIntegration
// Enables the new Jira integration for contact points in cloud alert managers.
FlagAlertingJiraIntegration = "alertingJiraIntegration"
)

View File

@ -274,6 +274,23 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingJiraIntegration",
"resourceVersion": "1739362088655",
"creationTimestamp": "2025-02-12T10:45:07Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-02-12 12:08:08.655259 +0000 UTC"
}
},
"spec": {
"description": "Enables the new Jira integration for contact points in cloud alert managers.",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"frontend": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "alertingListViewV2",

View File

@ -2,6 +2,7 @@ import { difference, groupBy, take, trim, upperFirst } from 'lodash';
import { ReactNode } from 'react';
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import {
AlertManagerCortexConfig,
@ -50,6 +51,17 @@ export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): Re
case 'webhook': {
return settings.url;
}
case 'jira': {
return t(
'alerting.contact-points.receiver-summary.jira',
`Creates a "{{issueType}}" issue in the "{{project}}" project`,
{
issueType: settings.issue_type,
project: settings.project,
url: settings.api_url,
}
);
}
case ReceiverTypes.OnCall: {
return receiver[RECEIVER_PLUGIN_META_KEY]?.description;
}

View File

@ -1,3 +1,4 @@
import { config } from '@grafana/runtime';
import { CloudNotifierType, NotificationChannelOption, NotifierDTO } from 'app/types';
import { option } from './notifier-types';
@ -69,6 +70,54 @@ const httpConfigOption: NotificationChannelOption = option(
}
);
const jiraNotifier: NotifierDTO<CloudNotifierType> = {
name: 'Jira',
description: 'Send notifications to Jira Service Management',
type: 'jira',
info: '',
heading: 'Jira settings',
options: [
option('api_url', 'API URL', 'The host to send Jira API requests to', { required: true }),
option('project', 'Project Key', 'The project key where issues are created', { required: true }),
option('summary', 'Summary', 'Issue summary template', { placeholder: '{{ template "jira.default.summary" . }}' }),
option('description', 'Description', 'Issue description template', {
placeholder: '{{ template "jira.default.description" . }}',
}),
option('labels', 'Labels', ' Labels to be added to the issue', { element: 'string_array' }),
option('priority', 'Priority', 'Priority of the issue', {
placeholder: '{{ template "jira.default.priority" . }}',
}),
option('issue_type', 'Issue Type', 'Type of the issue (e.g. Bug)', { required: true }),
option(
'reopen_transition',
'Reopen transition',
'Name of the workflow transition to reopen an issue. The target status should not have the category "done"'
),
option(
'resolve_transition',
'Resolve transition',
'Name of the workflow transition to resolve an issue. The target status must have the category "done"'
),
option(
'wont_fix_resolution',
"Won't fix resolution",
'If "Reopen transition" is defined, ignore issues with that resolution'
),
option(
'reopen_duration',
'Reopen duration',
'If "Reopen transition" is defined, reopen the issue when it is not older than this value (rounded down to the nearest minute)',
{
placeholder: 'Use duration format, for example: 1.2s, 100ms',
}
),
option('fields', 'Fields', 'Other issue and custom fields', {
element: 'key_value_map',
}),
httpConfigOption,
],
};
export const cloudNotifierTypes: Array<NotifierDTO<CloudNotifierType>> = [
{
name: 'Email',
@ -266,6 +315,7 @@ export const cloudNotifierTypes: Array<NotifierDTO<CloudNotifierType>> = [
httpConfigOption,
],
},
...(config.featureToggles?.alertingJiraIntegration ? [jiraNotifier] : []),
{
name: 'OpsGenie',
description: 'Send notifications to OpsGenie',

View File

@ -5,6 +5,8 @@ import { grafanaAlertNotifiers, grafanaAlertNotifiersMock } from '../mockGrafana
import { CloudChannelValues, GrafanaChannelValues, ReceiverFormValues } from '../types/receiver-form';
import {
convertJiraFieldToJson,
convertJsonToJiraField,
formValuesToCloudReceiver,
formValuesToGrafanaReceiver,
grafanaReceiverToFormValues,
@ -327,3 +329,107 @@ describe('grafanaReceiverToFormValues', () => {
expect(formValues.items[0].settings.url).toBeUndefined();
});
});
describe('convertJsonToJiraField', () => {
it('should convert nested objects to JSON strings ', () => {
const input = {
fields: {
key1: { nestedKey1: 'nestedValue1' },
key2: { nestedKey2: 'nestedValue2' },
},
};
const expectedOutput = {
fields: {
key1: '{"nestedKey1":"nestedValue1"}',
key2: '{"nestedKey2":"nestedValue2"}',
},
};
const result = convertJsonToJiraField(input);
expect(result).toEqual(expectedOutput);
});
it('should leave non-object values unchanged ', () => {
const input = {
fields: {
key1: 'value1',
key2: 123,
key3: true,
},
};
const result = convertJsonToJiraField(input);
expect(result).toEqual(input);
});
it('should handle fields object with mixed types', () => {
const input = {
fields: {
key1: 'value1',
key2: { nestedKey2: 'nestedValue2' },
key3: 123,
key4: true,
},
};
const expectedOutput = {
fields: {
key1: 'value1',
key2: '{"nestedKey2":"nestedValue2"}',
key3: 123,
key4: true,
},
};
const result = convertJsonToJiraField(input);
expect(result).toEqual(expectedOutput);
});
});
describe('convertJiraFieldToJson', () => {
it('should convert stringified objects to nested objects ', () => {
const input = {
fields: {
key1: '{"nestedKey1":{"a":2,"c":[1,2,3 ]}}',
key2: '{"nestedKey2":"nestedValue2"}',
},
};
const expectedOutput = {
fields: {
key1: { nestedKey1: { a: 2, c: [1, 2, 3] } },
key2: { nestedKey2: 'nestedValue2' },
},
};
const result = convertJiraFieldToJson(input);
expect(result).toEqual(expectedOutput);
});
it('should leave non-stringified values unchanged ', () => {
const input = {
fields: {
key1: 'value1',
key2: 123,
key3: true,
},
};
const result = convertJiraFieldToJson(input);
expect(result).toEqual(input);
});
it('should handle fields object with mixed types ', () => {
const input = {
fields: {
key1: 'value1',
key2: '{"nestedKey2":"nestedValue2"}',
key3: 123,
key4: true,
},
};
const expectedOutput = {
fields: {
key1: 'value1',
key2: { nestedKey2: 'nestedValue2' },
key3: 123,
key4: true,
},
};
const result = convertJiraFieldToJson(input);
expect(result).toEqual(expectedOutput);
});
});

View File

@ -105,11 +105,14 @@ export function formValuesToCloudReceiver(
name: values.name,
};
values.items.forEach(({ __id, type, settings, sendResolved }) => {
const channel = omitEmptyValues({
const channelWithOmmitedIdentifiers = omitEmptyValues({
...omitTemporaryIdentifiers(settings),
send_resolved: sendResolved ?? defaults.sendResolved,
});
const channel =
type === 'jira' ? convertJiraFieldToJson(channelWithOmmitedIdentifiers) : channelWithOmmitedIdentifiers;
if (!(`${type}_configs` in recv)) {
recv[`${type}_configs`] = [channel];
} else {
@ -119,6 +122,49 @@ export function formValuesToCloudReceiver(
return recv;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function convertJiraFieldToJson(object: Record<string, any>) {
// Only for cloud alert manager. Jira fields option can be a nested object. We need to convert it to JSON.
const objectCopy = structuredClone(object);
if (typeof objectCopy.fields === 'object') {
for (const [optionName, optionValue] of Object.entries(objectCopy.fields)) {
let valueForField;
try {
// eslint-disable-next-line
valueForField = JSON.parse(optionValue as string); // is a stringified object
} catch {
valueForField = optionValue; // is not a stringified object
}
objectCopy.fields[optionName] = valueForField;
}
}
return objectCopy;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function convertJsonToJiraField(object: Record<string, any>) {
// Only for cloud alert manager. Convert JSON back to nested Jira fields option.
const objectCopy = structuredClone(object);
if (typeof objectCopy.fields === 'object') {
for (const [optionName, optionValue] of Object.entries(objectCopy.fields)) {
let valueForField;
if (typeof optionValue === 'object') {
valueForField = JSON.stringify(optionValue);
} else {
valueForField = optionValue;
}
objectCopy.fields[optionName] = valueForField;
}
}
return objectCopy;
}
function cloudChannelConfigToFormChannelValues(
id: string,
type: CloudNotifierType,
@ -128,7 +174,7 @@ function cloudChannelConfigToFormChannelValues(
__id: id,
type,
settings: {
...channel,
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
},
secureFields: {},
secureSettings: {},

View File

@ -74,7 +74,8 @@ export type CloudNotifierType =
| 'telegram'
| 'sns'
| 'discord'
| 'msteams';
| 'msteams'
| 'jira';
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
export interface NotifierDTO<T = NotifierType> {

View File

@ -283,6 +283,9 @@
"no-delivery-attempts": "No delivery attempts",
"no-integrations": "No integrations configured",
"only-firing": "Delivering <1>only firing</1> notifications",
"receiver-summary": {
"jira": "Creates a \"{{issueType}}\" issue in the \"{{project}}\" project"
},
"telegram": {
"parse-mode-warning-body": "If you use a <1>parse_mode</1> option other than <3>None</3>, truncation may result in an invalid message, causing the notification to fail. For longer messages, we recommend using an alternative contact method.",
"parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters."

View File

@ -283,6 +283,9 @@
"no-delivery-attempts": "Ńő đęľįvęřy äŧŧęmpŧş",
"no-integrations": "Ńő įʼnŧęģřäŧįőʼnş čőʼnƒįģūřęđ",
"only-firing": "Đęľįvęřįʼnģ <1>őʼnľy ƒįřįʼnģ</1> ʼnőŧįƒįčäŧįőʼnş",
"receiver-summary": {
"jira": "Cřęäŧęş ä \"{{issueType}}\" įşşūę įʼn ŧĥę \"{{project}}\" přőĵęčŧ"
},
"telegram": {
"parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę</1> őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę</3>, ŧřūʼnčäŧįőʼn mäy řęşūľŧ įʼn äʼn įʼnväľįđ męşşäģę, čäūşįʼnģ ŧĥę ʼnőŧįƒįčäŧįőʼn ŧő ƒäįľ. Főř ľőʼnģęř męşşäģęş, ŵę řęčőmmęʼnđ ūşįʼnģ äʼn äľŧęřʼnäŧįvę čőʼnŧäčŧ męŧĥőđ.",
"parse-mode-warning-title": "Ŧęľęģřäm męşşäģęş äřę ľįmįŧęđ ŧő 4096 ŮŦF-8 čĥäřäčŧęřş."