diff --git a/integrations/jira/integration.definition.ts b/integrations/jira/integration.definition.ts index 491a3c53b3e..3c211f88503 100644 --- a/integrations/jira/integration.definition.ts +++ b/integrations/jira/integration.definition.ts @@ -1,13 +1,13 @@ import { IntegrationDefinition } from '@botpress/sdk' -import { configuration, states, user, channels, actions } from './src/definitions' +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.3.0', + version: '0.4.0', readme: 'readme.md', icon: 'icon.svg', configuration, @@ -16,6 +16,7 @@ export default new IntegrationDefinition({ actions, events: {}, states, + secrets, attributes: { category: 'Project Management', repo: 'botpress', diff --git a/integrations/jira/linkTemplate.vrl b/integrations/jira/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/jira/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/jira/readme.md b/integrations/jira/readme.md index d5a759fdd8a..9cb149c16a5 100644 --- a/integrations/jira/readme.md +++ b/integrations/jira/readme.md @@ -2,7 +2,7 @@ This integration allows you to connect your Botpress chatbot with Jira Software, a popular platform for project management and issue tracking. With this integration, you can search, create, update, and transition issues, list projects, find Jira users, and post issue comments from your chatbot. -To set up the integration, you will need to provide your **host**, **email**, and **API token** credentials. Once the integration is set up, you can use the built-in actions to manage issues and projects, and use the issue comments channel to post comments. +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. @@ -12,7 +12,8 @@ Before enabling the Botpress Jira Software Integration, please ensure that you h - A Botpress cloud account. - Access to a Jira Software account. -- API token generated from your 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 @@ -21,8 +22,10 @@ To enable the Jira Software integration in Botpress, follow these steps: - Access your Botpress admin panel. - Navigate to the “Integrations” section. - Locate the Jira Software integration and click on "Install Integration". -- Provide the required API token, host, and email configuration details. -- Save the configuration. +- 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 diff --git a/integrations/jira/src/actions/assign-issue.ts b/integrations/jira/src/actions/assign-issue.ts index e728b104675..847d4811020 100644 --- a/integrations/jira/src/actions/assign-issue.ts +++ b/integrations/jira/src/actions/assign-issue.ts @@ -5,9 +5,9 @@ import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const assignIssue: Implementation['actions']['assignIssue'] = async ({ ctx, input, logger }) => { +export const assignIssue: Implementation['actions']['assignIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = assignIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { await jiraClient.assignIssue(validatedInput.issueKey, validatedInput.accountId) diff --git a/integrations/jira/src/actions/count-issues.ts b/integrations/jira/src/actions/count-issues.ts index 7ef329ae60e..8a423f03aee 100644 --- a/integrations/jira/src/actions/count-issues.ts +++ b/integrations/jira/src/actions/count-issues.ts @@ -3,9 +3,9 @@ import type { Implementation } from '../misc/types' import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' -export const countIssues: Implementation['actions']['countIssues'] = async ({ ctx, input, logger }) => { +export const countIssues: Implementation['actions']['countIssues'] = async ({ client, ctx, input, logger }) => { const validatedInput = countIssuesInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const count = await jiraClient.countIssues(validatedInput.jql) diff --git a/integrations/jira/src/actions/delete-issue.ts b/integrations/jira/src/actions/delete-issue.ts index 1c27c2b1134..0afa0898a50 100644 --- a/integrations/jira/src/actions/delete-issue.ts +++ b/integrations/jira/src/actions/delete-issue.ts @@ -5,9 +5,9 @@ import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const deleteIssue: Implementation['actions']['deleteIssue'] = async ({ ctx, input, logger }) => { +export const deleteIssue: Implementation['actions']['deleteIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = deleteIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { await jiraClient.deleteIssue(validatedInput.issueKey, validatedInput.deleteSubtasks ?? false) diff --git a/integrations/jira/src/actions/find-all-users.ts b/integrations/jira/src/actions/find-all-users.ts index 3a9935f6a3c..ee08bbf3f8c 100644 --- a/integrations/jira/src/actions/find-all-users.ts +++ b/integrations/jira/src/actions/find-all-users.ts @@ -3,9 +3,9 @@ import type { Implementation } from '../misc/types' import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' -export const findAllUsers: Implementation['actions']['findAllUsers'] = async ({ ctx, input, logger }) => { +export const findAllUsers: Implementation['actions']['findAllUsers'] = async ({ client, ctx, input, logger }) => { const validatedInput = findAllUsersInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) const addParams = { startAt: validatedInput.startAt, maxResults: validatedInput.maxResults, diff --git a/integrations/jira/src/actions/find-user.ts b/integrations/jira/src/actions/find-user.ts index 839e9e8f650..25f440e038b 100644 --- a/integrations/jira/src/actions/find-user.ts +++ b/integrations/jira/src/actions/find-user.ts @@ -4,9 +4,9 @@ import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const findUser: Implementation['actions']['findUser'] = async ({ ctx, input, logger }) => { +export const findUser: Implementation['actions']['findUser'] = async ({ client, ctx, input, logger }) => { const validatedInput = findUserInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.findUser(validatedInput.query) logger diff --git a/integrations/jira/src/actions/get-issue-transitions.ts b/integrations/jira/src/actions/get-issue-transitions.ts index 0eb18e638e6..b7418034ba6 100644 --- a/integrations/jira/src/actions/get-issue-transitions.ts +++ b/integrations/jira/src/actions/get-issue-transitions.ts @@ -3,9 +3,14 @@ import { getIssueTransitionsInputSchema, getIssueTransitionsOutputSchema } from import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const getIssueTransitions: Implementation['actions']['getIssueTransitions'] = async ({ ctx, input, logger }) => { +export const getIssueTransitions: Implementation['actions']['getIssueTransitions'] = async ({ + client, + ctx, + input, + logger, +}) => { const validatedInput = getIssueTransitionsInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.getIssueTransitions({ diff --git a/integrations/jira/src/actions/get-issue.ts b/integrations/jira/src/actions/get-issue.ts index 13f191c2a08..1b7db9c03d5 100644 --- a/integrations/jira/src/actions/get-issue.ts +++ b/integrations/jira/src/actions/get-issue.ts @@ -5,9 +5,9 @@ import type { Implementation } from '../misc/types' import { ISSUE_SEARCH_FIELDS, flattenIssue, getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const getIssue: Implementation['actions']['getIssue'] = async ({ ctx, input, logger }) => { +export const getIssue: Implementation['actions']['getIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = getIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.getIssue({ @@ -15,7 +15,7 @@ export const getIssue: Implementation['actions']['getIssue'] = async ({ ctx, inp fields: ISSUE_SEARCH_FIELDS, }) logger.forBot().info(`Successful - Get Issue - ${response.key}`) - return getIssueOutputSchema.parse(flattenIssue(response, ctx.configuration.host)) + return getIssueOutputSchema.parse(flattenIssue(response, jiraClient.host)) } catch (error) { logger.forBot().debug(`'Get Issue' exception ${serializeErrorForLog(error)}`) const message = getErrorMessage(error) diff --git a/integrations/jira/src/actions/list-issue-types.ts b/integrations/jira/src/actions/list-issue-types.ts index 0a45c6f6a1d..f0d39cb22d0 100644 --- a/integrations/jira/src/actions/list-issue-types.ts +++ b/integrations/jira/src/actions/list-issue-types.ts @@ -5,9 +5,9 @@ import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const listIssueTypes: Implementation['actions']['listIssueTypes'] = async ({ ctx, input, logger }) => { +export const listIssueTypes: Implementation['actions']['listIssueTypes'] = async ({ client, ctx, input, logger }) => { const validatedInput = listIssueTypesInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.listIssueTypesForProject(validatedInput.projectKey) diff --git a/integrations/jira/src/actions/list-project-statuses.ts b/integrations/jira/src/actions/list-project-statuses.ts index 5c3d53f1107..a9f80297bc7 100644 --- a/integrations/jira/src/actions/list-project-statuses.ts +++ b/integrations/jira/src/actions/list-project-statuses.ts @@ -3,9 +3,14 @@ import { listProjectStatusesInputSchema, listProjectStatusesOutputSchema } from import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog } from '../utils' -export const listProjectStatuses: Implementation['actions']['listProjectStatuses'] = async ({ ctx, input, logger }) => { +export const listProjectStatuses: Implementation['actions']['listProjectStatuses'] = async ({ + client, + ctx, + input, + logger, +}) => { const validatedInput = listProjectStatusesInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.listProjectStatuses(validatedInput.projectKey) diff --git a/integrations/jira/src/actions/list-projects.ts b/integrations/jira/src/actions/list-projects.ts index fe51d9c63fa..4205a19fa7d 100644 --- a/integrations/jira/src/actions/list-projects.ts +++ b/integrations/jira/src/actions/list-projects.ts @@ -8,9 +8,9 @@ import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' const DEFAULT_MAX_RESULTS = 50 const HARD_MAX_RESULTS = 100 -export const listProjects: Implementation['actions']['listProjects'] = async ({ ctx, input, logger }) => { +export const listProjects: Implementation['actions']['listProjects'] = async ({ client, ctx, input, logger }) => { const validatedInput = listProjectsInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + 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) diff --git a/integrations/jira/src/actions/new-issue.ts b/integrations/jira/src/actions/new-issue.ts index fe0788eccdd..15a517a5215 100644 --- a/integrations/jira/src/actions/new-issue.ts +++ b/integrations/jira/src/actions/new-issue.ts @@ -10,9 +10,9 @@ import { textToAdfDocument, } from '../utils' -export const newIssue: Implementation['actions']['newIssue'] = async ({ ctx, input, logger }) => { +export const newIssue: Implementation['actions']['newIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = newIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const issueTypeIds = await resolveIssueTypeIds(jiraClient, [ diff --git a/integrations/jira/src/actions/new-issues.ts b/integrations/jira/src/actions/new-issues.ts index 473e447076b..b0157b6b17c 100644 --- a/integrations/jira/src/actions/new-issues.ts +++ b/integrations/jira/src/actions/new-issues.ts @@ -6,7 +6,7 @@ import { buildRuntimeError, getClient, resolveIssueTypeIds, serializeErrorForLog type IssueInput = Version3Models.IssueUpdateDetails -export const newIssues: Implementation['actions']['newIssues'] = async ({ ctx, input, logger }) => { +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') @@ -14,7 +14,7 @@ export const newIssues: Implementation['actions']['newIssues'] = async ({ ctx, i if (validatedInput.issues.length > 50) { throw new RuntimeError(`Jira allows up to 50 issues per batch; received ${validatedInput.issues.length}`) } - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const issueTypeIds = await resolveIssueTypeIds(jiraClient, validatedInput.issues) diff --git a/integrations/jira/src/actions/pick-issue.ts b/integrations/jira/src/actions/pick-issue.ts index 988d99d0a70..47861141023 100644 --- a/integrations/jira/src/actions/pick-issue.ts +++ b/integrations/jira/src/actions/pick-issue.ts @@ -3,9 +3,9 @@ import type { Implementation } from '../misc/types' import { buildRuntimeError, getClient, serializeErrorForLog } from '../utils' -export const pickIssue: Implementation['actions']['pickIssue'] = async ({ ctx, input, logger }) => { +export const pickIssue: Implementation['actions']['pickIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = pickIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { const response = await jiraClient.pickIssue(validatedInput.query, validatedInput.currentJql) diff --git a/integrations/jira/src/actions/search-issues.ts b/integrations/jira/src/actions/search-issues.ts index 2d3cafa5eae..bd84ed0f4ee 100644 --- a/integrations/jira/src/actions/search-issues.ts +++ b/integrations/jira/src/actions/search-issues.ts @@ -7,9 +7,9 @@ const DEFAULT_MAX_RESULTS = 50 const HARD_MAX_RESULTS = 100 const DEFAULT_JQL = 'order by created DESC' -export const searchIssues: Implementation['actions']['searchIssues'] = async ({ ctx, input, logger }) => { +export const searchIssues: Implementation['actions']['searchIssues'] = async ({ client, ctx, input, logger }) => { const validatedInput = searchIssuesInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + 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 @@ -23,7 +23,7 @@ export const searchIssues: Implementation['actions']['searchIssues'] = async ({ }) const issues = response.issues ?? [] - const items = issues.map((issue) => flattenIssue(issue, ctx.configuration.host)) + 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) { diff --git a/integrations/jira/src/actions/transition-issue.ts b/integrations/jira/src/actions/transition-issue.ts index 3e73c681b4b..71db123e9cf 100644 --- a/integrations/jira/src/actions/transition-issue.ts +++ b/integrations/jira/src/actions/transition-issue.ts @@ -5,9 +5,9 @@ import type { Implementation } from '../misc/types' import { getClient, getErrorMessage, serializeErrorForLog, textToAdfDocument } from '../utils' -export const transitionIssue: Implementation['actions']['transitionIssue'] = async ({ ctx, input, logger }) => { +export const transitionIssue: Implementation['actions']['transitionIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = transitionIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) try { await jiraClient.transitionIssue({ diff --git a/integrations/jira/src/actions/update-issue.ts b/integrations/jira/src/actions/update-issue.ts index b068f96b692..6be41437c3f 100644 --- a/integrations/jira/src/actions/update-issue.ts +++ b/integrations/jira/src/actions/update-issue.ts @@ -11,9 +11,9 @@ import { textToAdfDocument, } from '../utils' -export const updateIssue: Implementation['actions']['updateIssue'] = async ({ ctx, input, logger }) => { +export const updateIssue: Implementation['actions']['updateIssue'] = async ({ client, ctx, input, logger }) => { const validatedInput = updateIssueInputSchema.parse(input) - const jiraClient = getClient(ctx.configuration) + const jiraClient = await getClient({ client, ctx, logger }) const fields: NonNullable = {} if (validatedInput.summary !== undefined) { diff --git a/integrations/jira/src/client/auth.ts b/integrations/jira/src/client/auth.ts new file mode 100644 index 00000000000..44d12916373 --- /dev/null +++ b/integrations/jira/src/client/auth.ts @@ -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 { + 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 { + 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 { + 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 { + 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): Promise { + 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) + } +} diff --git a/integrations/jira/src/client/index.ts b/integrations/jira/src/client/index.ts index 1c449c5a744..00d14ae2d71 100644 --- a/integrations/jira/src/client/index.ts +++ b/integrations/jira/src/client/index.ts @@ -1,4 +1,4 @@ -import { Version3Client, Version3Models, Version3Parameters } from 'jira.js' +import { Version3Client, Version3Models, Version3Parameters, type Config } from 'jira.js' import { textToAdfDocument } from '../misc/adf' export type EnhancedSearchRequest = { @@ -48,20 +48,38 @@ export type IssuePickerResponse = { export class JiraApi { private _client: Version3Client + public readonly host: string - public constructor(host: string, email: string, apiToken: string) { + private constructor(requestHost: string, authentication: Config.Authentication, browseHost: string = requestHost) { + this.host = browseHost.replace(/\/$/, '') this._client = new Version3Client({ - host, - authentication: { - basic: { - email, - apiToken, - }, - }, + 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 { const { key } = await this._client.issues.createIssue(issue) return key diff --git a/integrations/jira/src/definitions/index.ts b/integrations/jira/src/definitions/index.ts index f9297e727f2..d287c7dcb89 100644 --- a/integrations/jira/src/definitions/index.ts +++ b/integrations/jira/src/definitions/index.ts @@ -4,19 +4,55 @@ export { actions } from './actions' export { channels } from './channels' export const configuration = { - schema: z.object({ - host: z.string().url().title('Host URL').describe('Jira Cloud host URL, such as https://example.atlassian.net'), - email: z.string().email().title('Email').describe('Atlassian account email used for Jira API authentication'), - apiToken: z - .string() - .min(1) - .secret() - .title('API Token') - .describe('Atlassian API token used for Jira API authentication'), - }), + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + schema: z.object({}), } satisfies IntegrationDefinitionProps['configuration'] -export const states = {} satisfies IntegrationDefinitionProps['states'] +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: {}, diff --git a/integrations/jira/src/oauth-wizard/index.ts b/integrations/jira/src/oauth-wizard/index.ts new file mode 100644 index 00000000000..6a77f3708ef --- /dev/null +++ b/integrations/jira/src/oauth-wizard/index.ts @@ -0,0 +1,24 @@ +import { generateRedirection } from '@botpress/common/src/html-dialogs' +import { getInterstitialUrl, isOAuthWizardUrl } 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)) + } +} diff --git a/integrations/jira/src/oauth-wizard/wizard.ts b/integrations/jira/src/oauth-wizard/wizard.ts new file mode 100644 index 00000000000..0b57f58d39f --- /dev/null +++ b/integrations/jira/src/oauth-wizard/wizard.ts @@ -0,0 +1,270 @@ +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 + +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.
' + + '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 => { + 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 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, + }) + } + + 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 }) +} diff --git a/integrations/jira/src/setup/channels.ts b/integrations/jira/src/setup/channels.ts index 80da415cab9..682a39d28e5 100644 --- a/integrations/jira/src/setup/channels.ts +++ b/integrations/jira/src/setup/channels.ts @@ -5,13 +5,13 @@ import { getClient } from '../utils' export const channels: Channels = { issueComments: { messages: { - text: async ({ ctx, payload, conversation, ack, logger }) => { + 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 = getClient(ctx.configuration) + 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}`) diff --git a/integrations/jira/src/setup/handler.ts b/integrations/jira/src/setup/handler.ts index e488327a93c..ce7b7c17dfd 100644 --- a/integrations/jira/src/setup/handler.ts +++ b/integrations/jira/src/setup/handler.ts @@ -1,3 +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 () => {} +export const handler: Handler = async (props) => { + if (isOAuthWizardUrl(props.req.path)) { + return await oauthWizardHandler(props) + } + return +} diff --git a/integrations/jira/src/setup/register.ts b/integrations/jira/src/setup/register.ts index fb4bf61c614..52c684091d1 100644 --- a/integrations/jira/src/setup/register.ts +++ b/integrations/jira/src/setup/register.ts @@ -2,8 +2,8 @@ import { RuntimeError } from '@botpress/sdk' import type { RegisterFunction } from '../misc/types' import { getClient } from '../utils' -export const register: RegisterFunction = async ({ ctx }) => { - const jiraClient = getClient(ctx.configuration) +export const register: RegisterFunction = async ({ client, ctx, logger }) => { + const jiraClient = await getClient({ client, ctx, logger }) try { await jiraClient.getCurrentUser() } catch (error) { diff --git a/integrations/jira/src/utils/index.ts b/integrations/jira/src/utils/index.ts index 2f8aee2211c..f907ab9e67b 100644 --- a/integrations/jira/src/utils/index.ts +++ b/integrations/jira/src/utils/index.ts @@ -1,10 +1,68 @@ -import { RuntimeError } from '@botpress/sdk' +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 type { Config } from '../misc/types' +import * as bp from '.botpress' -export const getClient = (config: Config) => new JiraApi(config.host, config.email, config.apiToken) +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 + if (typeof host === 'string' && typeof email === 'string' && typeof apiToken === 'string') { + return { host, email, apiToken } + } + + return +} + +export const getClient = async ({ client, ctx, logger }: ClientProps): Promise => { + 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 } diff --git a/integrations/jira/tsconfig.json b/integrations/jira/tsconfig.json index d46abc5b88f..4d1aa2db641 100644 --- a/integrations/jira/tsconfig.json +++ b/integrations/jira/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "paths": { "*": ["./*"] }, - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "preact" }, "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] } diff --git a/integrations/monday/integration.definition.ts b/integrations/monday/integration.definition.ts index 743555652f9..5d08f582d22 100644 --- a/integrations/monday/integration.definition.ts +++ b/integrations/monday/integration.definition.ts @@ -5,7 +5,7 @@ export default new IntegrationDefinition({ name: 'monday', title: 'Monday', description: 'Manage items in Monday boards.', - version: '1.1.4', + version: '1.1.5', readme: 'hub.md', icon: 'icon.svg', states: { diff --git a/integrations/monday/src/oauth-wizard/wizard.ts b/integrations/monday/src/oauth-wizard/wizard.ts index 9cd38b85f24..66ea9141457 100644 --- a/integrations/monday/src/oauth-wizard/wizard.ts +++ b/integrations/monday/src/oauth-wizard/wizard.ts @@ -22,6 +22,7 @@ const getMondayInstallUrl = () => { export const handler = async (props: bp.HandlerProps) => { const wizard = new oauthWizard.OAuthWizardBuilder(props) .addStep({ id: 'start', handler: _startHandler }) + .addStep({ id: 'confirm-install', handler: _confirmInstallHandler }) .addStep({ id: 'oauth-redirect', handler: _oauthRedirectHandler }) .addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler }) .build() @@ -29,20 +30,51 @@ export const handler = async (props: bp.HandlerProps) => { return await wizard.handleRequest() } -const _startHandler: WizardHandler = ({ responses }) => { +const _startHandler: WizardHandler = ({ ctx, responses }) => { return responses.displayButtons({ pageTitle: 'Connect Monday.com', htmlOrMarkdownPageContents: - `1. Open the Monday.com install page and install the Botpress app in your workspace.\n` + - '2. Come back to this page after the installation is complete.\n' + - '3. Click **Next step** to start the OAuth connection.', + '1. Click **Next step** to open the Monday.com install page in a new tab.\n' + + '2. Install the Botpress app in your Monday.com workspace.\n' + + '3. Return to this wizard and confirm the installation to start the OAuth connection.' + + ` + + `, buttons: [ { - action: 'navigate', + action: 'javascript', label: 'Next step', + callFunction: 'installMondayAppAndContinue', + buttonType: 'primary', + }, + ], + }) +} + +const _confirmInstallHandler: WizardHandler = ({ responses }) => { + return responses.displayButtons({ + pageTitle: 'Confirm Monday.com installation', + htmlOrMarkdownPageContents: + 'Have you installed the Botpress app in your Monday.com workspace?\n\n' + + 'Start OAuth only after the Monday app is installed in your workspace.', + buttons: [ + { + action: 'navigate', + label: 'Yes, continue', navigateToStep: 'oauth-redirect', buttonType: 'primary', }, + { + action: 'navigate', + label: 'Go back', + navigateToStep: 'start', + buttonType: 'secondary', + }, ], }) } @@ -125,7 +157,7 @@ const _exchangeCodeForTokens = async ({ code, redirectUri }: { code: string; red } } -const getWizardStepUrl = (stepId: string) => oauthWizard.getWizardStepUrl(stepId) +const getWizardStepUrl = (stepId: string, ctx?: { webhookId: string }) => oauthWizard.getWizardStepUrl(stepId, ctx) const getOAuthRedirectUri = () => getWizardStepUrl('oauth-callback')