diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8e955663673..9feb63a23eb 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -259,4 +259,5 @@ export interface FeatureToggles { newLogsPanel?: boolean; grafanaconThemes?: boolean; pluginsCDNSyncLoader?: boolean; + alertingJiraIntegration?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 879c3153378..f8d16934988 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a3694fa49ae..1a356254544 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d2f0868a839..49d0bbb3f33 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0cdcc21124f..94bd2a033c3 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index fff055731fe..868bf032362 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -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; } diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index 27b19c8d3fe..a172c9cd19d 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -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 = { + 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> = [ { name: 'Email', @@ -266,6 +315,7 @@ export const cloudNotifierTypes: Array> = [ httpConfigOption, ], }, + ...(config.featureToggles?.alertingJiraIntegration ? [jiraNotifier] : []), { name: 'OpsGenie', description: 'Send notifications to OpsGenie', diff --git a/public/app/features/alerting/unified/utils/receiver-form.test.ts b/public/app/features/alerting/unified/utils/receiver-form.test.ts index 3ea8089a71f..e72e40a4fe1 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.test.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.test.ts @@ -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); + }); +}); diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index 283ac3e81b1..c742837ad5d 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -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) { + // 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) { + // 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: {}, diff --git a/public/app/types/alerting.ts b/public/app/types/alerting.ts index ac598691607..3b32017319e 100644 --- a/public/app/types/alerting.ts +++ b/public/app/types/alerting.ts @@ -74,7 +74,8 @@ export type CloudNotifierType = | 'telegram' | 'sns' | 'discord' - | 'msteams'; + | 'msteams' + | 'jira'; export type NotifierType = GrafanaNotifierType | CloudNotifierType; export interface NotifierDTO { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1872daf2358..58494d7d319 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -283,6 +283,9 @@ "no-delivery-attempts": "No delivery attempts", "no-integrations": "No integrations configured", "only-firing": "Delivering <1>only firing notifications", + "receiver-summary": { + "jira": "Creates a \"{{issueType}}\" issue in the \"{{project}}\" project" + }, "telegram": { "parse-mode-warning-body": "If you use a <1>parse_mode option other than <3>None, 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." diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 0df58885897..9338082cc96 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -283,6 +283,9 @@ "no-delivery-attempts": "Ńő đęľįvęřy äŧŧęmpŧş", "no-integrations": "Ńő įʼnŧęģřäŧįőʼnş čőʼnƒįģūřęđ", "only-firing": "Đęľįvęřįʼnģ <1>őʼnľy ƒįřįʼnģ ʼnőŧįƒįčäŧįőʼnş", + "receiver-summary": { + "jira": "Cřęäŧęş ä \"{{issueType}}\" įşşūę įʼn ŧĥę \"{{project}}\" přőĵęčŧ" + }, "telegram": { "parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę, ŧřūʼ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 čĥäřäčŧęřş."