mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
101c590f34
commit
af8cab9210
@ -259,4 +259,5 @@ export interface FeatureToggles {
|
||||
newLogsPanel?: boolean;
|
||||
grafanaconThemes?: boolean;
|
||||
pluginsCDNSyncLoader?: boolean;
|
||||
alertingJiraIntegration?: boolean;
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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: {},
|
||||
|
@ -74,7 +74,8 @@ export type CloudNotifierType =
|
||||
| 'telegram'
|
||||
| 'sns'
|
||||
| 'discord'
|
||||
| 'msteams';
|
||||
| 'msteams'
|
||||
| 'jira';
|
||||
|
||||
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
|
||||
export interface NotifierDTO<T = NotifierType> {
|
||||
|
@ -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."
|
||||
|
@ -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 čĥäřäčŧęřş."
|
||||
|
Loading…
Reference in New Issue
Block a user