From 3d958b85e2a22ee0816d324f686d3b3d70b36e66 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 1 Oct 2020 13:26:17 +0300 Subject: [PATCH] [7.x] [Actions][Jira] Set parent issue for Sub-task issue type (#78772) (#79059) --- docs/user/alerting/action-types/jira.asciidoc | 2 + x-pack/plugins/actions/README.md | 25 ++-- .../builtin_action_types/jira/api.test.ts | 32 +++++ .../server/builtin_action_types/jira/api.ts | 20 +++- .../server/builtin_action_types/jira/index.ts | 26 ++++- .../server/builtin_action_types/jira/mocks.ts | 13 +++ .../builtin_action_types/jira/schema.ts | 11 ++ .../builtin_action_types/jira/service.test.ts | 109 ++++++++++++++++++ .../builtin_action_types/jira/service.ts | 67 +++++++++++ .../server/builtin_action_types/jira/types.ts | 35 +++++- .../builtin_action_types/jira/api.ts | 38 ++++++ .../jira/jira_params.test.tsx | 3 + .../builtin_action_types/jira/jira_params.tsx | 32 ++++- .../jira/search_issues.tsx | 104 +++++++++++++++++ .../builtin_action_types/jira/translations.ts | 37 ++++++ .../builtin_action_types/jira/types.ts | 1 + .../jira/use_get_issues.tsx | 94 +++++++++++++++ .../jira/use_get_single_issue.tsx | 96 +++++++++++++++ .../actions/builtin_action_types/jira.ts | 12 +- 19 files changed, 733 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/search_issues.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issues.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_single_issue.tsx diff --git a/docs/user/alerting/action-types/jira.asciidoc b/docs/user/alerting/action-types/jira.asciidoc index 48bd6c8501b9f6..65e5ee4fc4a013 100644 --- a/docs/user/alerting/action-types/jira.asciidoc +++ b/docs/user/alerting/action-types/jira.asciidoc @@ -69,6 +69,8 @@ Priority:: The priority of the incident. Labels:: The labels of the incident. Title:: A title for the issue, used for searching the contents of the knowledge base. Description:: The details about the incident. +Parent:: The parent issue id or key. Only for `Sub-task` issue types. +Priority:: The priority of the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. [[configuring-jira]] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index af29a1d5374990..02e8e91c987d8e 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -274,12 +274,12 @@ Running the action by scheduling a task means that we will no longer have a user The following table describes the properties of the `options` object. -| Property | Description | Type | -| -------- | ------------------------------------------------------------------------------------------------------ | ------ | -| id | The id of the action you want to execute. | string | -| params | The `params` value to give the action type executor. | object | -| spaceId | The space id the action is within. | string | -| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string | +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------ | ---------------- | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | +| spaceId | The space id the action is within. | string | +| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string | | source | The source of the execution, either an HTTP request or a reference to a Saved Object. | object, optional | ## Example @@ -308,11 +308,11 @@ This api runs the action and asynchronously returns the result of running the ac The following table describes the properties of the `options` object. -| Property | Description | Type | -| -------- | ------------------------------------------------------------------------------------ | ------ | -| id | The id of the action you want to execute. | string | -| params | The `params` value to give the action type executor. | object | -| source | The source of the execution, either an HTTP request or a reference to a Saved Object.| object, optional | +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------- | ---------------- | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | +| source | The source of the execution, either an HTTP request or a reference to a Saved Object. | object, optional | ## Example @@ -330,7 +330,7 @@ const result = await actionsClient.execute({ }, source: asSavedObjectExecutionSource({ id: '573891ae-8c48-49cb-a197-0cd5ec34a88b', - type: 'alert' + type: 'alert', }), }); ``` @@ -620,6 +620,7 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla | issueType | The id of the issue type in Jira. | string _(optional)_ | | priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | | labels | An array of labels. | string[] _(optional)_ | +| parent | The parent issue id or key. Only for `Sub-task` issue types. | string _(optional)_ | | comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | #### `subActionParams (issueTypes)` diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts index 4495c37f758eea..3948a19d40daea 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts @@ -93,6 +93,7 @@ describe('api', () => { issueType: '10006', labels: ['kibana', 'elastic'], priority: 'High', + parent: null, }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -252,6 +253,7 @@ describe('api', () => { issueType: '10006', labels: ['kibana', 'elastic'], priority: 'High', + parent: null, }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -380,6 +382,36 @@ describe('api', () => { }); }); + describe('getIssues', () => { + test('it returns the issues correctly', async () => { + const res = await api.issues({ + externalService, + params: { title: 'Title test' }, + }); + expect(res).toEqual([ + { + id: '10267', + key: 'RJ-107', + title: 'Test title', + }, + ]); + }); + }); + + describe('getIssue', () => { + test('it returns the issue correctly', async () => { + const res = await api.issue({ + externalService, + params: { id: 'RJ-107' }, + }); + expect(res).toEqual({ + id: '10267', + key: 'RJ-107', + title: 'Test title', + }); + }); + }); + describe('mapping variations', () => { test('overwrite & append', async () => { mapping.set('title', { diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts index a64eb7a2036cac..559bbc047b134f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts @@ -13,8 +13,10 @@ import { Incident, GetFieldsByIssueTypeHandlerArgs, GetIssueTypesHandlerArgs, + GetIssuesHandlerArgs, PushToServiceApiParams, PushToServiceResponse, + GetIssueHandlerArgs, } from './types'; // TODO: to remove, need to support Case @@ -46,6 +48,18 @@ const getFieldsByIssueTypeHandler = async ({ return res; }; +const getIssuesHandler = async ({ externalService, params }: GetIssuesHandlerArgs) => { + const { title } = params; + const res = await externalService.getIssues(title); + return res; +}; + +const getIssueHandler = async ({ externalService, params }: GetIssueHandlerArgs) => { + const { id } = params; + const res = await externalService.getIssue(id); + return res; +}; + const pushToServiceHandler = async ({ externalService, mapping, @@ -83,8 +97,8 @@ const pushToServiceHandler = async ({ currentIncident, }); } else { - const { title, description, priority, labels, issueType } = params; - incident = { summary: title, description, priority, labels, issueType }; + const { title, description, priority, labels, issueType, parent } = params; + incident = { summary: title, description, priority, labels, issueType, parent }; } if (externalId != null) { @@ -134,4 +148,6 @@ export const api: ExternalServiceApi = { getIncident: getIncidentHandler, issueTypes: getIssueTypesHandler, fieldsByIssueType: getFieldsByIssueTypeHandler, + issues: getIssuesHandler, + issue: getIssueHandler, }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index d3346557f36841..9d6ff90c337009 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -25,6 +25,8 @@ import { JiraExecutorResultData, ExecutorSubActionGetFieldsByIssueTypeParams, ExecutorSubActionGetIssueTypesParams, + ExecutorSubActionGetIssuesParams, + ExecutorSubActionGetIssueParams, } from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; @@ -37,7 +39,13 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -const supportedSubActions: string[] = ['pushToService', 'issueTypes', 'fieldsByIssueType']; +const supportedSubActions: string[] = [ + 'pushToService', + 'issueTypes', + 'fieldsByIssueType', + 'issues', + 'issue', +]; // action type definition export function getActionType( @@ -137,5 +145,21 @@ async function executor( }); } + if (subAction === 'issues') { + const getIssuesParams = subActionParams as ExecutorSubActionGetIssuesParams; + data = await api.issues({ + externalService, + params: getIssuesParams, + }); + } + + if (subAction === 'issue') { + const getIssueParams = subActionParams as ExecutorSubActionGetIssueParams; + data = await api.issue({ + externalService, + params: getIssueParams, + }); + } + return { status: 'ok', data: data ?? {}, actionId }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts index 53f8d43ebc2d8a..b98eda799e3aad 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts @@ -61,6 +61,18 @@ const createMock = (): jest.Mocked => { defaultValue: { name: 'Medium', id: '3' }, }, })), + getIssues: jest.fn().mockImplementation(() => [ + { + id: '10267', + key: 'RJ-107', + title: 'Test title', + }, + ]), + getIssue: jest.fn().mockImplementation(() => ({ + id: '10267', + key: 'RJ-107', + title: 'Test title', + })), }; service.createComment.mockImplementationOnce(() => @@ -120,6 +132,7 @@ const executorParams: ExecutorSubActionPushParams = { labels: ['kibana', 'elastic'], priority: 'High', issueType: '10006', + parent: null, comments: [ { commentId: 'case-comment-1', diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 9fee465e72efc2..4c31691280c2c8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -44,6 +44,7 @@ export const ExecutorSubActionPushParamsSchema = schema.object({ issueType: schema.nullable(schema.string()), priority: schema.nullable(schema.string()), labels: schema.nullable(schema.arrayOf(schema.string())), + parent: schema.nullable(schema.string()), // TODO: modify later to string[] - need for support Case schema comments: schema.nullable(schema.arrayOf(CommentSchema)), ...EntityInformation, @@ -60,6 +61,8 @@ export const ExecutorSubActionGetIssueTypesParamsSchema = schema.object({}); export const ExecutorSubActionGetFieldsByIssueTypeParamsSchema = schema.object({ id: schema.string(), }); +export const ExecutorSubActionGetIssuesParamsSchema = schema.object({ title: schema.string() }); +export const ExecutorSubActionGetIssueParamsSchema = schema.object({ id: schema.string() }); export const ExecutorParamsSchema = schema.oneOf([ schema.object({ @@ -82,4 +85,12 @@ export const ExecutorParamsSchema = schema.oneOf([ subAction: schema.literal('fieldsByIssueType'), subActionParams: ExecutorSubActionGetFieldsByIssueTypeParamsSchema, }), + schema.object({ + subAction: schema.literal('issues'), + subActionParams: ExecutorSubActionGetIssuesParamsSchema, + }), + schema.object({ + subAction: schema.literal('issue'), + subActionParams: ExecutorSubActionGetIssueParamsSchema, + }), ]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 2439c507c33286..605c05e2a9f259 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -95,6 +95,14 @@ const fieldsResponse = { }, }; +const issueResponse = { + id: '10267', + key: 'RJ-107', + fields: { summary: 'Test title' }, +}; + +const issuesResponse = [issueResponse]; + describe('Jira service', () => { let service: ExternalService; @@ -219,6 +227,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: null, }, }); @@ -264,6 +273,7 @@ describe('Jira service', () => { labels: [], priority: 'High', issueType: null, + parent: null, }, }); @@ -308,6 +318,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: 'RJ-107', }, }); @@ -324,6 +335,7 @@ describe('Jira service', () => { issuetype: { id: '10006' }, labels: [], priority: { name: 'High' }, + parent: { key: 'RJ-107' }, }, }, }); @@ -344,6 +356,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: null, }, }) ).rejects.toThrow( @@ -370,6 +383,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: null, }, }); @@ -398,6 +412,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: 'RJ-107', }, }); @@ -414,6 +429,7 @@ describe('Jira service', () => { priority: { name: 'High' }, issuetype: { id: '10006' }, project: { key: 'CK' }, + parent: { key: 'RJ-107' }, }, }, }); @@ -435,6 +451,7 @@ describe('Jira service', () => { labels: [], issueType: '10006', priority: 'High', + parent: null, }, }) ).rejects.toThrow( @@ -916,4 +933,96 @@ describe('Jira service', () => { }); }); }); + + describe('getIssues', () => { + test('it should return the issues', async () => { + requestMock.mockImplementation(() => ({ + data: { + issues: issuesResponse, + }, + })); + + const res = await service.getIssues('Test title'); + + expect(res).toEqual([ + { + id: '10267', + key: 'RJ-107', + title: 'Test title', + }, + ]); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + issues: issuesResponse, + }, + })); + + await service.getIssues('Test title'); + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: `https://siem-kibana.atlassian.net/rest/api/2/search?jql=project=CK and summary ~"Test title"`, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; + }); + + expect(service.getIssues('Test title')).rejects.toThrow( + '[Action][Jira]: Unable to get issues. Error: An error has occurred. Reason: Could not get issue types' + ); + }); + }); + + describe('getIssue', () => { + test('it should return a single issue', async () => { + requestMock.mockImplementation(() => ({ + data: issueResponse, + })); + + const res = await service.getIssue('RJ-107'); + + expect(res).toEqual({ + id: '10267', + key: 'RJ-107', + title: 'Test title', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + issues: issuesResponse, + }, + })); + + await service.getIssue('RJ-107'); + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: `https://siem-kibana.atlassian.net/rest/api/2/issue/RJ-107`, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; + }); + + expect(service.getIssue('RJ-107')).rejects.toThrow( + '[Action][Jira]: Unable to get issue with id RJ-107. Error: An error has occurred. Reason: Could not get issue types' + ); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index 84b6e70d2a1002..7429c3d36d7b0b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -53,6 +53,8 @@ export const createExternalService = ( const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; + const searchUrl = `${url}/${BASE_URL}/search`; + const axiosInstance = axios.create({ auth: { username: email, password: apiToken }, }); @@ -90,6 +92,10 @@ export const createExternalService = ( fields = { ...fields, priority: { name: incident.priority } }; } + if (incident.parent) { + fields = { ...fields, parent: { key: incident.parent } }; + } + return fields; }; @@ -119,6 +125,17 @@ export const createExternalService = ( }; }, {}); + const normalizeSearchResults = ( + issues: Array<{ id: string; key: string; fields: { summary: string } }> + ) => + issues.map((issue) => ({ id: issue.id, key: issue.key, title: issue.fields?.summary ?? null })); + + const normalizeIssue = (issue: { id: string; key: string; fields: { summary: string } }) => ({ + id: issue.id, + key: issue.key, + title: issue.fields?.summary ?? null, + }); + const getIncident = async (id: string) => { try { const res = await request({ @@ -378,6 +395,54 @@ export const createExternalService = ( } }; + const getIssues = async (title: string) => { + const query = `${searchUrl}?jql=project=${projectKey} and summary ~"${title}"`; + try { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: query, + logger, + proxySettings, + }); + + return normalizeSearchResults(res.data?.issues ?? []); + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to get issues. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` + ) + ); + } + }; + + const getIssue = async (id: string) => { + const getIssueUrl = `${incidentUrl}/${id}`; + try { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: getIssueUrl, + logger, + proxySettings, + }); + + return normalizeIssue(res.data ?? {}); + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to get issue with id ${id}. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` + ) + ); + } + }; + return { getIncident, createIncident, @@ -386,5 +451,7 @@ export const createExternalService = ( getCapabilities, getIssueTypes, getFieldsByIssueType, + getIssues, + getIssue, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index 6fe7c62976f22d..050ec195d74c1d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -17,6 +17,8 @@ import { ExecutorSubActionGetCapabilitiesParamsSchema, ExecutorSubActionGetIssueTypesParamsSchema, ExecutorSubActionGetFieldsByIssueTypeParamsSchema, + ExecutorSubActionGetIssuesParamsSchema, + ExecutorSubActionGetIssueParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { IncidentConfigurationSchema } from '../case/schema'; @@ -60,7 +62,7 @@ export type ExternalServiceParams = Record; export type Incident = Pick< ExecutorSubActionPushParams, - 'description' | 'priority' | 'labels' | 'issueType' + 'description' | 'priority' | 'labels' | 'issueType' | 'parent' > & { summary: string }; export interface CreateIncidentParams { @@ -83,6 +85,13 @@ export type GetFieldsByIssueTypeResponse = Record< { allowedValues: Array<{}>; defaultValue: {} } >; +export type GetIssuesResponse = Array<{ id: string; key: string; title: string }>; +export interface GetIssueResponse { + id: string; + key: string; + title: string; +} + export interface ExternalService { getIncident: (id: string) => Promise; createIncident: (params: CreateIncidentParams) => Promise; @@ -91,6 +100,8 @@ export interface ExternalService { getCapabilities: () => Promise; getIssueTypes: () => Promise; getFieldsByIssueType: (issueTypeId: string) => Promise; + getIssues: (title: string) => Promise; + getIssue: (id: string) => Promise; } export interface PushToServiceApiParams extends ExecutorSubActionPushParams { @@ -117,6 +128,12 @@ export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf< typeof ExecutorSubActionGetFieldsByIssueTypeParamsSchema >; +export type ExecutorSubActionGetIssuesParams = TypeOf< + typeof ExecutorSubActionGetIssuesParamsSchema +>; + +export type ExecutorSubActionGetIssueParams = TypeOf; + export interface ExternalServiceApiHandlerArgs { externalService: ExternalService; mapping: Map | null; @@ -149,6 +166,16 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } +export interface GetIssuesHandlerArgs { + externalService: ExternalService; + params: ExecutorSubActionGetIssuesParams; +} + +export interface GetIssueHandlerArgs { + externalService: ExternalService; + params: ExecutorSubActionGetIssueParams; +} + export interface ExternalServiceApi { handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; @@ -157,12 +184,16 @@ export interface ExternalServiceApi { fieldsByIssueType: ( args: GetFieldsByIssueTypeHandlerArgs ) => Promise; + issues: (args: GetIssuesHandlerArgs) => Promise; + issue: (args: GetIssueHandlerArgs) => Promise; } export type JiraExecutorResultData = | PushToServiceResponse | GetIssueTypesResponse - | GetFieldsByIssueTypeResponse; + | GetFieldsByIssueTypeResponse + | GetIssuesResponse + | GetIssueResponse; export interface Fields { [key: string]: string | string[] | { name: string } | { key: string } | { id: string }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts index 86893e5b87ddf4..bc9fee042a9a67 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts @@ -42,3 +42,41 @@ export async function getFieldsByIssueType({ signal, }); } + +export async function getIssues({ + http, + signal, + connectorId, + title, +}: { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + title: string; +}): Promise> { + return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issues', subActionParams: { title } }, + }), + signal, + }); +} + +export async function getIssue({ + http, + signal, + connectorId, + id, +}: { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +}): Promise> { + return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'getIncident', subActionParams: { id } }, + }), + signal, + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index d96657f8ca4077..416f6f7b18755d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -31,8 +31,10 @@ const actionParams = { priority: 'High', savedObjectId: '123', externalId: null, + parent: null, }, }; + const connector = { secrets: {}, config: {}, @@ -237,5 +239,6 @@ describe('JiraParamsFields renders', () => { expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="labelsComboBox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="search-parent-issues"]').exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index b457dcc60a43f9..c19d2c4048665d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -19,6 +19,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { JiraActionParams } from './types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import { SearchIssues } from './search_issues'; const JiraParamsFields: React.FunctionComponent> = ({ actionParams, @@ -30,7 +31,7 @@ const JiraParamsFields: React.FunctionComponent { - const { title, description, comments, issueType, priority, labels, savedObjectId } = + const { title, description, comments, issueType, priority, labels, parent, savedObjectId } = actionParams.subActionParams || {}; const [issueTypesSelectOptions, setIssueTypesSelectOptions] = useState([]); @@ -62,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent Object.prototype.hasOwnProperty.call(fields, 'priority'), [ fields, ]); + const hasParent = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'parent'), [fields]); useEffect(() => { const options = issueTypes.map((type) => ({ @@ -179,6 +181,34 @@ const JiraParamsFields: React.FunctionComponent + {hasParent && ( + <> + + + + { + editSubActionProperty('parent', parentIssueKey); + }} + /> + + + + + + )} <> {hasPriority && ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/search_issues.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/search_issues.tsx new file mode 100644 index 00000000000000..fff606982677aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/search_issues.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useCallback, useState, memo } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { useGetIssues } from './use_get_issues'; +import { useGetSingleIssue } from './use_get_single_issue'; +import * as i18n from './translations'; + +interface Props { + selectedValue: string | null; + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + onChange: (parentIssueKey: string) => void; +} + +const SearchIssuesComponent: React.FC = ({ + selectedValue, + http, + toastNotifications, + actionConnector, + onChange, +}) => { + const [query, setQuery] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>>( + [] + ); + const [options, setOptions] = useState>>([]); + + const { isLoading: isLoadingIssues, issues } = useGetIssues({ + http, + toastNotifications, + actionConnector, + query, + }); + + const { isLoading: isLoadingSingleIssue, issue: singleIssue } = useGetSingleIssue({ + http, + toastNotifications, + actionConnector, + id: selectedValue, + }); + + useEffect(() => setOptions(issues.map((issue) => ({ label: issue.title, value: issue.key }))), [ + issues, + ]); + + useEffect(() => { + if (isLoadingSingleIssue || singleIssue == null) { + return; + } + + const singleIssueAsOptions = [{ label: singleIssue.title, value: singleIssue.key }]; + setOptions(singleIssueAsOptions); + setSelectedOptions(singleIssueAsOptions); + }, [singleIssue, isLoadingSingleIssue]); + + const onSearchChange = useCallback((searchVal: string) => { + setQuery(searchVal); + }, []); + + const onChangeComboBox = useCallback( + (changedOptions) => { + setSelectedOptions(changedOptions); + onChange(changedOptions[0].value); + }, + [onChange] + ); + + const inputPlaceholder = useMemo( + (): string => + isLoadingIssues || isLoadingSingleIssue + ? i18n.SEARCH_ISSUES_LOADING + : i18n.SEARCH_ISSUES_PLACEHOLDER, + [isLoadingIssues, isLoadingSingleIssue] + ); + + return ( + + ); +}; + +export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index bfcb72d1cb977f..2517552304d8da 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -131,3 +131,40 @@ export const FIELDS_API_ERROR = i18n.translate( defaultMessage: 'Unable to get fields', } ); + +export const ISSUES_API_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssuesMessage', + { + defaultMessage: 'Unable to get issues', + } +); + +export const GET_ISSUE_API_ERROR = (id: string) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueMessage', + { + defaultMessage: 'Unable to get issue with id {id}', + values: { id }, + } + ); + +export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxAriaLabel', + { + defaultMessage: 'Select parent issue', + } +); + +export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxPlaceholder', + { + defaultMessage: 'Select parent issue', + } +); + +export const SEARCH_ISSUES_LOADING = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesLoading', + { + defaultMessage: 'Loading...', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts index ff11199f35fea9..4c13d067913f21 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts @@ -22,6 +22,7 @@ export interface JiraActionParams { issueType: string; priority: string; labels: string[]; + parent: string | null; }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issues.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issues.tsx new file mode 100644 index 00000000000000..d6590b8c70939a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issues.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, debounce } from 'lodash/fp'; +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { getIssues } from './api'; +import * as i18n from './translations'; + +type Issues = Array<{ id: string; key: string; title: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + query: string | null; +} + +export interface UseGetIssues { + issues: Issues; + isLoading: boolean; +} + +export const useGetIssues = ({ + http, + actionConnector, + toastNotifications, + query, +}: Props): UseGetIssues => { + const [isLoading, setIsLoading] = useState(false); + const [issues, setIssues] = useState([]); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = debounce(500, async () => { + if (!actionConnector || isEmpty(query)) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + + try { + const res = await getIssues({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + title: query ?? '', + }); + + if (!didCancel) { + setIsLoading(false); + setIssues(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } + } + }); + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, toastNotifications, query]); + + return { + issues, + isLoading, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_single_issue.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_single_issue.tsx new file mode 100644 index 00000000000000..7df9834f1bd850 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_single_issue.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { getIssue } from './api'; +import * as i18n from './translations'; + +interface Issue { + id: string; + key: string; + title: string; +} + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + id: string | null; + actionConnector?: ActionConnector; +} + +export interface UseGetSingleIssue { + issue: Issue | null; + isLoading: boolean; +} + +export const useGetSingleIssue = ({ + http, + toastNotifications, + actionConnector, + id, +}: Props): UseGetSingleIssue => { + const [isLoading, setIsLoading] = useState(false); + const [issue, setIssue] = useState(null); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!actionConnector || !id) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + try { + const res = await getIssue({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + id, + }); + + if (!didCancel) { + setIsLoading(false); + setIssue(res.data ?? {}); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, id, toastNotifications]); + + return { + isLoading, + issue, + }; +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 84fad699525a9c..1a56a9dfcb4dbf 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -333,7 +333,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); @@ -351,7 +351,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); @@ -369,7 +369,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); @@ -392,7 +392,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); @@ -420,7 +420,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); @@ -448,7 +448,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); });