diff --git a/integrations/jira/icon.svg b/integrations/jira/icon.svg new file mode 100644 index 00000000000..4170bcbd83a --- /dev/null +++ b/integrations/jira/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/integrations/jira/integration.definition.ts b/integrations/jira/integration.definition.ts new file mode 100644 index 00000000000..491a3c53b3e --- /dev/null +++ b/integrations/jira/integration.definition.ts @@ -0,0 +1,23 @@ +import { IntegrationDefinition } from '@botpress/sdk' + +import { configuration, states, user, channels, actions } from './src/definitions' + +export default new IntegrationDefinition({ + name: 'jira', + title: 'Jira', + description: + 'This integration allows you to work with your Jira workspace, users, projects, and workflow transitions.', + version: '0.3.0', + readme: 'readme.md', + icon: 'icon.svg', + configuration, + channels, + user, + actions, + events: {}, + states, + attributes: { + category: 'Project Management', + repo: 'botpress', + }, +}) diff --git a/integrations/jira/package.json b/integrations/jira/package.json new file mode 100644 index 00000000000..7692feeddd5 --- /dev/null +++ b/integrations/jira/package.json @@ -0,0 +1,24 @@ +{ + "name": "@botpresshub/jirasoftware", + "description": "Work with your Jira workspace, users, projects, and workflow transitions from Botpress.", + "scripts": { + "build": "bp add -y && bp build", + "check:type": "tsc --noEmit", + "check:bplint": "bp lint", + "test": "vitest --run" + }, + "keywords": [], + "private": true, + "author": "", + "license": "MIT", + "dependencies": { + "@botpress/client": "workspace:*", + "@botpress/sdk": "workspace:*", + "jira.js": "^2.19.1" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@botpress/common": "workspace:*", + "@types/node": "^22.16.4" + } +} diff --git a/integrations/jira/readme.md b/integrations/jira/readme.md new file mode 100644 index 00000000000..d5a759fdd8a --- /dev/null +++ b/integrations/jira/readme.md @@ -0,0 +1,51 @@ +# Botpress Jira Software Integration + +This integration allows you to connect your Botpress chatbot with Jira Software, a popular platform for project management and issue tracking. With this integration, you can search, create, update, and transition issues, list projects, find Jira users, and post issue comments from your chatbot. + +To set up the integration, you will need to provide your **host**, **email**, and **API token** credentials. Once the integration is set up, you can use the built-in actions to manage issues and projects, and use the issue comments channel to post comments. + +For more detailed instructions on how to set up and use the Botpress Jira Software integration, please refer to our documentation. + +## Prerequisites + +Before enabling the Botpress Jira Software Integration, please ensure that you have the following: + +- A Botpress cloud account. +- Access to a Jira Software account. +- API token generated from your Jira Software account. + +## Enable Integration + +To enable the Jira Software integration in Botpress, follow these steps: + +- Access your Botpress admin panel. +- Navigate to the “Integrations” section. +- Locate the Jira Software integration and click on "Install Integration". +- Provide the required API token, host, and email configuration details. +- Save the configuration. + +## Usage + +Once the integration is enabled, you can start using Jira from your Botpress chatbot. The integration offers the following actions: + +- **Issues**: `searchIssues` (JQL with cursor pagination), `countIssues`, `pickIssue`, `getIssue`, `newIssue`, `newIssues` (batch up to 50), `updateIssue`, `assignIssue`, `deleteIssue`, `getIssueTransitions`, `transitionIssue` +- **Projects**: `listProjects`, `listProjectStatuses`, `listIssueTypes` (per project) +- **Users**: `findUser`, `findAllUsers` + +To post comments to Jira issues, send text messages through the `issueComments` channel with the target `issueKey` conversation tag. + +To move an issue through its workflow, first call `getIssueTransitions` for that issue to discover valid transition IDs, then pass one to `transitionIssue`. + +> Issue search uses Atlassian's `POST /rest/api/3/search/jql` endpoint (replacing the deprecated `/rest/api/3/search` retired in May 2025). Pagination is cursor-based — pass the `nextToken` from the previous response to fetch the next page. Use `countIssues` if you only need a total. + +For more detailed information and examples, refer to the Botpress documentation or the Jira Software documentation for configuring the integration. + +## Limitations + +Remember that Jira's administrative limits also apply to the use of the API. + +## Contributing + +Contributions are welcome! If you encounter any issues or have suggestions for improvement, please submit them via the project’s issue tracker. Pull requests are also appreciated. + +Enjoy the seamless project management integration between Botpress and Jira Software! diff --git a/integrations/jira/src/actions/assign-issue.ts b/integrations/jira/src/actions/assign-issue.ts new file mode 100644 index 00000000000..e728b104675 --- /dev/null +++ b/integrations/jira/src/actions/assign-issue.ts @@ -0,0 +1,30 @@ +import { RuntimeError } from '@botpress/sdk' + +import { assignIssueInputSchema, assignIssueOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const assignIssue: Implementation['actions']['assignIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = assignIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + await jiraClient.assignIssue(validatedInput.issueKey, validatedInput.accountId) + logger + .forBot() + .info( + `Successful - Assign Issue - ${validatedInput.issueKey} ${ + validatedInput.accountId === null ? 'unassigned' : `→ ${validatedInput.accountId}` + }` + ) + return assignIssueOutputSchema.parse({ + issueKey: validatedInput.issueKey, + accountId: validatedInput.accountId, + }) + } catch (error) { + logger.forBot().debug(`'Assign Issue' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to assign issue ${validatedInput.issueKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/count-issues.ts b/integrations/jira/src/actions/count-issues.ts new file mode 100644 index 00000000000..7ef329ae60e --- /dev/null +++ b/integrations/jira/src/actions/count-issues.ts @@ -0,0 +1,18 @@ +import { countIssuesInputSchema, countIssuesOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' + +export const countIssues: Implementation['actions']['countIssues'] = async ({ ctx, input, logger }) => { + const validatedInput = countIssuesInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const count = await jiraClient.countIssues(validatedInput.jql) + logger.forBot().info(`Successful - Count Issues - ${count} match`) + return countIssuesOutputSchema.parse({ count }) + } catch (error) { + logger.forBot().debug(`'Count Issues' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to count issues', error) + } +} diff --git a/integrations/jira/src/actions/delete-issue.ts b/integrations/jira/src/actions/delete-issue.ts new file mode 100644 index 00000000000..1c27c2b1134 --- /dev/null +++ b/integrations/jira/src/actions/delete-issue.ts @@ -0,0 +1,21 @@ +import { RuntimeError } from '@botpress/sdk' + +import { deleteIssueInputSchema, deleteIssueOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const deleteIssue: Implementation['actions']['deleteIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = deleteIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + await jiraClient.deleteIssue(validatedInput.issueKey, validatedInput.deleteSubtasks ?? false) + logger.forBot().info(`Successful - Delete Issue - ${validatedInput.issueKey}`) + return deleteIssueOutputSchema.parse({ issueKey: validatedInput.issueKey }) + } catch (error) { + logger.forBot().debug(`'Delete Issue' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to delete issue ${validatedInput.issueKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/find-all-users.ts b/integrations/jira/src/actions/find-all-users.ts new file mode 100644 index 00000000000..3a9935f6a3c --- /dev/null +++ b/integrations/jira/src/actions/find-all-users.ts @@ -0,0 +1,26 @@ +import { findAllUsersInputSchema, findAllUsersOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' + +export const findAllUsers: Implementation['actions']['findAllUsers'] = async ({ ctx, input, logger }) => { + const validatedInput = findAllUsersInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + const addParams = { + startAt: validatedInput.startAt, + maxResults: validatedInput.maxResults, + } + try { + const response = await jiraClient.findAllUsers(addParams) + logger.forBot().info(`Successful - Find All User - Total Users ${response.length}`) + return findAllUsersOutputSchema.parse({ + users: response.map((user) => ({ + ...user, + active: user.active ?? false, + })), + }) + } catch (error) { + logger.forBot().debug(`'Find All User' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to find all users', error) + } +} diff --git a/integrations/jira/src/actions/find-user.ts b/integrations/jira/src/actions/find-user.ts new file mode 100644 index 00000000000..839e9e8f650 --- /dev/null +++ b/integrations/jira/src/actions/find-user.ts @@ -0,0 +1,24 @@ +import { RuntimeError } from '@botpress/sdk' +import { findUserInputSchema, findUserOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const findUser: Implementation['actions']['findUser'] = async ({ ctx, input, logger }) => { + const validatedInput = findUserInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + try { + const response = await jiraClient.findUser(validatedInput.query) + logger + .forBot() + .info(`Successful - Find User - ${response?.displayName || 'Unknown'} - with query: ${validatedInput.query}`) + return findUserOutputSchema.parse({ + ...response, + active: response.active ?? false, + }) + } catch (error) { + logger.forBot().debug(`'Find User' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to find user: ${message}`) + } +} diff --git a/integrations/jira/src/actions/get-issue-transitions.ts b/integrations/jira/src/actions/get-issue-transitions.ts new file mode 100644 index 00000000000..0eb18e638e6 --- /dev/null +++ b/integrations/jira/src/actions/get-issue-transitions.ts @@ -0,0 +1,37 @@ +import { RuntimeError } from '@botpress/sdk' +import { getIssueTransitionsInputSchema, getIssueTransitionsOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const getIssueTransitions: Implementation['actions']['getIssueTransitions'] = async ({ ctx, input, logger }) => { + const validatedInput = getIssueTransitionsInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const response = await jiraClient.getIssueTransitions({ + issueIdOrKey: validatedInput.issueKey, + includeUnavailableTransitions: true, + }) + + const transitions = (response.transitions ?? []).flatMap((t) => { + if (!t.id) return [] + return [ + { + id: t.id, + name: t.name, + toStatus: t.to?.name, + toStatusCategory: t.to?.statusCategory?.name, + isAvailable: t.isAvailable, + hasScreen: t.hasScreen, + }, + ] + }) + + logger.forBot().info(`Successful - Get Issue Transitions - ${transitions.length} for ${validatedInput.issueKey}`) + return getIssueTransitionsOutputSchema.parse({ transitions }) + } catch (error) { + logger.forBot().debug(`'Get Issue Transitions' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to get transitions for ${validatedInput.issueKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/get-issue.ts b/integrations/jira/src/actions/get-issue.ts new file mode 100644 index 00000000000..13f191c2a08 --- /dev/null +++ b/integrations/jira/src/actions/get-issue.ts @@ -0,0 +1,24 @@ +import { RuntimeError } from '@botpress/sdk' + +import { getIssueInputSchema, getIssueOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { ISSUE_SEARCH_FIELDS, flattenIssue, getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const getIssue: Implementation['actions']['getIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = getIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const response = await jiraClient.getIssue({ + issueIdOrKey: validatedInput.issueKey, + fields: ISSUE_SEARCH_FIELDS, + }) + logger.forBot().info(`Successful - Get Issue - ${response.key}`) + return getIssueOutputSchema.parse(flattenIssue(response, ctx.configuration.host)) + } catch (error) { + logger.forBot().debug(`'Get Issue' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to get issue ${validatedInput.issueKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/index.ts b/integrations/jira/src/actions/index.ts new file mode 100644 index 00000000000..2327edbbf98 --- /dev/null +++ b/integrations/jira/src/actions/index.ts @@ -0,0 +1,35 @@ +import { assignIssue } from './assign-issue' +import { countIssues } from './count-issues' +import { deleteIssue } from './delete-issue' +import { findAllUsers } from './find-all-users' +import { findUser } from './find-user' +import { getIssue } from './get-issue' +import { getIssueTransitions } from './get-issue-transitions' +import { listIssueTypes } from './list-issue-types' +import { listProjectStatuses } from './list-project-statuses' +import { listProjects } from './list-projects' +import { newIssue } from './new-issue' +import { newIssues } from './new-issues' +import { pickIssue } from './pick-issue' +import { searchIssues } from './search-issues' +import { transitionIssue } from './transition-issue' +import { updateIssue } from './update-issue' + +export default { + findUser, + newIssue, + newIssues, + updateIssue, + assignIssue, + deleteIssue, + findAllUsers, + searchIssues, + countIssues, + pickIssue, + getIssue, + listProjects, + getIssueTransitions, + transitionIssue, + listIssueTypes, + listProjectStatuses, +} diff --git a/integrations/jira/src/actions/list-issue-types.ts b/integrations/jira/src/actions/list-issue-types.ts new file mode 100644 index 00000000000..0a45c6f6a1d --- /dev/null +++ b/integrations/jira/src/actions/list-issue-types.ts @@ -0,0 +1,28 @@ +import { RuntimeError } from '@botpress/sdk' + +import { listIssueTypesInputSchema, listIssueTypesOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const listIssueTypes: Implementation['actions']['listIssueTypes'] = async ({ ctx, input, logger }) => { + const validatedInput = listIssueTypesInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const response = await jiraClient.listIssueTypesForProject(validatedInput.projectKey) + const items = (response.issueTypes ?? []).map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + subtask: t.subtask, + hierarchyLevel: t.hierarchyLevel, + })) + logger.forBot().info(`Successful - List Issue Types - ${items.length} for ${validatedInput.projectKey}`) + return listIssueTypesOutputSchema.parse({ items }) + } catch (error) { + logger.forBot().debug(`'List Issue Types' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to list issue types for project ${validatedInput.projectKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/list-project-statuses.ts b/integrations/jira/src/actions/list-project-statuses.ts new file mode 100644 index 00000000000..5c3d53f1107 --- /dev/null +++ b/integrations/jira/src/actions/list-project-statuses.ts @@ -0,0 +1,28 @@ +import { RuntimeError } from '@botpress/sdk' +import { listProjectStatusesInputSchema, listProjectStatusesOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' +import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' + +export const listProjectStatuses: Implementation['actions']['listProjectStatuses'] = async ({ ctx, input, logger }) => { + const validatedInput = listProjectStatusesInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const response = await jiraClient.listProjectStatuses(validatedInput.projectKey) + const items = response.flatMap((typeWithStatus) => + (typeWithStatus.statuses ?? []).map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + category: s.statusCategory?.name, + issueType: typeWithStatus.name, + })) + ) + logger.forBot().info(`Successful - List Project Statuses - ${items.length} for ${validatedInput.projectKey}`) + return listProjectStatusesOutputSchema.parse({ items }) + } catch (error) { + logger.forBot().debug(`'List Project Statuses' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError(`Failed to list statuses for project ${validatedInput.projectKey}: ${message}`) + } +} diff --git a/integrations/jira/src/actions/list-projects.ts b/integrations/jira/src/actions/list-projects.ts new file mode 100644 index 00000000000..fe51d9c63fa --- /dev/null +++ b/integrations/jira/src/actions/list-projects.ts @@ -0,0 +1,57 @@ +import { RuntimeError } from '@botpress/sdk' + +import { listProjectsInputSchema, listProjectsOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' + +const DEFAULT_MAX_RESULTS = 50 +const HARD_MAX_RESULTS = 100 + +export const listProjects: Implementation['actions']['listProjects'] = async ({ ctx, input, logger }) => { + const validatedInput = listProjectsInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + const startAt = validatedInput.nextToken ? Number(validatedInput.nextToken) : 0 + const maxResults = Math.min(validatedInput.maxResults ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS) + + if (!Number.isFinite(startAt) || startAt < 0) { + throw new RuntimeError('Invalid nextToken: expected a non-negative integer string') + } + + try { + const response = await jiraClient.listProjects({ + startAt, + maxResults, + query: validatedInput.query, + expand: 'description,lead', + }) + + const projects = response.values ?? [] + const items = projects.flatMap((p) => { + if (!p.id || !p.key) return [] + return [ + { + id: p.id, + key: p.key, + name: p.name, + projectTypeKey: p.projectTypeKey, + description: p.description, + leadAccountId: p.lead?.accountId, + leadName: p.lead?.displayName, + }, + ] + }) + + const isLast = response.isLast ?? projects.length < maxResults + const consumed = startAt + projects.length + const nextToken = !isLast && projects.length > 0 ? String(consumed) : undefined + + logger.forBot().info(`Successful - List Projects - ${items.length} returned`) + + return listProjectsOutputSchema.parse({ items, nextToken }) + } catch (error) { + logger.forBot().debug(`'List Projects' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to list projects', error) + } +} diff --git a/integrations/jira/src/actions/new-issue.ts b/integrations/jira/src/actions/new-issue.ts new file mode 100644 index 00000000000..fe0788eccdd --- /dev/null +++ b/integrations/jira/src/actions/new-issue.ts @@ -0,0 +1,57 @@ +import { Version3Parameters } from 'jira.js' +import { newIssueInputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { + buildIssueRuntimeError, + getClient, + resolveIssueTypeIds, + serializeErrorForLog, + textToAdfDocument, +} from '../utils' + +export const newIssue: Implementation['actions']['newIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = newIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const issueTypeIds = await resolveIssueTypeIds(jiraClient, [ + { + issueType: validatedInput.issueType, + projectKey: validatedInput.projectKey, + }, + ]) + const issueTypeId = issueTypeIds.get(`${validatedInput.projectKey}::${validatedInput.issueType}`)! + + const fields: Version3Parameters.CreateIssue['fields'] = { + summary: validatedInput.summary, + issuetype: { + id: issueTypeId, + }, + project: { + key: validatedInput.projectKey, + }, + } + + if (validatedInput.description !== undefined) { + fields.description = textToAdfDocument(validatedInput.description) + } + if (validatedInput.parentKey !== undefined) { + fields.parent = { key: validatedInput.parentKey } + } + if (validatedInput.assigneeId !== undefined) { + fields.assignee = { accountId: validatedInput.assigneeId } + } + + const issue: Version3Parameters.CreateIssue = { + fields, + } + + const response = await jiraClient.newIssue(issue) + logger.forBot().info(`Successful - New Issue - ${response}`) + return { issueKey: response } + } catch (error) { + logger.forBot().debug(`'New Issue' exception ${serializeErrorForLog(error)}`) + throw buildIssueRuntimeError(error, validatedInput.issueType, validatedInput.projectKey, 'create') + } +} diff --git a/integrations/jira/src/actions/new-issues.ts b/integrations/jira/src/actions/new-issues.ts new file mode 100644 index 00000000000..473e447076b --- /dev/null +++ b/integrations/jira/src/actions/new-issues.ts @@ -0,0 +1,54 @@ +import { RuntimeError } from '@botpress/sdk' +import type { Version3Models } from 'jira.js' +import { newIssuesInputSchema, newIssuesOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' +import { buildRuntimeError, getClient, resolveIssueTypeIds, serializeErrorForLog, textToAdfDocument } from '../utils' + +type IssueInput = Version3Models.IssueUpdateDetails + +export const newIssues: Implementation['actions']['newIssues'] = async ({ ctx, input, logger }) => { + const validatedInput = newIssuesInputSchema.parse(input) + if (validatedInput.issues.length === 0) { + throw new RuntimeError('At least one issue must be provided') + } + if (validatedInput.issues.length > 50) { + throw new RuntimeError(`Jira allows up to 50 issues per batch; received ${validatedInput.issues.length}`) + } + const jiraClient = getClient(ctx.configuration) + + try { + const issueTypeIds = await resolveIssueTypeIds(jiraClient, validatedInput.issues) + + const issueUpdates: IssueInput[] = validatedInput.issues.map((i) => { + const issueTypeId = issueTypeIds.get(`${i.projectKey}::${i.issueType}`)! + const fields: NonNullable = { + summary: i.summary, + issuetype: { id: issueTypeId }, + project: { key: i.projectKey }, + } + if (i.description !== undefined) fields.description = textToAdfDocument(i.description) + if (i.parentKey !== undefined) fields.parent = { key: i.parentKey } + if (i.assigneeId !== undefined) fields.assignee = { accountId: i.assigneeId } + return { fields } + }) + + const response = await jiraClient.newIssues({ issueUpdates }) + const created = (response.issues ?? []).map((c) => ({ issueKey: c.key })) + const errors = (response.errors ?? []).map((e) => { + const fieldErrors = (e.elementErrors?.errors ?? {}) as Record + const detail = [ + ...(e.elementErrors?.errorMessages ?? []), + ...Object.entries(fieldErrors).map(([k, v]) => `${k}: ${v}`), + ].join('; ') + return { + index: e.failedElementNumber, + message: detail.length > 0 ? detail : `HTTP ${e.status ?? 'unknown'}`, + } + }) + logger.forBot().info(`Successful - New Issues - ${created.length} created, ${errors.length} failed`) + return newIssuesOutputSchema.parse({ created, errors }) + } catch (error) { + logger.forBot().debug(`'New Issues' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to create issues', error) + } +} diff --git a/integrations/jira/src/actions/pick-issue.ts b/integrations/jira/src/actions/pick-issue.ts new file mode 100644 index 00000000000..988d99d0a70 --- /dev/null +++ b/integrations/jira/src/actions/pick-issue.ts @@ -0,0 +1,29 @@ +import { pickIssueInputSchema, pickIssueOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' + +export const pickIssue: Implementation['actions']['pickIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = pickIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + const response = await jiraClient.pickIssue(validatedInput.query, validatedInput.currentJql) + const matches: Array<{ issueKey: string; summary?: string; section?: string }> = [] + for (const section of response.sections ?? []) { + for (const issue of section.issues ?? []) { + if (!issue.key) continue + matches.push({ + issueKey: issue.key, + summary: issue.summaryText ?? issue.summary, + section: section.label, + }) + } + } + logger.forBot().info(`Successful - Pick Issue - ${matches.length} matches for "${validatedInput.query}"`) + return pickIssueOutputSchema.parse({ matches }) + } catch (error) { + logger.forBot().debug(`'Pick Issue' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to pick issue', error) + } +} diff --git a/integrations/jira/src/actions/search-issues.ts b/integrations/jira/src/actions/search-issues.ts new file mode 100644 index 00000000000..2d3cafa5eae --- /dev/null +++ b/integrations/jira/src/actions/search-issues.ts @@ -0,0 +1,42 @@ +import { searchIssuesInputSchema, searchIssuesOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { ISSUE_SEARCH_FIELDS, buildRuntimeError, flattenIssue, getClient, serializeErrorForLog } from '../utils' + +const DEFAULT_MAX_RESULTS = 50 +const HARD_MAX_RESULTS = 100 +const DEFAULT_JQL = 'order by created DESC' + +export const searchIssues: Implementation['actions']['searchIssues'] = async ({ ctx, input, logger }) => { + const validatedInput = searchIssuesInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + const maxResults = Math.min(validatedInput.maxResults ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS) + const jql = validatedInput.jql && validatedInput.jql.trim().length > 0 ? validatedInput.jql : DEFAULT_JQL + + try { + const response = await jiraClient.searchIssues({ + jql, + maxResults, + fields: ISSUE_SEARCH_FIELDS, + ...(validatedInput.nextToken !== undefined && { nextPageToken: validatedInput.nextToken }), + }) + + const issues = response.issues ?? [] + const items = issues.map((issue) => flattenIssue(issue, ctx.configuration.host)) + const nextToken = response.isLast ? undefined : response.nextPageToken + + if (response.isLast === false && response.nextPageToken === undefined && items.length > 0) { + logger + .forBot() + .warn('Search Issues: Jira reported isLast=false but returned no nextPageToken; pagination may be incomplete') + } + + logger.forBot().info(`Successful - Search Issues - ${items.length} returned${nextToken ? ' (more available)' : ''}`) + + return searchIssuesOutputSchema.parse({ items, nextToken }) + } catch (error) { + logger.forBot().debug(`'Search Issues' exception ${serializeErrorForLog(error)}`) + throw buildRuntimeError('Failed to search issues', error) + } +} diff --git a/integrations/jira/src/actions/transition-issue.ts b/integrations/jira/src/actions/transition-issue.ts new file mode 100644 index 00000000000..3e73c681b4b --- /dev/null +++ b/integrations/jira/src/actions/transition-issue.ts @@ -0,0 +1,38 @@ +import { RuntimeError } from '@botpress/sdk' + +import { transitionIssueInputSchema, transitionIssueOutputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { getClient, getErrorMessage, serializeErrorForLog, textToAdfDocument } from '../utils' + +export const transitionIssue: Implementation['actions']['transitionIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = transitionIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + + try { + await jiraClient.transitionIssue({ + issueIdOrKey: validatedInput.issueKey, + transition: { id: validatedInput.transitionId }, + ...(validatedInput.comment !== undefined && { + update: { + comment: [{ add: { body: textToAdfDocument(validatedInput.comment) } }], + }, + }), + }) + + logger + .forBot() + .info(`Successful - Transition Issue - ${validatedInput.issueKey} via transition ${validatedInput.transitionId}`) + + return transitionIssueOutputSchema.parse({ + issueKey: validatedInput.issueKey, + transitionId: validatedInput.transitionId, + }) + } catch (error) { + logger.forBot().debug(`'Transition Issue' exception ${serializeErrorForLog(error)}`) + const message = getErrorMessage(error) + throw new RuntimeError( + `Failed to transition issue ${validatedInput.issueKey}: ${message}. Use getIssueTransitions to discover valid transition IDs.` + ) + } +} diff --git a/integrations/jira/src/actions/update-issue.ts b/integrations/jira/src/actions/update-issue.ts new file mode 100644 index 00000000000..b068f96b692 --- /dev/null +++ b/integrations/jira/src/actions/update-issue.ts @@ -0,0 +1,64 @@ +import { RuntimeError } from '@botpress/sdk' +import { Version3Parameters } from 'jira.js' +import { updateIssueInputSchema } from '../misc/custom-schemas' +import type { Implementation } from '../misc/types' + +import { + buildIssueRuntimeError, + getClient, + resolveIssueTypeIds, + serializeErrorForLog, + textToAdfDocument, +} from '../utils' + +export const updateIssue: Implementation['actions']['updateIssue'] = async ({ ctx, input, logger }) => { + const validatedInput = updateIssueInputSchema.parse(input) + const jiraClient = getClient(ctx.configuration) + const fields: NonNullable = {} + + if (validatedInput.summary !== undefined) { + fields.summary = validatedInput.summary + } + if (validatedInput.description !== undefined) { + fields.description = textToAdfDocument(validatedInput.description) + } + if (validatedInput.projectKey !== undefined) { + fields.project = { key: validatedInput.projectKey } + } + if (validatedInput.parentKey !== undefined) { + fields.parent = { key: validatedInput.parentKey } + } + if (validatedInput.assigneeId !== undefined) { + fields.assignee = { accountId: validatedInput.assigneeId } + } + + const issueUpdate: Version3Parameters.EditIssue = { + issueIdOrKey: validatedInput.issueKey, + fields, + } + try { + if (validatedInput.issueType !== undefined) { + if (validatedInput.projectKey === undefined) { + throw new RuntimeError('projectKey is required when updating issueType') + } + + const issueTypeIds = await resolveIssueTypeIds(jiraClient, [ + { + issueType: validatedInput.issueType, + projectKey: validatedInput.projectKey, + }, + ]) + const issueTypeId = issueTypeIds.get(`${validatedInput.projectKey}::${validatedInput.issueType}`) + if (issueTypeId) { + fields.issuetype = { id: issueTypeId } + } + } + + await jiraClient.updateIssue(issueUpdate) + logger.forBot().info(`Successful - Update Issue - ${validatedInput.issueKey}`) + return { issueKey: validatedInput.issueKey } + } catch (error) { + logger.forBot().debug(`'Update Issue' exception ${serializeErrorForLog(error)}`) + throw buildIssueRuntimeError(error, validatedInput.issueType, validatedInput.projectKey, 'update') + } +} diff --git a/integrations/jira/src/client/index.ts b/integrations/jira/src/client/index.ts new file mode 100644 index 00000000000..1c449c5a744 --- /dev/null +++ b/integrations/jira/src/client/index.ts @@ -0,0 +1,191 @@ +import { Version3Client, Version3Models, Version3Parameters } from 'jira.js' +import { textToAdfDocument } from '../misc/adf' + +export type EnhancedSearchRequest = { + jql: string + nextPageToken?: string + fields?: string[] + fieldsByKeys?: boolean + expand?: string + properties?: string[] + maxResults?: number + reconcileIssues?: number[] +} + +export type EnhancedSearchResponse = { + issues?: Version3Models.Issue[] + nextPageToken?: string + isLast?: boolean +} + +export type CreateMetaIssueTypesPage = { + startAt?: number + maxResults?: number + total?: number + issueTypes?: Array<{ + id?: string + name?: string + description?: string + subtask?: boolean + hierarchyLevel?: number + }> +} + +export type IssuePickerResponse = { + sections?: Array<{ + id?: string + label?: string + sub?: string + issues?: Array<{ + key?: string + keyHtml?: string + img?: string + summary?: string + summaryText?: string + }> + }> +} + +export class JiraApi { + private _client: Version3Client + + public constructor(host: string, email: string, apiToken: string) { + this._client = new Version3Client({ + host, + authentication: { + basic: { + email, + apiToken, + }, + }, + newErrorHandling: true, + }) + } + + public async newIssue(issue: Version3Parameters.CreateIssue): Promise { + const { key } = await this._client.issues.createIssue(issue) + return key + } + + public async newIssues(payload: Version3Parameters.CreateIssues): Promise { + return await this._client.issues.createIssues(payload) + } + + public async updateIssue(issueUpdate: Version3Parameters.EditIssue): Promise { + await this._client.issues.editIssue(issueUpdate) + } + + public async getCurrentUser(): Promise { + return await this._client.myself.getCurrentUser() + } + + public async assignIssue(issueIdOrKey: string, accountId: string | null): Promise { + await this._client.sendRequest( + { + url: `/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/assignee`, + method: 'PUT', + data: { accountId }, + }, + undefined as never + ) + } + + public async deleteIssue(issueIdOrKey: string, deleteSubtasks: boolean = false): Promise { + await this._client.issues.deleteIssue({ + issueIdOrKey, + deleteSubtasks: String(deleteSubtasks) as 'true' | 'false', + }) + } + + public async getIssue(params: Version3Parameters.GetIssue): Promise { + return await this._client.issues.getIssue(params) + } + + public async searchIssues(params: EnhancedSearchRequest): Promise { + return await this._client.sendRequest( + { + url: '/rest/api/3/search/jql', + method: 'POST', + data: params, + }, + undefined as never + ) + } + + public async getIssueTransitions(params: Version3Parameters.GetTransitions): Promise { + return await this._client.issues.getTransitions(params) + } + + public async transitionIssue(params: Version3Parameters.DoTransition): Promise { + await this._client.issues.doTransition(params) + } + + public async listProjects(params: Version3Parameters.SearchProjects): Promise { + return await this._client.projects.searchProjects(params) + } + + public async listIssueTypesForProject(projectIdOrKey: string): Promise { + return await this._client.sendRequest( + { + url: `/rest/api/3/issue/createmeta/${encodeURIComponent(projectIdOrKey)}/issuetypes`, + method: 'GET', + }, + undefined as never + ) + } + + public async countIssues(jql: string): Promise { + const response = await this._client.sendRequest<{ count: number }>( + { + url: '/rest/api/3/search/approximate-count', + method: 'POST', + data: { jql }, + }, + undefined as never + ) + return response.count + } + + public async pickIssue(query: string, currentJql?: string): Promise { + const params = new URLSearchParams({ query }) + if (currentJql) params.set('currentJQL', currentJql) + return await this._client.sendRequest( + { + url: `/rest/api/3/issue/picker?${params.toString()}`, + method: 'GET', + }, + undefined as never + ) + } + + public async listProjectStatuses(projectIdOrKey: string): Promise { + return await this._client.projects.getAllStatuses(projectIdOrKey) + } + + public async addCommentToIssue(issueIdOrKey: string, body: string): Promise { + const { id } = await this._client.issueComments.addComment({ + issueIdOrKey, + body: textToAdfDocument(body), + }) + if (!id) { + throw new Error(`Jira did not return a comment ID for issue ${issueIdOrKey}`) + } + return id + } + + public async findUser(query: string): Promise { + const users = await this._client.userSearch.findUsers({ + query, + maxResults: 1, + }) + const user = users[0] + if (!user) { + throw new Error('Specified user does not exist or you do not have required permissions') + } + return user + } + + public async findAllUsers(addParams?: Version3Parameters.GetAllUsers): Promise { + return await this._client.users.getAllUsers(addParams) + } +} diff --git a/integrations/jira/src/definitions/actions.ts b/integrations/jira/src/definitions/actions.ts new file mode 100644 index 00000000000..3127c7dba32 --- /dev/null +++ b/integrations/jira/src/definitions/actions.ts @@ -0,0 +1,225 @@ +import type { IntegrationDefinitionProps } from '@botpress/sdk' +import { + findUserInputSchema, + findUserOutputSchema, + newIssueInputSchema, + newIssueOutputSchema, + newIssuesInputSchema, + newIssuesOutputSchema, + updateIssueInputSchema, + updateIssueOutputSchema, + findAllUsersInputSchema, + findAllUsersOutputSchema, + searchIssuesInputSchema, + searchIssuesOutputSchema, + getIssueInputSchema, + getIssueOutputSchema, + listProjectsInputSchema, + listProjectsOutputSchema, + getIssueTransitionsInputSchema, + getIssueTransitionsOutputSchema, + transitionIssueInputSchema, + transitionIssueOutputSchema, + listIssueTypesInputSchema, + listIssueTypesOutputSchema, + listProjectStatusesInputSchema, + listProjectStatusesOutputSchema, + assignIssueInputSchema, + assignIssueOutputSchema, + deleteIssueInputSchema, + deleteIssueOutputSchema, + countIssuesInputSchema, + countIssuesOutputSchema, + pickIssueInputSchema, + pickIssueOutputSchema, +} from '../misc/custom-schemas' + +type SdkActions = NonNullable +type SdkAction = SdkActions[string] + +const findUser = { + title: 'Find User', + description: 'Find the first Jira user matching a search query', + input: { + schema: findUserInputSchema, + }, + output: { + schema: findUserOutputSchema, + }, +} satisfies SdkAction + +const newIssue = { + title: 'New Issue', + description: 'Create a new issue in Jira', + input: { + schema: newIssueInputSchema, + }, + output: { + schema: newIssueOutputSchema, + }, +} satisfies SdkAction + +const newIssues = { + title: 'New Issues (Batch)', + description: 'Create up to 50 Jira issues in a single request. Returns both successes and errors.', + input: { + schema: newIssuesInputSchema, + }, + output: { + schema: newIssuesOutputSchema, + }, +} satisfies SdkAction + +const updateIssue = { + title: 'Update Issue', + description: 'Update an existing Jira issue', + input: { + schema: updateIssueInputSchema, + }, + output: { + schema: updateIssueOutputSchema, + }, +} satisfies SdkAction + +const findAllUsers = { + title: 'Find All Users', + description: 'List Jira users with optional pagination', + input: { + schema: findAllUsersInputSchema, + }, + output: { + schema: findAllUsersOutputSchema, + }, +} satisfies SdkAction + +const searchIssues = { + title: 'Search Issues', + description: + 'Search for Jira issues using JQL. When no JQL is provided, returns issues ordered by most recently created. Supports pagination via nextToken.', + input: { + schema: searchIssuesInputSchema, + }, + output: { + schema: searchIssuesOutputSchema, + }, +} satisfies SdkAction + +const getIssue = { + title: 'Get Issue', + description: 'Fetch a single Jira issue by key or ID, including its current status, assignee, and type.', + input: { + schema: getIssueInputSchema, + }, + output: { + schema: getIssueOutputSchema, + }, +} satisfies SdkAction + +const listProjects = { + title: 'List Projects', + description: + 'List Jira projects visible to the configured user. Supports pagination and an optional name/key query filter.', + input: { + schema: listProjectsInputSchema, + }, + output: { + schema: listProjectsOutputSchema, + }, +} satisfies SdkAction + +const getIssueTransitions = { + title: 'Get Issue Transitions', + description: + 'List the workflow transitions currently available for a Jira issue. Use the returned transition ID with transitionIssue.', + input: { + schema: getIssueTransitionsInputSchema, + }, + output: { + schema: getIssueTransitionsOutputSchema, + }, +} satisfies SdkAction + +const transitionIssue = { + title: 'Transition Issue', + description: + 'Apply a workflow transition to a Jira issue (for example, move it to In Progress or Done). Use getIssueTransitions to find a valid transitionId.', + input: { + schema: transitionIssueInputSchema, + }, + output: { + schema: transitionIssueOutputSchema, + }, +} satisfies SdkAction + +const listIssueTypes = { + title: 'List Issue Types', + description: 'List all Jira issue types visible to the configured user.', + input: { + schema: listIssueTypesInputSchema, + }, + output: { + schema: listIssueTypesOutputSchema, + }, +} satisfies SdkAction + +const listProjectStatuses = { + title: 'List Project Statuses', + description: 'List the workflow statuses available in a Jira project, grouped per issue type.', + input: { + schema: listProjectStatusesInputSchema, + }, + output: { + schema: listProjectStatusesOutputSchema, + }, +} satisfies SdkAction + +const assignIssue = { + title: 'Assign Issue', + description: + 'Assign or unassign a Jira issue. Pass an account ID (find one via findUser) to assign, or null to unassign.', + input: { schema: assignIssueInputSchema }, + output: { schema: assignIssueOutputSchema }, +} satisfies SdkAction + +const deleteIssue = { + title: 'Delete Issue', + description: + 'Permanently delete a Jira issue. Set deleteSubtasks=true to recursively delete its subtasks; otherwise the call fails if the issue has children.', + input: { schema: deleteIssueInputSchema }, + output: { schema: deleteIssueOutputSchema }, +} satisfies SdkAction + +const countIssues = { + title: 'Count Issues', + description: + 'Return the approximate number of issues matching a JQL query. Cheaper than paginating searchIssues when you only need a count.', + input: { schema: countIssuesInputSchema }, + output: { schema: countIssuesOutputSchema }, +} satisfies SdkAction + +const pickIssue = { + title: 'Pick Issue', + description: + 'Find Jira issues by free-text query (matches keys and summaries, ranked by relevance). Use this when the user references an issue by description rather than key.', + input: { schema: pickIssueInputSchema }, + output: { schema: pickIssueOutputSchema }, +} satisfies SdkAction + +export const actions = { + findUser, + newIssue, + newIssues, + updateIssue, + assignIssue, + deleteIssue, + findAllUsers, + searchIssues, + countIssues, + pickIssue, + getIssue, + listProjects, + getIssueTransitions, + transitionIssue, + listIssueTypes, + listProjectStatuses, +} satisfies SdkActions diff --git a/integrations/jira/src/definitions/channels.ts b/integrations/jira/src/definitions/channels.ts new file mode 100644 index 00000000000..b0a2a475941 --- /dev/null +++ b/integrations/jira/src/definitions/channels.ts @@ -0,0 +1,27 @@ +import { IntegrationDefinitionProps, messages } from '@botpress/sdk' + +export const channels = { + issueComments: { + title: 'Issue Comments', + description: 'Outbound comments on Jira issues', + messages: { + text: messages.defaults.text, + }, + message: { + tags: { + commentId: { + title: 'Comment ID', + description: 'Jira identifier of the created issue comment', + }, + }, + }, + conversation: { + tags: { + issueKey: { + title: 'Issue Key', + description: 'Jira issue key that text messages in this conversation are posted to', + }, + }, + }, + }, +} satisfies NonNullable diff --git a/integrations/jira/src/definitions/index.ts b/integrations/jira/src/definitions/index.ts new file mode 100644 index 00000000000..f9297e727f2 --- /dev/null +++ b/integrations/jira/src/definitions/index.ts @@ -0,0 +1,23 @@ +import { z, type IntegrationDefinitionProps } from '@botpress/sdk' + +export { actions } from './actions' +export { channels } from './channels' + +export const configuration = { + schema: z.object({ + host: z.string().url().title('Host URL').describe('Jira Cloud host URL, such as https://example.atlassian.net'), + email: z.string().email().title('Email').describe('Atlassian account email used for Jira API authentication'), + apiToken: z + .string() + .min(1) + .secret() + .title('API Token') + .describe('Atlassian API token used for Jira API authentication'), + }), +} satisfies IntegrationDefinitionProps['configuration'] + +export const states = {} satisfies IntegrationDefinitionProps['states'] + +export const user = { + tags: {}, +} satisfies IntegrationDefinitionProps['user'] diff --git a/integrations/jira/src/index.ts b/integrations/jira/src/index.ts new file mode 100644 index 00000000000..d94e88179de --- /dev/null +++ b/integrations/jira/src/index.ts @@ -0,0 +1,11 @@ +import actions from './actions' +import { register, unregister, channels, handler } from './setup' +import * as botpress from '.botpress' + +export default new botpress.Integration({ + register, + unregister, + actions, + channels, + handler, +}) diff --git a/integrations/jira/src/misc/adf.ts b/integrations/jira/src/misc/adf.ts new file mode 100644 index 00000000000..38f1c0baa2a --- /dev/null +++ b/integrations/jira/src/misc/adf.ts @@ -0,0 +1,17 @@ +import type { Version3Models } from 'jira.js' + +export const textToAdfDocument = (text: string): Version3Models.Document => { + if (!text) { + return { version: 1, type: 'doc', content: [] } + } + return { + version: 1, + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text }], + }, + ], + } +} diff --git a/integrations/jira/src/misc/custom-schemas.ts b/integrations/jira/src/misc/custom-schemas.ts new file mode 100644 index 00000000000..5f63e9892d5 --- /dev/null +++ b/integrations/jira/src/misc/custom-schemas.ts @@ -0,0 +1,336 @@ +import { z } from '@botpress/sdk' + +export const jiraUserSchema = z.object({ + self: z.string().optional().title('Self URL').describe('Jira API URL for the user'), + key: z.string().optional().title('User Key').describe('Legacy Jira user key, when available'), + accountId: z.string().optional().title('Account ID').describe('Jira account ID for the user'), + accountType: z.string().optional().title('Account Type').describe('Jira account type for the user'), + name: z.string().optional().title('Name').describe('Legacy Jira username, when available'), + emailAddress: z.string().optional().title('Email Address').describe('Email address for the Jira user, when visible'), + displayName: z.string().optional().title('Display Name').describe('Display name for the Jira user'), + active: z.boolean().optional().title('Active').describe('Whether the Jira user account is active'), + timeZone: z.string().optional().title('Time Zone').describe('User time zone configured in Jira'), + locale: z.string().optional().title('Locale').describe('User locale configured in Jira'), +}) + +export const findUserInputSchema = z.object({ + query: z + .string() + .title('Query') + .describe('Search query for a Jira user, such as a name, email, or account identifier'), +}) + +export const findUserOutputSchema = jiraUserSchema + +export const findAllUsersInputSchema = z.object({ + startAt: z.number().int().min(0).optional().title('Start At').describe('Index of the first Jira user to return'), + maxResults: z + .number() + .int() + .min(1) + .max(100) + .optional() + .title('Max Results') + .describe('Maximum number of Jira users to return (1-100)'), +}) + +export const findAllUsersOutputSchema = z.object({ + users: z.array(jiraUserSchema).title('Users').describe('Jira users returned by the lookup'), +}) + +export const newIssueInputSchema = z.object({ + summary: z.string().title('Summary').describe('Summary of the Jira issue to create'), + description: z.string().optional().title('Description').describe('Detailed description of the Jira issue'), + issueType: z.string().title('Issue Type').describe('Name of the Jira issue type, such as Task, Story, Bug, or Epic'), + projectKey: z.string().title('Project Key').describe('Key of the Jira project where the issue is created'), + parentKey: z.string().optional().title('Parent Key').describe('Parent issue key when creating a sub-task'), + assigneeId: z.string().optional().title('Assignee ID').describe('Jira account ID of the user assigned to the issue'), +}) + +export const newIssueOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the Jira issue that was created'), +}) + +export const updateIssueInputSchema = newIssueInputSchema.partial().extend({ + issueKey: z.string().title('Issue Key').describe('Key of the Jira issue to update'), +}) + +export const updateIssueOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the Jira issue that was updated'), +}) + +export const jiraIssueSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Jira issue key (e.g. SCRUM-17)'), + id: z.string().optional().title('Issue ID').describe('Internal Jira issue ID'), + browseUrl: z + .string() + .optional() + .title('Browse URL') + .describe('User-facing Jira URL for the issue (https:///browse/)'), + summary: z.string().optional().title('Summary').describe('Issue summary'), + description: z.string().optional().title('Description').describe('Issue description as plain text, when available'), + status: z.string().optional().title('Status').describe('Current workflow status name'), + statusCategory: z.string().optional().title('Status Category').describe('Status category (To Do, In Progress, Done)'), + issueType: z.string().optional().title('Issue Type').describe('Name of the issue type'), + priority: z.string().optional().title('Priority').describe('Priority name'), + projectKey: z.string().optional().title('Project Key').describe('Key of the project the issue belongs to'), + assigneeId: z.string().optional().title('Assignee ID').describe('Account ID of the assigned user'), + assigneeName: z.string().optional().title('Assignee Name').describe('Display name of the assigned user'), + reporterId: z.string().optional().title('Reporter ID').describe('Account ID of the reporting user'), + reporterName: z.string().optional().title('Reporter Name').describe('Display name of the reporting user'), + parentKey: z.string().optional().title('Parent Key').describe('Issue key of the parent issue, when applicable'), + created: z.string().optional().title('Created').describe('ISO timestamp of issue creation'), + updated: z.string().optional().title('Updated').describe('ISO timestamp of last update'), +}) + +export const searchIssuesInputSchema = z.object({ + jql: z + .string() + .optional() + .title('JQL') + .describe('JQL query for issue search. Defaults to "order by created DESC" when omitted.'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Pagination cursor returned from a previous searchIssues call'), + maxResults: z + .number() + .int() + .min(1) + .max(100) + .optional() + .title('Max Results') + .describe('Maximum number of issues to return per page (1-100, default 50)'), +}) + +export const searchIssuesOutputSchema = z.object({ + items: z.array(jiraIssueSchema).title('Items').describe('Issues matching the JQL query'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Cursor to pass as nextToken to fetch the next page; omitted when there are no more results'), +}) + +export const getIssueInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the Jira issue to fetch'), +}) + +export const getIssueOutputSchema = jiraIssueSchema + +export const jiraProjectSchema = z.object({ + id: z.string().title('Project ID').describe('Internal Jira project ID'), + key: z.string().title('Project Key').describe('Project key (e.g. SCRUM)'), + name: z.string().optional().title('Project Name').describe('Display name of the project'), + projectTypeKey: z + .string() + .optional() + .title('Project Type') + .describe('Project type (software, service_desk, business)'), + description: z.string().optional().title('Description').describe('Project description'), + leadAccountId: z.string().optional().title('Lead Account ID').describe('Account ID of the project lead'), + leadName: z.string().optional().title('Lead Name').describe('Display name of the project lead'), +}) + +export const listProjectsInputSchema = z.object({ + query: z + .string() + .optional() + .title('Query') + .describe('Optional case-insensitive substring match against project key or name'), + nextToken: z.string().optional().title('Next Token').describe('Pagination cursor returned from a previous call'), + maxResults: z + .number() + .int() + .min(1) + .max(100) + .optional() + .title('Max Results') + .describe('Maximum number of projects per page (1-100, default 50)'), +}) + +export const listProjectsOutputSchema = z.object({ + items: z.array(jiraProjectSchema).title('Items').describe('Projects visible to the configured user'), + nextToken: z.string().optional().title('Next Token').describe('Cursor for the next page; omitted when no more pages'), +}) + +export const jiraTransitionSchema = z.object({ + id: z.string().title('Transition ID').describe('ID of the transition (pass to transitionIssue)'), + name: z.string().optional().title('Transition Name').describe('Display name of the transition'), + toStatus: z + .string() + .optional() + .title('Target Status') + .describe('Status the issue moves to when the transition is applied'), + toStatusCategory: z + .string() + .optional() + .title('Target Status Category') + .describe('Status category of the target status'), + isAvailable: z.boolean().optional().title('Available').describe('Whether the transition is currently available'), + hasScreen: z + .boolean() + .optional() + .title('Has Screen') + .describe('Whether the transition shows a screen for additional fields'), +}) + +export const getIssueTransitionsInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the issue whose transitions to list'), +}) + +export const getIssueTransitionsOutputSchema = z.object({ + transitions: z.array(jiraTransitionSchema).title('Transitions').describe('Transitions available for the issue'), +}) + +export const transitionIssueInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the issue to transition'), + transitionId: z + .string() + .title('Transition ID') + .describe('ID of the transition to apply. Use getIssueTransitions to list valid IDs.'), + comment: z + .string() + .optional() + .title('Comment') + .describe('Optional comment to add to the issue as part of the transition'), +}) + +export const transitionIssueOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the issue that was transitioned'), + transitionId: z.string().title('Transition ID').describe('ID of the transition that was applied'), +}) + +export const jiraIssueTypeSchema = z.object({ + id: z.string().optional().title('Issue Type ID').describe('Internal Jira issue type ID'), + name: z.string().optional().title('Name').describe('Issue type name (e.g. Task, Bug, Story)'), + description: z.string().optional().title('Description').describe('Issue type description'), + subtask: z.boolean().optional().title('Is Subtask').describe('Whether the issue type represents a subtask'), + hierarchyLevel: z.number().optional().title('Hierarchy Level').describe('Hierarchy level of the issue type'), +}) + +export const listIssueTypesInputSchema = z.object({ + projectKey: z + .string() + .title('Project Key') + .describe('Key of the project to list issue types for (e.g. SCRUM). Issue types vary per project.'), +}) + +export const listIssueTypesOutputSchema = z.object({ + items: z.array(jiraIssueTypeSchema).title('Items').describe('Available issue types'), +}) + +export const jiraStatusSchema = z.object({ + id: z.string().optional().title('Status ID').describe('Internal Jira status ID'), + name: z.string().optional().title('Name').describe('Status name (e.g. To Do, In Progress, Done)'), + description: z.string().optional().title('Description').describe('Status description'), + category: z.string().optional().title('Category').describe('Status category (To Do, In Progress, Done)'), + issueType: z.string().optional().title('Issue Type').describe('Issue type the status belongs to'), +}) + +export const listProjectStatusesInputSchema = z.object({ + projectKey: z.string().title('Project Key').describe('Key or ID of the project'), +}) + +export const listProjectStatusesOutputSchema = z.object({ + items: z.array(jiraStatusSchema).title('Items').describe('Statuses grouped per issue type for the project'), +}) + +export const newIssuesInputSchema = z.object({ + issues: z + .array(newIssueInputSchema) + .title('Issues') + .describe('Issues to create in a single request (1-50). Jira enforces a hard limit of 50.'), +}) + +export const newIssuesOutputSchema = z.object({ + created: z + .array(z.object({ issueKey: z.string().title('Issue Key').describe('Key of an issue that was created') })) + .title('Created') + .describe('Issues that were successfully created'), + errors: z + .array( + z.object({ + index: z.number().optional().title('Input Index').describe('Index in the input array that failed'), + message: z.string().title('Message').describe('Error message describing why the create failed'), + }) + ) + .title('Errors') + .describe('Errors for issues that failed to create. Empty when all issues succeeded.'), +}) + +export const assignIssueInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the issue to assign (e.g. SCRUM-17)'), + accountId: z + .string() + .nullable() + .title('Assignee Account ID') + .describe('Jira account ID of the new assignee, or null to unassign'), +}) + +export const assignIssueOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the issue that was assigned'), + accountId: z + .string() + .nullable() + .title('Assignee Account ID') + .describe('New assignee account ID, or null if unassigned'), +}) + +export const deleteIssueInputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key or ID of the issue to delete'), + deleteSubtasks: z + .boolean() + .optional() + .title('Delete Subtasks') + .describe( + 'Whether to also delete subtasks of this issue (default false). If false and the issue has subtasks, the API will reject the delete.' + ), +}) + +export const deleteIssueOutputSchema = z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the issue that was deleted'), +}) + +export const countIssuesInputSchema = z.object({ + jql: z.string().title('JQL').describe('JQL query whose matching issues should be counted'), +}) + +export const countIssuesOutputSchema = z.object({ + count: z + .number() + .title('Count') + .describe( + 'Approximate number of issues matching the JQL. Atlassian returns an estimate optimized for performance, not an exact count.' + ), +}) + +export const pickIssueInputSchema = z.object({ + query: z + .string() + .title('Query') + .describe('Free-text query Jira matches against issue keys and summaries (e.g. "login bug")'), + currentJql: z + .string() + .optional() + .title('Scoping JQL') + .describe('Optional JQL to restrict the search scope (e.g. "project = SCRUM")'), +}) + +export const pickIssueOutputSchema = z.object({ + matches: z + .array( + z.object({ + issueKey: z.string().title('Issue Key').describe('Key of the matching issue'), + summary: z.string().optional().title('Summary').describe('Issue summary'), + section: z + .string() + .optional() + .title('Section') + .describe('Picker section this match came from (e.g. "History Search", "Current Search")'), + }) + ) + .title('Matches') + .describe('Issues matching the query, ranked by relevance and flattened across picker sections'), +}) diff --git a/integrations/jira/src/misc/types.ts b/integrations/jira/src/misc/types.ts new file mode 100644 index 00000000000..a4566b325a7 --- /dev/null +++ b/integrations/jira/src/misc/types.ts @@ -0,0 +1,9 @@ +import * as botpress from '.botpress' + +export type Config = botpress.configuration.Configuration +export type Implementation = botpress.IntegrationProps + +export type RegisterFunction = Implementation['register'] +export type UnregisterFunction = Implementation['unregister'] +export type Channels = Implementation['channels'] +export type Handler = Implementation['handler'] diff --git a/integrations/jira/src/setup/channels.ts b/integrations/jira/src/setup/channels.ts new file mode 100644 index 00000000000..80da415cab9 --- /dev/null +++ b/integrations/jira/src/setup/channels.ts @@ -0,0 +1,27 @@ +import { RuntimeError } from '@botpress/sdk' +import type { Channels } from '../misc/types' +import { getClient } from '../utils' + +export const channels: Channels = { + issueComments: { + messages: { + text: async ({ ctx, payload, conversation, ack, logger }) => { + const issueKey = conversation.tags.issueKey + if (!issueKey) { + throw new RuntimeError('Issue key must be set on the Jira issue comments conversation') + } + + const jiraClient = getClient(ctx.configuration) + try { + const commentId = await jiraClient.addCommentToIssue(issueKey, payload.text) + logger.forBot().info(`Successful - Add Jira issue comment - ${issueKey} - ${commentId}`) + await ack({ tags: { commentId } }) + } catch (error) { + logger.forBot().debug(`'Add Jira issue comment' exception ${JSON.stringify(error)}`) + const message = error instanceof Error ? error.message : JSON.stringify(error) + throw new RuntimeError(`Failed to add Jira issue comment: ${message}`) + } + }, + }, + }, +} diff --git a/integrations/jira/src/setup/handler.ts b/integrations/jira/src/setup/handler.ts new file mode 100644 index 00000000000..e488327a93c --- /dev/null +++ b/integrations/jira/src/setup/handler.ts @@ -0,0 +1,3 @@ +import type { Handler } from '../misc/types' + +export const handler: Handler = async () => {} diff --git a/integrations/jira/src/setup/index.ts b/integrations/jira/src/setup/index.ts new file mode 100644 index 00000000000..942ecedd0c6 --- /dev/null +++ b/integrations/jira/src/setup/index.ts @@ -0,0 +1,4 @@ +export { register } from './register' +export { unregister } from './unregister' +export { channels } from './channels' +export { handler } from './handler' diff --git a/integrations/jira/src/setup/register.ts b/integrations/jira/src/setup/register.ts new file mode 100644 index 00000000000..fb4bf61c614 --- /dev/null +++ b/integrations/jira/src/setup/register.ts @@ -0,0 +1,13 @@ +import { RuntimeError } from '@botpress/sdk' +import type { RegisterFunction } from '../misc/types' +import { getClient } from '../utils' + +export const register: RegisterFunction = async ({ ctx }) => { + const jiraClient = getClient(ctx.configuration) + try { + await jiraClient.getCurrentUser() + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error) + throw new RuntimeError(`Invalid Jira configuration: ${message}`) + } +} diff --git a/integrations/jira/src/setup/unregister.ts b/integrations/jira/src/setup/unregister.ts new file mode 100644 index 00000000000..48967dfd5e4 --- /dev/null +++ b/integrations/jira/src/setup/unregister.ts @@ -0,0 +1,3 @@ +import type { UnregisterFunction } from '../misc/types' + +export const unregister: UnregisterFunction = async () => {} diff --git a/integrations/jira/src/utils/index.ts b/integrations/jira/src/utils/index.ts new file mode 100644 index 00000000000..2f8aee2211c --- /dev/null +++ b/integrations/jira/src/utils/index.ts @@ -0,0 +1,205 @@ +import { RuntimeError } from '@botpress/sdk' +import type { Version3Models } from 'jira.js' +import { JiraApi } from '../client' +import { textToAdfDocument } from '../misc/adf' +import type { Config } from '../misc/types' + +export const getClient = (config: Config) => new JiraApi(config.host, config.email, config.apiToken) + +export { textToAdfDocument } + +export const serializeErrorForLog = (error: unknown): string => { + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +export const resolveIssueTypeIds = async ( + client: JiraApi, + issues: Array<{ issueType: string; projectKey: string }> +): Promise> => { + const projectKeys = new Set(issues.map((i) => i.projectKey)) + const key = (projectKey: string, typeName: string) => `${projectKey}::${typeName}` + const nameToId = new Map() + + for (const projectKey of projectKeys) { + let response: Awaited> + try { + response = await client.listIssueTypesForProject(projectKey) + } catch (error) { + throw buildRuntimeError(`Failed to resolve issue types for project "${projectKey}"`, error) + } + + for (const issue of issues) { + const mapKey = key(projectKey, issue.issueType) + if (issue.projectKey !== projectKey || nameToId.has(mapKey)) continue + const match = (response.issueTypes ?? []).find((t) => t.name === issue.issueType) + if (!match?.id) { + throw new RuntimeError( + `Failed to resolve issue types: invalid issue type "${issue.issueType}" for project "${projectKey}". Use a Jira issue type that is valid for the target project.` + ) + } + nameToId.set(mapKey, match.id) + } + } + + return nameToId +} + +type FlattenedIssue = { + issueKey: string + id?: string + browseUrl?: string + summary?: string + description?: string + status?: string + statusCategory?: string + issueType?: string + priority?: string + projectKey?: string + assigneeId?: string + assigneeName?: string + reporterId?: string + reporterName?: string + parentKey?: string + created?: string + updated?: string +} + +const isString = (v: unknown): v is string => typeof v === 'string' && v.length > 0 + +const extractDescriptionText = (description: unknown): string | undefined => { + if (typeof description === 'string') { + return description + } + // Atlassian Document Format: walk text nodes. + const collected: string[] = [] + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return + const n = node as { type?: string; text?: string; content?: unknown[] } + if (n.type === 'text' && isString(n.text)) { + collected.push(n.text) + } + if (Array.isArray(n.content)) { + n.content.forEach(walk) + } + } + walk(description) + const joined = collected.join(' ').trim() + return joined.length > 0 ? joined : undefined +} + +export const flattenIssue = (issue: Version3Models.Issue, host?: string): FlattenedIssue => { + const fields = (issue.fields ?? {}) as Record + const status = fields.status as { name?: string; statusCategory?: { name?: string } } | undefined + const issueType = fields.issuetype as { name?: string } | undefined + const priority = fields.priority as { name?: string } | undefined + const project = fields.project as { key?: string } | undefined + const assignee = fields.assignee as { accountId?: string; displayName?: string } | undefined + const reporter = fields.reporter as { accountId?: string; displayName?: string } | undefined + const parent = fields.parent as { key?: string } | undefined + + return { + issueKey: issue.key, + id: issue.id, + browseUrl: host && issue.key ? `${host.replace(/\/$/, '')}/browse/${issue.key}` : undefined, + summary: isString(fields.summary) ? (fields.summary as string) : undefined, + description: extractDescriptionText(fields.description), + status: status?.name, + statusCategory: status?.statusCategory?.name, + issueType: issueType?.name, + priority: priority?.name, + projectKey: project?.key, + assigneeId: assignee?.accountId, + assigneeName: assignee?.displayName, + reporterId: reporter?.accountId, + reporterName: reporter?.displayName, + parentKey: parent?.key, + created: isString(fields.created) ? (fields.created as string) : undefined, + updated: isString(fields.updated) ? (fields.updated as string) : undefined, + } +} + +export const ISSUE_SEARCH_FIELDS = [ + 'summary', + 'description', + 'status', + 'issuetype', + 'priority', + 'project', + 'assignee', + 'reporter', + 'parent', + 'created', + 'updated', +] + +type JiraErrorShape = { + errors?: Record + errorMessages?: string[] + status?: number + statusText?: string +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const getStringArray = (value: unknown): string[] | undefined => + Array.isArray(value) && value.every((item): item is string => typeof item === 'string') ? value : undefined + +const getStringRecord = (value: unknown): Record | undefined => { + if (!isRecord(value)) return undefined + const entries = Object.entries(value) + if (!entries.every((entry): entry is [string, string] => typeof entry[1] === 'string')) return undefined + return Object.fromEntries(entries) +} + +const getJiraError = (error: unknown): JiraErrorShape | undefined => { + if (!isRecord(error)) return undefined + + const errors = getStringRecord(error.errors) + const errorMessages = getStringArray(error.errorMessages) + const status = typeof error.status === 'number' ? error.status : undefined + const statusText = typeof error.statusText === 'string' ? error.statusText : undefined + + if (!errors && !errorMessages && status === undefined && statusText === undefined) return undefined + return { errors, errorMessages, status, statusText } +} + +export const getJiraErrorDetail = (error: unknown): string | undefined => { + const jiraError = getJiraError(error) + if (!jiraError) return undefined + + const fieldErrors = jiraError.errors ? Object.entries(jiraError.errors).map(([k, v]) => `${k}: ${v}`) : [] + const detail = [...(jiraError.errorMessages ?? []), ...fieldErrors].join('; ') + return detail.length > 0 ? detail : undefined +} + +export const getErrorMessage = (error: unknown): string => + getJiraErrorDetail(error) ?? (error instanceof Error ? error.message : serializeErrorForLog(error)) + +export const buildRuntimeError = (prefix: string, error: unknown): RuntimeError => + new RuntimeError(`${prefix}: ${getErrorMessage(error)}`) + +export const buildIssueRuntimeError = ( + error: unknown, + issueType: string | undefined, + projectKey: string | undefined, + verb: 'create' | 'update' +): RuntimeError => { + const jiraError = getJiraError(error) + const issueTypeError = jiraError?.errors?.issuetype + if (issueTypeError && projectKey) { + return new RuntimeError( + `Failed to ${verb} issue: invalid issue type "${issueType ?? ''}" for project "${projectKey}". Use a Jira issue type that is valid for the target project. (Jira: ${issueTypeError})` + ) + } + const detail = getJiraErrorDetail(error) + if (detail) { + return new RuntimeError(`Failed to ${verb} issue: ${detail}`) + } + const message = error instanceof Error ? error.message : JSON.stringify(error) + return new RuntimeError(`Failed to ${verb} issue: ${message}`) +} diff --git a/integrations/jira/tsconfig.json b/integrations/jira/tsconfig.json new file mode 100644 index 00000000000..d46abc5b88f --- /dev/null +++ b/integrations/jira/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { "*": ["./*"] }, + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3df6258cd0..5f9852230bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,6 +1341,28 @@ importers: specifier: ^2.39.1 version: 2.39.1 + integrations/jira: + dependencies: + '@botpress/client': + specifier: workspace:* + version: link:../../packages/client + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + jira.js: + specifier: ^2.19.1 + version: 2.20.1 + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@types/node': + specifier: ^22.16.4 + version: 22.16.4 + integrations/kommo: dependencies: '@botpress/client': @@ -7141,6 +7163,11 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atlassian-jwt@2.0.3: + resolution: {integrity: sha512-G9oO3HHS1UKgsLRXj6nNKv2TY6g3PleBCdzHwbFeVKg+18GBFIMRz+ApxuOuWAgcL7RngNFF5rGNtw1Ss3hvTg==} + engines: {node: '>= 0.4.0'} + deprecated: 'DEPRECATED: atlassian-jwt has moved to @atlassian/atlassian-jwt. The latest version is 2.1.1. Please update your dependency.' + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -9545,6 +9572,9 @@ packages: node-notifier: optional: true + jira.js@2.20.1: + resolution: {integrity: sha512-ZFlFAVTEaw86OemQ8BVyVSV0YDZqMI6WVc08Th5GgqVEODW4gmhXLOxqSmCasRJMMVAtI1LrFlFFTf9GnFaUhg==} + jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true @@ -9717,6 +9747,9 @@ packages: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} + jsuri@1.3.1: + resolution: {integrity: sha512-LLdAeqOf88/X0hylAI7oSir6QUsz/8kOW0FcJzzu/SJRfORA/oPHycAOthkNp7eLPlTAbqVDFbqNRHkRVzEA3g==} + jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} @@ -17269,6 +17302,11 @@ snapshots: asynckit@0.4.0: {} + atlassian-jwt@2.0.3: + dependencies: + jsuri: 1.3.1 + lodash: 4.17.21 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -20481,6 +20519,16 @@ snapshots: - supports-color - ts-node + jira.js@2.20.1: + dependencies: + atlassian-jwt: 2.0.3 + axios: 1.13.6 + form-data: 4.0.5 + oauth: 0.10.2 + tslib: 2.6.2 + transitivePeerDependencies: + - debug + jiti@2.4.2: {} jju@1.4.0: {} @@ -20693,6 +20741,8 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jsuri@1.3.1: {} + jwa@1.4.1: dependencies: buffer-equal-constant-time: 1.0.1