Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions .github/actions/deploy-integrations/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ inputs:
cloud_ops_workspace_id:
description: 'Cloud Ops workspace id'
required: true
shopify_admin_automation_token:
description: 'Shopify app automation token for the Shopify Admin app (app-scoped, env-specific)'
required: false
default: ''
shopify_storefront_automation_token:
description: 'Shopify app automation token for the Shopify Storefront app (app-scoped, env-specific)'
required: false
default: ''

runs:
using: 'composite'
Expand Down Expand Up @@ -71,6 +79,9 @@ runs:
INPUT_EXTRA_FILTER: ${{ inputs.extra_filter }}
TOKEN_CLOUD_OPS_ACCOUNT: ${{ inputs.token_cloud_ops_account }}
CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }}
SHOPIFY_ADMIN_AUTOMATION_TOKEN: ${{ inputs.shopify_admin_automation_token }}
SHOPIFY_STOREFRONT_AUTOMATION_TOKEN: ${{ inputs.shopify_storefront_automation_token }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
SENTRY_RELEASE: ${{ github.sha }}
SENTRY_ENVIRONMENT: ${{ inputs.environment }}
shell: bash
Expand Down Expand Up @@ -112,25 +123,44 @@ runs:

base_command="bp deploy -v -y --noBuild --visibility public --allowDeprecated $dryrun"

upload_sandbox_scripts=false
integration_deployed=false
if [ "$exists" -eq 0 ]; then
echo -e "\nDeploying integration: ### $integration ###\n"
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command"
upload_sandbox_scripts=true
integration_deployed=true
elif [ "$redeploy" -eq 1 ]; then
echo -e "\nRe-deploying integration: ### $integration ###\n"
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command"
upload_sandbox_scripts=true
integration_deployed=true
else
echo -e "\nSkipping integration: ### $integration ###\n"
fi

# upload sandbox scripts
integration_implements_sandbox=$(./.github/scripts/integration-implements-sandbox.sh "$integration")
if [ "$integration_implements_sandbox" = "true" ] && [ "$upload_sandbox_scripts" = "true" ] && [ "$is_dry_run" -eq 0 ]; then
if [ "$integration_implements_sandbox" = "true" ] && [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ]; then
echo -e "\nUploading integration sandbox scripts\n"
base_upload_command="uploadSandboxScripts --apiUrl=$api_url --workspaceId=$CLOUD_OPS_WORKSPACE_ID --token=$TOKEN_CLOUD_OPS_ACCOUNT --userEmail=cloud-ops@botpress.com"
# shellcheck disable=SC2086 # base_upload_command contains multiple space-separated arguments
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" run -- $base_upload_command
fi

# deploy shopify app manifest (config-as-code) for the current environment
manifest_file="$integration_path/shopify.app.$INPUT_ENVIRONMENT.toml"
if [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ] && [ -f "$manifest_file" ]; then
case "$integration" in
shopify-admin) shopify_token="$SHOPIFY_ADMIN_AUTOMATION_TOKEN" ;;
shopify-storefront) shopify_token="$SHOPIFY_STOREFRONT_AUTOMATION_TOKEN" ;;
*) shopify_token="" ;;
esac

if [ -z "$shopify_token" ]; then
echo "::warning::Found $manifest_file but no Shopify automation token for $integration - skipping manifest deploy"
else
echo -e "\nDeploying Shopify app manifest ($INPUT_ENVIRONMENT): ### $integration ###\n"
shopify_deploy_command="pnpm shopify app deploy --config $INPUT_ENVIRONMENT --allow-updates --source-control-url $COMMIT_URL"
SHOPIFY_APP_AUTOMATION_TOKEN="$shopify_token" \
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$shopify_deploy_command"
fi
fi
done
2 changes: 2 additions & 0 deletions .github/workflows/deploy-integrations-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
shopify_admin_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_ADMIN_AUTOMATION_TOKEN }}
shopify_storefront_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }}
- name: Deploy Plugins
uses: ./.github/actions/deploy-plugins
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-integrations-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}
shopify_admin_automation_token: ${{ secrets.STAGING_SHOPIFY_ADMIN_AUTOMATION_TOKEN }}
shopify_storefront_automation_token: ${{ secrets.STAGING_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }}
- name: Deploy Plugins
uses: ./.github/actions/deploy-plugins
if: ${{ github.event_name != 'pull_request' }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ __snapshots__
.botpress
.botpresshome
.botpresshome.*
.shopify
.turbo
.genenv/
.genenv.*
Expand Down
2 changes: 1 addition & 1 deletion integrations/jira/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default new IntegrationDefinition({
title: 'Jira',
description:
'This integration allows you to work with your Jira workspace, users, projects, and workflow transitions.',
version: '0.4.0',
version: '0.5.0',
readme: 'readme.md',
icon: 'icon.svg',
configuration,
Expand Down
6 changes: 4 additions & 2 deletions integrations/jira/readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Botpress Jira Software Integration

This integration allows you to connect your Botpress chatbot with Jira Software, a popular platform for project management and issue tracking. With this integration, you can search, create, update, and transition issues, list projects, find Jira users, and post issue comments from your chatbot.
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**.

Expand Down Expand Up @@ -31,12 +31,14 @@ To enable the Jira Software integration in Botpress, follow these steps:

Once the integration is enabled, you can start using Jira from your Botpress chatbot. The integration offers the following actions:

- **Issues**: `searchIssues` (JQL with cursor pagination), `countIssues`, `pickIssue`, `getIssue`, `newIssue`, `newIssues` (batch up to 50), `updateIssue`, `assignIssue`, `deleteIssue`, `getIssueTransitions`, `transitionIssue`
- **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.
Expand Down
49 changes: 49 additions & 0 deletions integrations/jira/src/actions/add-attachment.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
2 changes: 2 additions & 0 deletions integrations/jira/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addAttachment } from './add-attachment'
import { assignIssue } from './assign-issue'
import { countIssues } from './count-issues'
import { deleteIssue } from './delete-issue'
Expand Down Expand Up @@ -32,4 +33,5 @@ export default {
transitionIssue,
listIssueTypes,
listProjectStatuses,
addAttachment,
}
31 changes: 31 additions & 0 deletions integrations/jira/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export type IssuePickerResponse = {
}>
}

export type AttachmentInput = {
filename: string
contentType?: string
data: ArrayBuffer | Uint8Array
}

export class JiraApi {
private _client: Version3Client
public readonly host: string
Expand Down Expand Up @@ -191,6 +197,31 @@ export class JiraApi {
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,
Expand Down
14 changes: 14 additions & 0 deletions integrations/jira/src/definitions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
listIssueTypesOutputSchema,
listProjectStatusesInputSchema,
listProjectStatusesOutputSchema,
addAttachmentInputSchema,
addAttachmentOutputSchema,
assignIssueInputSchema,
assignIssueOutputSchema,
deleteIssueInputSchema,
Expand Down Expand Up @@ -173,6 +175,17 @@ const listProjectStatuses = {
},
} 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:
Expand Down Expand Up @@ -222,4 +235,5 @@ export const actions = {
transitionIssue,
listIssueTypes,
listProjectStatuses,
addAttachment,
} satisfies SdkActions
36 changes: 36 additions & 0 deletions integrations/jira/src/misc/custom-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,42 @@ 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)
Expand Down
Loading
Loading