chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<svg width="160" height="166" viewBox="0 0 160 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M157.918 78.1853L86.8049 6.90333L79.9127 0L1.91237 78.1853C-0.637456 80.7482 -0.637456 84.897 1.91237 87.4546L50.8191 136.475L79.9127 165.645L157.918 87.4546C160.471 84.897 160.471 80.7482 157.918 78.1853ZM79.9127 107.306L55.4863 82.8235L79.9127 58.3321L104.344 82.8235L79.9127 107.306Z" fill="#2684FF"/>
|
||||
<path d="M80.0707 58.3528C64.0409 42.3001 63.959 16.3013 79.9001 0.154297L26.3088 53.7781L55.4784 82.9766L80.0707 58.3528Z" fill="url(#paint0_linear_370_36559)"/>
|
||||
<path d="M103.931 82.8223L79.4893 107.319C87.1918 115.034 91.5239 125.505 91.5239 136.423C91.5239 147.341 87.1918 157.805 79.4893 165.527L132.957 111.926L103.931 82.8223Z" fill="url(#paint1_linear_370_36559)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_370_36559" x1="69.5461" y1="39.7188" x2="41.4762" y2="67.7538" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_370_36559" x1="90.2657" y1="125.628" x2="118.189" y2="97.7739" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,24 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
|
||||
import { configuration, states, secrets, 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.5.1',
|
||||
readme: 'readme.md',
|
||||
icon: 'icon.svg',
|
||||
configuration,
|
||||
channels,
|
||||
user,
|
||||
actions,
|
||||
events: {},
|
||||
states,
|
||||
secrets,
|
||||
attributes: {
|
||||
category: 'Project Management',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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 through the comments channel.
|
||||
|
||||
This version supports the Jira OAuth setup wizard. If you are testing the OAuth build, the setup flow should offer **Connect with OAuth** and **Use an API Token**.
|
||||
|
||||
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.
|
||||
- For OAuth setup: permission to authorize Jira access from your Atlassian account.
|
||||
- For manual setup: an API token generated from your Atlassian 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".
|
||||
- In the setup wizard, select **Connect with OAuth** to authorize Botpress with Atlassian.
|
||||
- If your Atlassian account has access to multiple Jira sites, select the site this integration should use.
|
||||
- Alternatively, select **Use an API Token** and provide your Jira host, Atlassian account email, and API token.
|
||||
- Finish the setup.
|
||||
|
||||
## 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`, `addAttachment`, `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 upload an image or file to a Jira issue, call `addAttachment` with an `issueKey`, `filename`, and either a `fileUrl` or base64-encoded `data`.
|
||||
|
||||
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!
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { addAttachmentInputSchema, addAttachmentOutputSchema } from '../misc/custom-schemas'
|
||||
import type { Implementation } from '../misc/types'
|
||||
import { getClient, getErrorMessage, serializeErrorForLog } from '../utils'
|
||||
|
||||
const _downloadFile = async (fileUrl: string): Promise<{ data: ArrayBuffer; contentType?: string }> => {
|
||||
const response = await fetch(fileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file (${response.status} ${response.statusText})`)
|
||||
}
|
||||
|
||||
return {
|
||||
data: await response.arrayBuffer(),
|
||||
contentType: response.headers.get('content-type') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const _decodeBase64 = (data: string): Buffer => {
|
||||
const base64 = data.includes(',') ? data.split(',').pop()! : data
|
||||
return Buffer.from(base64, 'base64')
|
||||
}
|
||||
|
||||
export const addAttachment: Implementation['actions']['addAttachment'] = async ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = addAttachmentInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
if (!validatedInput.fileUrl && !validatedInput.data) {
|
||||
throw new RuntimeError('Either fileUrl or data must be provided')
|
||||
}
|
||||
|
||||
const file = validatedInput.fileUrl
|
||||
? await _downloadFile(validatedInput.fileUrl)
|
||||
: { data: _decodeBase64(validatedInput.data!), contentType: undefined }
|
||||
|
||||
const attachments = await jiraClient.addAttachmentToIssue(validatedInput.issueKey, {
|
||||
filename: validatedInput.filename,
|
||||
contentType: validatedInput.contentType ?? file.contentType,
|
||||
data: file.data,
|
||||
})
|
||||
|
||||
logger.forBot().info(`Successful - Add Attachment - ${validatedInput.issueKey} - ${validatedInput.filename}`)
|
||||
return addAttachmentOutputSchema.parse({ issueKey: validatedInput.issueKey, attachments })
|
||||
} catch (error) {
|
||||
logger.forBot().debug(`'Add Attachment' exception ${serializeErrorForLog(error)}`)
|
||||
const message = getErrorMessage(error)
|
||||
throw new RuntimeError(`Failed to add attachment to issue ${validatedInput.issueKey}: ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = assignIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = countIssuesInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = deleteIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = findAllUsersInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = findUserInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
logger,
|
||||
}) => {
|
||||
const validatedInput = getIssueTransitionsInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = getIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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, jiraClient.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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { addAttachment } from './add-attachment'
|
||||
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,
|
||||
addAttachment,
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = listIssueTypesInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
logger,
|
||||
}) => {
|
||||
const validatedInput = listProjectStatusesInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = listProjectsInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = newIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, 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 = await getClient({ client, ctx, logger })
|
||||
|
||||
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<IssueInput['fields']> = {
|
||||
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<string, string>
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = pickIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = searchIssuesInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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, jiraClient.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)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = transitionIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
|
||||
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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 ({ client, ctx, input, logger }) => {
|
||||
const validatedInput = updateIssueInputSchema.parse(input)
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
const fields: NonNullable<Version3Parameters.EditIssue['fields']> = {}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { isApiError, RuntimeError } from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type AtlassianTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
type AccessibleResource = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'
|
||||
const ATLASSIAN_ACCESSIBLE_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
|
||||
const MINIMUM_TOKEN_VALIDITY_SECONDS = 300
|
||||
|
||||
export class JiraOAuthClient {
|
||||
public constructor(
|
||||
private readonly _props: {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
logger: bp.Logger
|
||||
}
|
||||
) {}
|
||||
|
||||
public async getAccessToken(): Promise<string> {
|
||||
const credentials = await this._getOAuthCredentials()
|
||||
if (!credentials) {
|
||||
throw new RuntimeError('No Jira OAuth credentials found. Re-run the Jira setup wizard.')
|
||||
}
|
||||
|
||||
if (new Date(credentials.expiresAt) > this._getMinExpiryDate()) {
|
||||
return credentials.accessToken
|
||||
}
|
||||
|
||||
return await this.refreshAccessToken(credentials.refreshToken)
|
||||
}
|
||||
|
||||
public async exchangeAuthorizationCode(code: string, redirectUri: string): Promise<string> {
|
||||
const token = await this._postToken({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: bp.secrets.CLIENT_ID,
|
||||
client_secret: bp.secrets.CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
|
||||
await this._saveOAuthCredentials(token)
|
||||
return token.access_token
|
||||
}
|
||||
|
||||
public async refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const token = await this._postToken({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: bp.secrets.CLIENT_ID,
|
||||
client_secret: bp.secrets.CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
await this._saveOAuthCredentials(token, refreshToken)
|
||||
return token.access_token
|
||||
}
|
||||
|
||||
public async listAccessibleJiraResources(accessToken: string): Promise<AccessibleResource[]> {
|
||||
const response = await fetch(ATLASSIAN_ACCESSIBLE_RESOURCES_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RuntimeError(`Failed to list accessible Jira sites: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
const resources = (await response.json()) as AccessibleResource[]
|
||||
return resources.filter((resource) => resource.scopes.some((scope) => scope.includes('jira')))
|
||||
}
|
||||
|
||||
public async saveManualCredentials(credentials: bp.states.manualCredentials.ManualCredentials['payload']) {
|
||||
await this._props.client.setState({
|
||||
type: 'integration',
|
||||
name: 'manualCredentials',
|
||||
id: this._props.ctx.integrationId,
|
||||
payload: credentials,
|
||||
})
|
||||
await this.clearOAuthCredentials()
|
||||
}
|
||||
|
||||
public async clearManualCredentials() {
|
||||
await this._props.client.setState({
|
||||
type: 'integration',
|
||||
name: 'manualCredentials',
|
||||
id: this._props.ctx.integrationId,
|
||||
payload: { host: 'https://cleared.atlassian.net', email: 'cleared@example.com', apiToken: '' },
|
||||
})
|
||||
}
|
||||
|
||||
public async clearOAuthCredentials() {
|
||||
await this._props.client.setState({
|
||||
type: 'integration',
|
||||
name: 'oAuthCredentials',
|
||||
id: this._props.ctx.integrationId,
|
||||
payload: {
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
expiresAt: new Date(0).toISOString(),
|
||||
scopes: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async _postToken(body: Record<string, string>): Promise<AtlassianTokenResponse> {
|
||||
const response = await fetch(ATLASSIAN_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RuntimeError(`Failed to exchange Jira OAuth token: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
const token = (await response.json()) as AtlassianTokenResponse
|
||||
if (!token.access_token || !token.expires_in) {
|
||||
this._props.logger.forBot().error('Atlassian OAuth token response is missing required fields')
|
||||
throw new RuntimeError('Jira OAuth response is missing required fields')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
private async _getOAuthCredentials() {
|
||||
return this._props.client
|
||||
.getState({
|
||||
type: 'integration',
|
||||
name: 'oAuthCredentials',
|
||||
id: this._props.ctx.integrationId,
|
||||
})
|
||||
.then(({ state }) => state.payload)
|
||||
.catch((e: unknown) => {
|
||||
if (isApiError(e) && e.type === 'ResourceNotFound') {
|
||||
return undefined
|
||||
}
|
||||
this._props.logger.forBot().error('Failed to read Jira OAuth credentials state', { error: e })
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
private async _saveOAuthCredentials(token: AtlassianTokenResponse, previousRefreshToken?: string) {
|
||||
const refreshToken = token.refresh_token ?? previousRefreshToken
|
||||
if (!refreshToken) {
|
||||
throw new RuntimeError('Jira OAuth response did not include a refresh token')
|
||||
}
|
||||
|
||||
await this._props.client.setState({
|
||||
type: 'integration',
|
||||
name: 'oAuthCredentials',
|
||||
id: this._props.ctx.integrationId,
|
||||
payload: {
|
||||
accessToken: token.access_token,
|
||||
refreshToken,
|
||||
expiresAt: new Date(Date.now() + token.expires_in * 1000).toISOString(),
|
||||
scopes: token.scope ? token.scope.split(' ') : [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _getMinExpiryDate() {
|
||||
return new Date(Date.now() + MINIMUM_TOKEN_VALIDITY_SECONDS * 1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Version3Client, Version3Models, Version3Parameters, type Config } 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 type AttachmentInput = {
|
||||
filename: string
|
||||
contentType?: string
|
||||
data: ArrayBuffer | Uint8Array
|
||||
}
|
||||
|
||||
export class JiraApi {
|
||||
private _client: Version3Client
|
||||
public readonly host: string
|
||||
|
||||
private constructor(requestHost: string, authentication: Config.Authentication, browseHost: string = requestHost) {
|
||||
this.host = browseHost.replace(/\/$/, '')
|
||||
this._client = new Version3Client({
|
||||
host: requestHost.replace(/\/$/, ''),
|
||||
authentication,
|
||||
newErrorHandling: true,
|
||||
})
|
||||
}
|
||||
|
||||
public static fromBasicAuth(host: string, email: string, apiToken: string): JiraApi {
|
||||
return new JiraApi(host, {
|
||||
basic: {
|
||||
email,
|
||||
apiToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public static fromOAuth(cloudId: string, accessToken: string, host: string): JiraApi {
|
||||
return new JiraApi(
|
||||
`https://api.atlassian.com/ex/jira/${cloudId}`,
|
||||
{
|
||||
oauth2: {
|
||||
accessToken,
|
||||
},
|
||||
},
|
||||
host
|
||||
)
|
||||
}
|
||||
|
||||
public async newIssue(issue: Version3Parameters.CreateIssue): Promise<string> {
|
||||
const { key } = await this._client.issues.createIssue(issue)
|
||||
return key
|
||||
}
|
||||
|
||||
public async newIssues(payload: Version3Parameters.CreateIssues): Promise<Version3Models.CreatedIssues> {
|
||||
return await this._client.issues.createIssues(payload)
|
||||
}
|
||||
|
||||
public async updateIssue(issueUpdate: Version3Parameters.EditIssue): Promise<void> {
|
||||
await this._client.issues.editIssue(issueUpdate)
|
||||
}
|
||||
|
||||
public async getCurrentUser(): Promise<Version3Models.User> {
|
||||
return await this._client.myself.getCurrentUser()
|
||||
}
|
||||
|
||||
public async assignIssue(issueIdOrKey: string, accountId: string | null): Promise<void> {
|
||||
await this._client.sendRequest<void>(
|
||||
{
|
||||
url: `/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/assignee`,
|
||||
method: 'PUT',
|
||||
data: { accountId },
|
||||
},
|
||||
undefined as never
|
||||
)
|
||||
}
|
||||
|
||||
public async deleteIssue(issueIdOrKey: string, deleteSubtasks: boolean = false): Promise<void> {
|
||||
await this._client.issues.deleteIssue({
|
||||
issueIdOrKey,
|
||||
deleteSubtasks: String(deleteSubtasks) as 'true' | 'false',
|
||||
})
|
||||
}
|
||||
|
||||
public async getIssue(params: Version3Parameters.GetIssue): Promise<Version3Models.Issue> {
|
||||
return await this._client.issues.getIssue(params)
|
||||
}
|
||||
|
||||
public async searchIssues(params: EnhancedSearchRequest): Promise<EnhancedSearchResponse> {
|
||||
return await this._client.sendRequest<EnhancedSearchResponse>(
|
||||
{
|
||||
url: '/rest/api/3/search/jql',
|
||||
method: 'POST',
|
||||
data: params,
|
||||
},
|
||||
undefined as never
|
||||
)
|
||||
}
|
||||
|
||||
public async getIssueTransitions(params: Version3Parameters.GetTransitions): Promise<Version3Models.Transitions> {
|
||||
return await this._client.issues.getTransitions(params)
|
||||
}
|
||||
|
||||
public async transitionIssue(params: Version3Parameters.DoTransition): Promise<void> {
|
||||
await this._client.issues.doTransition(params)
|
||||
}
|
||||
|
||||
public async listProjects(params: Version3Parameters.SearchProjects): Promise<Version3Models.PageProject> {
|
||||
return await this._client.projects.searchProjects(params)
|
||||
}
|
||||
|
||||
public async listIssueTypesForProject(projectIdOrKey: string): Promise<CreateMetaIssueTypesPage> {
|
||||
return await this._client.sendRequest<CreateMetaIssueTypesPage>(
|
||||
{
|
||||
url: `/rest/api/3/issue/createmeta/${encodeURIComponent(projectIdOrKey)}/issuetypes`,
|
||||
method: 'GET',
|
||||
},
|
||||
undefined as never
|
||||
)
|
||||
}
|
||||
|
||||
public async countIssues(jql: string): Promise<number> {
|
||||
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<IssuePickerResponse> {
|
||||
const params = new URLSearchParams({ query })
|
||||
if (currentJql) params.set('currentJQL', currentJql)
|
||||
return await this._client.sendRequest<IssuePickerResponse>(
|
||||
{
|
||||
url: `/rest/api/3/issue/picker?${params.toString()}`,
|
||||
method: 'GET',
|
||||
},
|
||||
undefined as never
|
||||
)
|
||||
}
|
||||
|
||||
public async listProjectStatuses(projectIdOrKey: string): Promise<Version3Models.IssueTypeWithStatus[]> {
|
||||
return await this._client.projects.getAllStatuses(projectIdOrKey)
|
||||
}
|
||||
|
||||
public async addCommentToIssue(issueIdOrKey: string, body: string): Promise<string> {
|
||||
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 addAttachmentToIssue(
|
||||
issueIdOrKey: string,
|
||||
attachment: AttachmentInput
|
||||
): Promise<Version3Models.Attachment[]> {
|
||||
const form = new FormData()
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([attachment.data], attachment.contentType ? { type: attachment.contentType } : undefined),
|
||||
attachment.filename
|
||||
)
|
||||
|
||||
return await this._client.sendRequest<Version3Models.Attachment[]>(
|
||||
{
|
||||
url: `/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/attachments`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Atlassian-Token': 'no-check',
|
||||
},
|
||||
data: form,
|
||||
},
|
||||
undefined as never
|
||||
)
|
||||
}
|
||||
|
||||
public async findUser(query: string): Promise<Version3Models.User> {
|
||||
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<Version3Models.User[]> {
|
||||
return await this._client.users.getAllUsers(addParams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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,
|
||||
addAttachmentInputSchema,
|
||||
addAttachmentOutputSchema,
|
||||
assignIssueInputSchema,
|
||||
assignIssueOutputSchema,
|
||||
deleteIssueInputSchema,
|
||||
deleteIssueOutputSchema,
|
||||
countIssuesInputSchema,
|
||||
countIssuesOutputSchema,
|
||||
pickIssueInputSchema,
|
||||
pickIssueOutputSchema,
|
||||
} from '../misc/custom-schemas'
|
||||
|
||||
type SdkActions = NonNullable<IntegrationDefinitionProps['actions']>
|
||||
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 addAttachment = {
|
||||
title: 'Add Attachment',
|
||||
description: 'Upload a file or image as an attachment on a Jira issue.',
|
||||
input: {
|
||||
schema: addAttachmentInputSchema,
|
||||
},
|
||||
output: {
|
||||
schema: addAttachmentOutputSchema,
|
||||
},
|
||||
} 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,
|
||||
addAttachment,
|
||||
} satisfies SdkActions
|
||||
@@ -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<IntegrationDefinitionProps['channels']>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z, type IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export { actions } from './actions'
|
||||
export { channels } from './channels'
|
||||
|
||||
export const configuration = {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
},
|
||||
schema: z.object({}),
|
||||
} satisfies IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const states = {
|
||||
oAuthCredentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
accessToken: z.string().secret().describe('The Atlassian OAuth access token'),
|
||||
refreshToken: z.string().secret().describe('The rotating Atlassian OAuth refresh token'),
|
||||
expiresAt: z.string().datetime().describe('The timestamp of when the access token expires'),
|
||||
scopes: z.array(z.string()).describe('The scopes granted to the token'),
|
||||
}),
|
||||
},
|
||||
manualCredentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
host: z.string().url().describe('Jira Cloud host URL, such as https://example.atlassian.net'),
|
||||
email: z.string().email().describe('Atlassian account email used for Jira API authentication'),
|
||||
apiToken: z.string().secret().describe('Atlassian API token used for Jira API authentication'),
|
||||
}),
|
||||
},
|
||||
oauthSession: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
state: z.string().describe('The OAuth state paired with the in-flight authorization request'),
|
||||
createdAt: z.string().datetime().describe('The timestamp of when the OAuth state was issued'),
|
||||
}),
|
||||
},
|
||||
configuration: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
authType: z.enum(['oauth', 'manual']).describe('The Jira authentication mode'),
|
||||
host: z.string().url().describe('The selected Jira Cloud host URL'),
|
||||
cloudId: z.string().optional().describe('The selected Atlassian cloud ID for OAuth requests'),
|
||||
}),
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
|
||||
export const secrets = {
|
||||
CLIENT_ID: {
|
||||
description: 'The client ID of the Atlassian OAuth app.',
|
||||
},
|
||||
CLIENT_SECRET: {
|
||||
description: 'The client secret of the Atlassian OAuth app.',
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['secrets']
|
||||
|
||||
export const user = {
|
||||
tags: {},
|
||||
} satisfies IntegrationDefinitionProps['user']
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
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://<host>/browse/<KEY>)'),
|
||||
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 jiraAttachmentSchema = z.object({
|
||||
id: z.string().title('Attachment ID').describe('Jira identifier of the uploaded attachment'),
|
||||
filename: z.string().optional().title('Filename').describe('Attachment filename'),
|
||||
mimeType: z.string().optional().title('MIME Type').describe('Attachment MIME type'),
|
||||
size: z.number().optional().title('Size').describe('Attachment size in bytes'),
|
||||
self: z.string().optional().title('Self URL').describe('Jira API URL for the attachment metadata'),
|
||||
content: z.string().optional().title('Content URL').describe('Jira API URL for downloading the attachment content'),
|
||||
thumbnail: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Thumbnail URL')
|
||||
.describe('Jira API URL for the attachment thumbnail, when available'),
|
||||
})
|
||||
|
||||
export const addAttachmentInputSchema = z.object({
|
||||
issueKey: z.string().title('Issue Key').describe('Key or ID of the Jira issue to attach the file to'),
|
||||
filename: z.string().title('Filename').describe('Name Jira should use for the uploaded attachment'),
|
||||
contentType: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Content Type')
|
||||
.describe('MIME content type of the file, such as image/png or application/pdf'),
|
||||
fileUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.title('File URL')
|
||||
.describe('URL to download the file from before uploading to Jira'),
|
||||
data: z.string().optional().title('Data').describe('Base64-encoded file content to upload to Jira'),
|
||||
})
|
||||
|
||||
export const addAttachmentOutputSchema = z.object({
|
||||
issueKey: z.string().title('Issue Key').describe('Key or ID of the Jira issue that received the attachment'),
|
||||
attachments: z.array(jiraAttachmentSchema).title('Attachments').describe('Attachments returned by Jira'),
|
||||
})
|
||||
|
||||
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'),
|
||||
})
|
||||
@@ -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']
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
|
||||
import * as wizard from './wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const oauthWizardHandler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
|
||||
if (!isOAuthWizardUrl(req.path)) {
|
||||
return {
|
||||
status: 404,
|
||||
body: 'Invalid OAuth wizard endpoint',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await wizard.handler(props)
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown : Error(String(thrown))
|
||||
const errorMessage = 'OAuth wizard error: ' + error.message
|
||||
logger.forBot().error(errorMessage)
|
||||
return generateRedirection(getInterstitialUrl(false, errorMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { z, type Response } from '@botpress/sdk'
|
||||
import { JiraApi } from '../client'
|
||||
import { JiraOAuthClient } from '../client/auth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const REQUIRED_JIRA_SCOPES = [
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'read:jira-user',
|
||||
'manage:jira-project',
|
||||
'manage:jira-configuration',
|
||||
'offline_access',
|
||||
]
|
||||
|
||||
const OAUTH_SESSION_MAX_AGE_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
const _getRedirectUri = () => oauthWizard.getWizardStepUrl('oauth-callback').toString()
|
||||
|
||||
const _manualCredentialsSchema = 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'),
|
||||
})
|
||||
|
||||
const _manualCredentialsForm = {
|
||||
pageTitle: 'Jira API Token Setup',
|
||||
htmlOrMarkdownPageContents:
|
||||
'Enter your Jira Cloud host, Atlassian account email, and API token.<br>' +
|
||||
'You can create an API token from your Atlassian account security settings.',
|
||||
schema: _manualCredentialsSchema,
|
||||
nextStepId: 'save-manual-credentials',
|
||||
}
|
||||
|
||||
export const handler = async (props: bp.HandlerProps): Promise<Response> => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startHandler })
|
||||
.addStep({ id: 'route-choice', handler: _routeChoiceHandler })
|
||||
.addStep({ id: 'oauth-redirect', handler: _oauthRedirectHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'pick-site', handler: _pickSiteHandler })
|
||||
.addStep({ id: 'save-site', handler: _saveSiteHandler })
|
||||
.addStep({ id: 'get-manual-credentials', handler: _getManualCredentialsHandler })
|
||||
.addStep({ id: 'save-manual-credentials', handler: _saveManualCredentialsHandler })
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayChoices({
|
||||
pageTitle: 'Jira Integration Setup',
|
||||
htmlOrMarkdownPageContents: 'Choose how you would like to configure your Jira integration:',
|
||||
choices: [
|
||||
{ label: 'Connect with OAuth', value: 'oauth' },
|
||||
{ label: 'Use an API Token', value: 'manual' },
|
||||
],
|
||||
nextStepId: 'route-choice',
|
||||
})
|
||||
|
||||
const _routeChoiceHandler: WizardHandler = ({ selectedChoice, responses }) => {
|
||||
if (selectedChoice === 'manual') {
|
||||
return responses.redirectToStep('get-manual-credentials')
|
||||
}
|
||||
return responses.redirectToStep('oauth-redirect')
|
||||
}
|
||||
|
||||
const _oauthRedirectHandler: WizardHandler = async ({ ctx, client, responses }) => {
|
||||
try {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauthSession',
|
||||
id: ctx.integrationId,
|
||||
payload: { state: ctx.webhookId, createdAt: new Date().toISOString() },
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
audience: 'api.atlassian.com',
|
||||
client_id: bp.secrets.CLIENT_ID,
|
||||
scope: REQUIRED_JIRA_SCOPES.join(' '),
|
||||
redirect_uri: _getRedirectUri(),
|
||||
state: ctx.webhookId,
|
||||
response_type: 'code',
|
||||
prompt: 'consent',
|
||||
})
|
||||
|
||||
return responses.redirectToExternalUrl(`https://auth.atlassian.com/authorize?${params.toString()}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ ctx, client, logger, responses, query }) => {
|
||||
try {
|
||||
const error = query.get('error')
|
||||
if (error) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `${error} - ${query.get('error_description') ?? ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
if (!code) {
|
||||
return responses.endWizard({ success: false, errorMessage: 'Jira did not return an authorization code' })
|
||||
}
|
||||
|
||||
const state = query.get('state')
|
||||
const { state: sessionState } = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'oauthSession',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
if (!state || state !== ctx.webhookId || sessionState.payload.state !== ctx.webhookId) {
|
||||
return responses.endWizard({ success: false, errorMessage: 'Invalid OAuth state parameter' })
|
||||
}
|
||||
|
||||
const createdAt = sessionState.payload.createdAt
|
||||
if (!createdAt || Date.now() - new Date(createdAt).getTime() > OAUTH_SESSION_MAX_AGE_MS) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'OAuth session has expired. Please restart the setup wizard.',
|
||||
})
|
||||
}
|
||||
|
||||
const oauth = new JiraOAuthClient({ client, ctx, logger })
|
||||
await oauth.exchangeAuthorizationCode(code, _getRedirectUri())
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauthSession',
|
||||
id: ctx.integrationId,
|
||||
payload: { state: '', createdAt: new Date(0).toISOString() },
|
||||
})
|
||||
|
||||
return responses.redirectToStep('pick-site')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
logger.forBot().error(`Jira wizard step failed: ${message}`, { error })
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
}
|
||||
|
||||
const _pickSiteHandler: WizardHandler = async ({ ctx, client, logger, responses }) => {
|
||||
try {
|
||||
const oauth = new JiraOAuthClient({ client, ctx, logger })
|
||||
const accessToken = await oauth.getAccessToken()
|
||||
const sites = await oauth.listAccessibleJiraResources(accessToken)
|
||||
|
||||
if (sites.length === 0) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'No Jira sites were found for this Atlassian account',
|
||||
})
|
||||
}
|
||||
|
||||
if (sites.length === 1) {
|
||||
const site = sites[0]!
|
||||
await _saveOAuthSite({ ctx, client, siteId: site.id, host: site.url })
|
||||
await oauth.clearManualCredentials()
|
||||
return responses.endWizard({ success: true })
|
||||
}
|
||||
|
||||
return responses.displayChoices({
|
||||
pageTitle: 'Select a Jira Site',
|
||||
htmlOrMarkdownPageContents: 'Pick the Jira site you want this integration to use.',
|
||||
choices: sites.map((site) => ({ label: site.name || site.url, value: `${site.id}|${site.url}` })),
|
||||
nextStepId: 'save-site',
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
logger.forBot().error(`Jira wizard step failed: ${message}`, { error })
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
}
|
||||
|
||||
const _saveSiteHandler: WizardHandler = async ({ ctx, client, logger, selectedChoice, responses }) => {
|
||||
try {
|
||||
if (!selectedChoice) {
|
||||
return responses.redirectToStep('pick-site')
|
||||
}
|
||||
|
||||
const [siteId, host] = selectedChoice.split('|')
|
||||
if (!siteId || !host) {
|
||||
return responses.redirectToStep('pick-site')
|
||||
}
|
||||
|
||||
await _saveOAuthSite({ ctx, client, siteId, host })
|
||||
const oauth = new JiraOAuthClient({ client, ctx, logger })
|
||||
await oauth.clearManualCredentials()
|
||||
return responses.endWizard({ success: true })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
logger.forBot().error(`Jira wizard step failed: ${message}`, { error })
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
}
|
||||
|
||||
const _getManualCredentialsHandler: WizardHandler = ({ responses }) => responses.displayForm(_manualCredentialsForm)
|
||||
|
||||
const _saveManualCredentialsHandler: WizardHandler = async ({ ctx, client, logger, formValues, responses }) => {
|
||||
try {
|
||||
if (!formValues) {
|
||||
return responses.redirectToStep('get-manual-credentials')
|
||||
}
|
||||
|
||||
const parsed = _manualCredentialsSchema.safeParse(formValues)
|
||||
if (!parsed.success) {
|
||||
return responses.displayForm({
|
||||
..._manualCredentialsForm,
|
||||
errors: parsed.error,
|
||||
previousValues: formValues as z.input<typeof _manualCredentialsSchema>,
|
||||
})
|
||||
}
|
||||
|
||||
const jiraClient = JiraApi.fromBasicAuth(parsed.data.host, parsed.data.email, parsed.data.apiToken)
|
||||
try {
|
||||
await jiraClient.getCurrentUser()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid Jira credentials'
|
||||
const syntheticParse = _manualCredentialsSchema.safeParse({})
|
||||
if (!syntheticParse.success) {
|
||||
syntheticParse.error.issues = [{ message, path: ['apiToken'], code: 'custom' } satisfies z.ZodIssue]
|
||||
return responses.displayForm({
|
||||
..._manualCredentialsForm,
|
||||
errors: syntheticParse.error,
|
||||
previousValues: parsed.data,
|
||||
})
|
||||
}
|
||||
// Should never reach here since empty object fails validation
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
|
||||
const oauth = new JiraOAuthClient({ client, ctx, logger })
|
||||
await oauth.saveManualCredentials(parsed.data)
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
payload: { authType: 'manual', host: parsed.data.host },
|
||||
})
|
||||
await client.configureIntegration({ identifier: parsed.data.host })
|
||||
|
||||
return responses.endWizard({ success: true })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
logger.forBot().error(`Jira wizard step failed: ${message}`, { error })
|
||||
return responses.endWizard({ success: false, errorMessage: message })
|
||||
}
|
||||
}
|
||||
|
||||
const _saveOAuthSite = async ({
|
||||
ctx,
|
||||
client,
|
||||
siteId,
|
||||
host,
|
||||
}: {
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
siteId: string
|
||||
host: string
|
||||
}) => {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
payload: { authType: 'oauth', cloudId: siteId, host },
|
||||
})
|
||||
await client.configureIntegration({ identifier: host })
|
||||
}
|
||||
@@ -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 ({ client, 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 = await getClient({ client, ctx, logger })
|
||||
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}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
|
||||
|
||||
import type { Handler } from '../misc/types'
|
||||
import { oauthWizardHandler } from '../oauth-wizard'
|
||||
|
||||
export const handler: Handler = async (props) => {
|
||||
if (isOAuthWizardUrl(props.req.path)) {
|
||||
return await oauthWizardHandler(props)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { register } from './register'
|
||||
export { unregister } from './unregister'
|
||||
export { channels } from './channels'
|
||||
export { handler } from './handler'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import type { RegisterFunction } from '../misc/types'
|
||||
import { getClient } from '../utils'
|
||||
|
||||
export const register: RegisterFunction = async ({ client, ctx, logger }) => {
|
||||
const jiraClient = await getClient({ client, ctx, logger })
|
||||
try {
|
||||
await jiraClient.getCurrentUser()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error)
|
||||
throw new RuntimeError(`Invalid Jira configuration: ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { UnregisterFunction } from '../misc/types'
|
||||
|
||||
export const unregister: UnregisterFunction = async () => {}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { isApiError, RuntimeError } from '@botpress/sdk'
|
||||
import type { Version3Models } from 'jira.js'
|
||||
import { JiraApi } from '../client'
|
||||
import { JiraOAuthClient } from '../client/auth'
|
||||
import { textToAdfDocument } from '../misc/adf'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ClientProps = {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
logger: bp.Logger
|
||||
}
|
||||
|
||||
const _getLegacyManualConfig = (ctx: bp.Context) => {
|
||||
const config = ctx.configuration as unknown
|
||||
if (!config || typeof config !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const { host, email, apiToken } = config as Record<string, unknown>
|
||||
if (typeof host === 'string' && typeof email === 'string' && typeof apiToken === 'string') {
|
||||
return { host, email, apiToken }
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
export const getClient = async ({ client, ctx, logger }: ClientProps): Promise<JiraApi> => {
|
||||
const configuration = await client
|
||||
.getState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
.then(({ state }) => state.payload)
|
||||
.catch((e: unknown) => {
|
||||
if (isApiError(e) && e.type === 'ResourceNotFound') {
|
||||
return undefined
|
||||
}
|
||||
logger.forBot().error('Failed to read Jira configuration state', { error: e })
|
||||
throw e
|
||||
})
|
||||
|
||||
if (configuration?.authType === 'manual') {
|
||||
const { state } = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'manualCredentials',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
return JiraApi.fromBasicAuth(state.payload.host, state.payload.email, state.payload.apiToken)
|
||||
}
|
||||
|
||||
if (configuration?.authType === 'oauth' && configuration.cloudId) {
|
||||
const oauth = new JiraOAuthClient({ client, ctx, logger })
|
||||
const accessToken = await oauth.getAccessToken()
|
||||
return JiraApi.fromOAuth(configuration.cloudId, accessToken, configuration.host)
|
||||
}
|
||||
|
||||
const legacyManualConfig = _getLegacyManualConfig(ctx)
|
||||
if (legacyManualConfig) {
|
||||
return JiraApi.fromBasicAuth(legacyManualConfig.host, legacyManualConfig.email, legacyManualConfig.apiToken)
|
||||
}
|
||||
|
||||
throw new RuntimeError('Jira is not configured. Re-run the Jira setup wizard.')
|
||||
}
|
||||
|
||||
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<Map<string, string>> => {
|
||||
const projectKeys = new Set(issues.map((i) => i.projectKey))
|
||||
const key = (projectKey: string, typeName: string) => `${projectKey}::${typeName}`
|
||||
const nameToId = new Map<string, string>()
|
||||
|
||||
for (const projectKey of projectKeys) {
|
||||
let response: Awaited<ReturnType<JiraApi['listIssueTypesForProject']>>
|
||||
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<string, unknown>
|
||||
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<string, string>
|
||||
errorMessages?: string[]
|
||||
status?: number
|
||||
statusText?: string
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
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<string, string> | 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}`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user