chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,300 @@
|
||||
import { z, IntegrationDefinitionProps, ActionDefinition } from '@botpress/sdk'
|
||||
import { linearIdsSchema, userProfileSchema, issueSchema } from './schemas'
|
||||
|
||||
const _channels = ['issue'] as const
|
||||
|
||||
export type Target = {
|
||||
displayName: string
|
||||
tags: { [key: string]: string }
|
||||
channel: (typeof _channels)[number]
|
||||
}
|
||||
|
||||
const findTarget = {
|
||||
title: 'Find Target',
|
||||
description: 'Find a target on Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().min(2).title('Search query').describe('Issue title, description, or number'),
|
||||
channel: z.enum(['issue']).title('Channel').describe('The channel to search in'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
targets: z
|
||||
.array(
|
||||
z.object({
|
||||
displayName: z.string().title('Display Name').describe('The name of the issue'),
|
||||
tags: z.record(z.string()).title('Tags').describe('The tags applied to the issue'),
|
||||
channel: z.enum(['issue']).title('Channel').describe('The channel of the search result'),
|
||||
})
|
||||
)
|
||||
.title('Results')
|
||||
.describe('The list of issues found'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const getIssue = {
|
||||
title: 'Get Issue',
|
||||
description: 'Get an issue by its ID',
|
||||
input: {
|
||||
schema: z.object({
|
||||
issueId: z.string().title('Issue ID').describe('The issue ID on Linear. Ex: {{event.payload.linearIds.issueId}}'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: issueSchema.title('Issue').describe('The issue found'),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const listIssues = {
|
||||
title: 'List Issues',
|
||||
description: 'List issues from Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
count: z.number().optional().default(10).title('Fetch Amount').describe('The number of issues to return'),
|
||||
startCursor: z.string().optional().title('Start Cursor').describe('The cursor to start from'),
|
||||
teamId: z.string().optional().title('Team ID').describe('The team ID to filter by'),
|
||||
startDate: z.string().optional().title('Start Date').describe('Ignore issues created before this date'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
issues: z
|
||||
.array(issueSchema.extend({ linearIds: linearIdsSchema }))
|
||||
.title('Issues')
|
||||
.describe('The list of matching issues'),
|
||||
nextCursor: z.string().optional().title('Next Cursor').describe('The cursor to fetch the next page'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.string().title('ID').describe('The user ID on Linear'),
|
||||
email: z.string().title('Email').describe("The user's email address"),
|
||||
name: z.string().title('Name').describe("The user's nickname"),
|
||||
displayName: z.string().title('Display Name').describe("The user's display name"),
|
||||
avatarUrl: z.string().optional().title('Avatar URL').describe('The URL of the user avatar'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Description')
|
||||
.describe("The user's description, such as their title or bio"),
|
||||
lastSeen: z.string().optional().title('Last Seen').describe('The last time the user was seen'),
|
||||
updatedAt: z.string().datetime().title('Updated At').describe('The last time the user was updated'),
|
||||
})
|
||||
|
||||
const listUsers = {
|
||||
title: 'List Users',
|
||||
description: 'List users from Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
count: z.number().optional().default(10).title('Fetch Amount').describe('The number of users to return'),
|
||||
startCursor: z.string().optional().title('Start Cursor').describe('The cursor to start from'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
users: z.array(userSchema).title('Users').describe('The list of matching users'),
|
||||
nextCursor: z.string().optional().title('Next Cursor').describe('The cursor to fetch the next page'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const listTeams = {
|
||||
title: 'List Teams',
|
||||
description: 'List teams from Linear',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
teams: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().title('ID').describe('The unique identifier of the entity'),
|
||||
key: z.string().title('Key').describe("The team's key"),
|
||||
name: z.string().title('Name').describe("The team's name"),
|
||||
description: z.string().optional().title('Description').describe("The team's description"),
|
||||
icon: z.string().optional().title('Icon').describe('The icon of the team'),
|
||||
})
|
||||
)
|
||||
.title('Teams')
|
||||
.describe('The list of teams'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const listStates = {
|
||||
title: 'List States',
|
||||
description: 'List states from Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
count: z.number().optional().default(10).title('Fetch Amount').describe('The number of states to return'),
|
||||
startCursor: z.string().optional().title('Start Cursor').describe('The cursor to start from'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
states: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().title('ID').describe('The unique identifier of the entity'),
|
||||
name: z.string().title('Name').describe("The state's name"),
|
||||
})
|
||||
)
|
||||
.title('States')
|
||||
.describe('The list of states'),
|
||||
nextCursor: z.string().optional().title('Next Cursor').describe('The cursor to fetch the next page'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const markAsDuplicate = {
|
||||
title: 'Mark Issue as Duplicate',
|
||||
description: 'Mark an issue as a duplicate of another',
|
||||
input: {
|
||||
schema: z.object({
|
||||
issueId: z.string().title('Issue ID').describe('The issue ID on Linear. Ex: {{event.payload.linearIds.issueId}}'),
|
||||
relatedIssueId: z.string().title('Related Issue ID').describe('The ID of the existing issue on Linear'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const getUser = {
|
||||
title: 'Get User Profile',
|
||||
description: 'Get a user profile from Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
linearUserId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('User ID')
|
||||
.describe(
|
||||
"The user's ID on Linear. Ex: {{event.payload.linearIds.creatorId}}. If omitted, returns the current user."
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: userProfileSchema.title('User').describe('The user profile'),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const updateIssue = {
|
||||
title: 'Update Issue',
|
||||
description: 'Update an issue on Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
issueId: z.string().title('Issue ID').describe('The issue ID on Linear. Example: {{event.payload.id}}'),
|
||||
priority: z.number().optional().title('Priority').describe('0 = none, 1 = urgent, 2 = high, 3 = medium, 4 = low'),
|
||||
teamName: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Move to team...')
|
||||
.describe('Type a name to change the assigned team of the issue'),
|
||||
labels: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.title('Set labels')
|
||||
.describe('One or multiple labels to assign to this issue'),
|
||||
project: z.string().optional().title('Associate to project...').describe('A project to associate to this issue'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
issue: issueSchema.optional().title('Issue').describe('The updated issue'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const createIssue = {
|
||||
title: 'Create Issue',
|
||||
description: 'Create an issue on Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
title: z.string().min(1).title('Title').describe("The issue's title"),
|
||||
description: z.string().title('Description').describe('The content of the issue'),
|
||||
priority: z.number().optional().title('Priority').describe('0 = none, 1 = urgent, 2 = high, 3 = medium, 4 = low'),
|
||||
teamName: z.string().title('Team Name').describe('Name of the team to assign the issue to'),
|
||||
labels: z.array(z.string()).optional().title('Labels').describe('One or multiple labels to assign to this issue'),
|
||||
project: z.string().optional().title('Project').describe('A project to associate to this issue'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
issue: issueSchema.title('Issue').describe('The created issue'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const deleteIssue = {
|
||||
title: 'Delete Issue',
|
||||
description: 'Delete an issue on Linear',
|
||||
input: {
|
||||
schema: z.object({
|
||||
id: z.string().title('Issue ID').describe('The issue ID on Linear. Ex: {{event.payload.linearIds.issueId}}'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const sendRawGraphqlQuery = {
|
||||
title: 'Send Raw GraphQL Query',
|
||||
description: 'Send a raw GraphQL query to the linear API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().title('Query').describe('The GraphQL query'),
|
||||
parameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().title('Name').describe('The parameter name'),
|
||||
value: z.any().title('Value').describe('The parameter value'),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Parameters')
|
||||
.describe('The query parameters'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
result: z.unknown().title('Result').describe('The query result'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
const resolveComment = {
|
||||
title: 'Resolve Comment',
|
||||
description: 'Resolve a comment by id',
|
||||
input: {
|
||||
schema: z.object({
|
||||
id: z.string().title('ID').describe('The comment ID'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
success: z.boolean().title('Success').describe('Whether the operation was successful'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies ActionDefinition
|
||||
|
||||
export const actions = {
|
||||
findTarget,
|
||||
listIssues,
|
||||
listTeams,
|
||||
listUsers,
|
||||
listStates,
|
||||
markAsDuplicate,
|
||||
getIssue,
|
||||
getUser,
|
||||
updateIssue,
|
||||
createIssue,
|
||||
deleteIssue,
|
||||
sendRawGraphqlQuery,
|
||||
resolveComment,
|
||||
} as const satisfies IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventDefinition, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { issueEventSchema, targets } from './schemas'
|
||||
|
||||
const issueCreated = {
|
||||
title: 'Issue Created',
|
||||
description: 'Triggered when an issue is created',
|
||||
schema: issueEventSchema.extend({
|
||||
targets: targets.title('Created Issue').describe('The issue that was created'),
|
||||
}),
|
||||
} as const satisfies EventDefinition
|
||||
|
||||
const issueUpdated = {
|
||||
title: 'Issue Updated',
|
||||
description: 'Triggered when an issue is updated',
|
||||
schema: issueEventSchema.extend({
|
||||
targets: targets.title('Updated Issue').describe('The issue that was updated'),
|
||||
}),
|
||||
} as const satisfies EventDefinition
|
||||
|
||||
const issueDeleted = {
|
||||
title: 'Issue Deleted',
|
||||
description: 'Triggered when an issue is deleted',
|
||||
schema: issueEventSchema.omit({ userId: true, conversationId: true }),
|
||||
} as const satisfies EventDefinition
|
||||
|
||||
export const events = {
|
||||
issueCreated,
|
||||
issueUpdated,
|
||||
issueDeleted,
|
||||
} as const satisfies IntegrationDefinitionProps['events']
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z, IntegrationDefinitionProps, messages, ConfigurationDefinition } from '@botpress/sdk'
|
||||
import { issueSchema } from './schemas'
|
||||
|
||||
export { actions } from './actions'
|
||||
export { events } from './events'
|
||||
export { states } from './states'
|
||||
export * as schemas from './schemas'
|
||||
|
||||
export const configuration = {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
},
|
||||
schema: z.object({
|
||||
displayName: z.string().optional().title('Display Name').describe('The name displayed in message transmissions'),
|
||||
avatarUrl: z.string().optional().title('Avatar URL').describe('The web address for the profile picture'),
|
||||
}),
|
||||
} as const satisfies ConfigurationDefinition
|
||||
|
||||
export const configurations = {
|
||||
apiKey: {
|
||||
title: 'API Key',
|
||||
description: 'Configure Linear with an API Key.',
|
||||
schema: z.object({
|
||||
apiKey: z.string().title('API Key').describe('The API key for Linear'),
|
||||
webhookSigningSecret: z
|
||||
.string()
|
||||
.secret()
|
||||
.optional()
|
||||
.title('Webhook Signing Secret (deprecated)')
|
||||
.describe(
|
||||
'The secret key for verifying incoming Linear webhook events if the webhook has been registered manually. ' +
|
||||
"If you don't provide this value, a webhook will be created automatically."
|
||||
),
|
||||
}),
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['configurations']
|
||||
|
||||
export const channels = {
|
||||
issue: {
|
||||
title: 'Issue',
|
||||
description: 'A linear issue',
|
||||
messages: { ...messages.defaults, markdown: messages.markdown, bloc: messages.markdownBloc },
|
||||
message: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Comment ID',
|
||||
description: 'The ID of the comment on Linear',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Issue ID',
|
||||
description: 'The ID of the issue on Linear',
|
||||
},
|
||||
title: {
|
||||
title: 'Issue Title',
|
||||
description: 'The title of the issue',
|
||||
},
|
||||
url: {
|
||||
title: 'Issue URL',
|
||||
description: 'The URL of the issue on Linear',
|
||||
},
|
||||
parentId: {
|
||||
title: 'Parent Issue ID',
|
||||
description: 'The ID of the parent issue on Linear',
|
||||
},
|
||||
parentTitle: {
|
||||
title: 'Parent Issue Title',
|
||||
description: 'The title of the parent issue',
|
||||
},
|
||||
parentUrl: {
|
||||
title: 'Parent Issue URL',
|
||||
description: 'The URL of the parent issue on Linear',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['channels']
|
||||
|
||||
export const user = {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'User ID',
|
||||
description: 'The ID of the user on Linear',
|
||||
},
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['user']
|
||||
|
||||
export const entities = {
|
||||
issue: {
|
||||
title: 'Issue',
|
||||
description: 'A linear issue',
|
||||
schema: issueSchema,
|
||||
},
|
||||
issueConversation: {
|
||||
title: 'Issue Conversation',
|
||||
description: 'A conversation representing a linear issue',
|
||||
schema: z.object({ id: z.string().title('Issue ID').describe('The issue ID on Linear') }),
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['entities']
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { User } from '@linear/sdk'
|
||||
import { assert, Equals } from 'tsafe/assert'
|
||||
|
||||
export const targets = z.object({
|
||||
issue: z
|
||||
.record(z.string().title('Issue').describe('The issue'))
|
||||
.optional()
|
||||
.title('Target Issue')
|
||||
.describe('The target issue on Linear'),
|
||||
})
|
||||
|
||||
type TransformDatesToStrings<T> = {
|
||||
[K in keyof T]: T[K] extends Date | undefined ? string : T[K]
|
||||
}
|
||||
|
||||
type LinearUserProfile = Pick<
|
||||
TransformDatesToStrings<User>,
|
||||
| 'admin'
|
||||
| 'archivedAt'
|
||||
| 'avatarUrl'
|
||||
| 'createdAt'
|
||||
| 'description'
|
||||
| 'displayName'
|
||||
| 'guest'
|
||||
| 'email'
|
||||
| 'isMe'
|
||||
| 'url'
|
||||
| 'timezone'
|
||||
| 'name'
|
||||
> & { linearId: string }
|
||||
|
||||
export const userProfileSchema = z.object({
|
||||
linearId: z.string().title('User ID').describe('Linear User ID'),
|
||||
admin: z.boolean().title('Is Admin?').describe('Indicates if the user is an admin of the organization'),
|
||||
archivedAt: z.string().datetime().optional().title('Archival Date').describe('Date when the user was archived'),
|
||||
avatarUrl: z.string().url().optional().title('Avatar URL').describe('The URL for the profile picture'),
|
||||
createdAt: z.string().datetime().title('Creation Date').describe('Date when the user was created'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Description')
|
||||
.describe('A short description of the user, either its title or bio'),
|
||||
displayName: z
|
||||
.string()
|
||||
.title('Display Name')
|
||||
.describe("The user's display (nick) name. Unique within each organization"),
|
||||
guest: z
|
||||
.boolean()
|
||||
.title('Is Guest?')
|
||||
.describe('Whether the user is a guest in the workspace and limited to accessing a subset of teams'),
|
||||
email: z.string().title('Email').describe("The user's email address"),
|
||||
isMe: z.boolean().title('Is Self?').describe('Whether the user is the currently authenticated user'),
|
||||
url: z.string().title('Profile URL').describe("User's profile URL"),
|
||||
timezone: z.string().optional().title('Timezone').describe('The local timezone of the user'),
|
||||
name: z.string().title('Full Name').describe("The user's full name"),
|
||||
})
|
||||
|
||||
export const linearIdsSchema = z
|
||||
.object({
|
||||
creatorId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title("Creator's User ID")
|
||||
.describe('The internal Linear User ID of the user who created the issue'),
|
||||
labelIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.title('Label IDs')
|
||||
.describe('The internal Linear Label IDs associated with the issue'),
|
||||
issueId: z.string().title('Issue ID').describe('The internal Linear Issue ID'),
|
||||
teamId: z.string().optional().title('Team ID').describe('The internal Linear Team ID'),
|
||||
projectId: z.string().optional().title('Project ID').describe('The internal Linear Project ID'),
|
||||
assigneeId: z.string().optional().title('Assignee User ID').describe('The internal Linear Assignee ID'),
|
||||
subscriberIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.title('Subscriber IDs')
|
||||
.describe('The internal Linear Subscriber User IDs'),
|
||||
})
|
||||
.describe('The Linear IDs of the referenced entities')
|
||||
|
||||
const commonIssueProperties = {
|
||||
title: z.string().title('Title').describe('The issue title on Linear, such as "Fix the bug'),
|
||||
number: z.number().title('Number').describe('The issue number on Linear, such as "123" in XXX-123'),
|
||||
createdAt: z.string().datetime().title('Created At').describe('The ISO date the issue was created'),
|
||||
updatedAt: z.string().datetime().title('Updated At').describe('The ISO date the issue was last updated'),
|
||||
identifier: z.string().title('Identifier').describe("Issue's human readable identifier (e.g. XXX-123)"),
|
||||
url: z.string().title('Issue URL').describe('The URL of the issue on Linear'),
|
||||
priority: z
|
||||
.number()
|
||||
.title('Priority')
|
||||
.describe('Priority of the issue, such as "1" for "Urgent", 0 for "No Priority"'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Description')
|
||||
.describe('A markdown description of the issue. Images and videos are inlined using markdown links.'),
|
||||
}
|
||||
|
||||
export const issueSchema = z.object({
|
||||
...commonIssueProperties,
|
||||
id: z.string().title('Issue ID').describe('The issue ID on Linear'),
|
||||
estimate: z.number().optional().title('Points Estimate').describe('The estimate of the issue in points'),
|
||||
})
|
||||
|
||||
export const issueEventSchema = z.object({
|
||||
...commonIssueProperties,
|
||||
teamName: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Team Name')
|
||||
.describe('The name of the Linear team the issue currently belongs to, such as "Customer Support"'),
|
||||
teamKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Team Key')
|
||||
.describe('The key of the Linear team the issue currently belongs to, such as "XXX" in XXX-123'),
|
||||
status: z.string().title('Status').describe('The issue State name (such as "In Progress"'),
|
||||
statusColor: z.string().title('Status Color').describe('The issue State color (#000000 format)'),
|
||||
statusType: z.string().title('Status Type').describe('The issue State type'),
|
||||
labels: z
|
||||
.array(z.string().title('Label Name').describe('The name of the label'))
|
||||
.optional()
|
||||
.title('Applied Labels')
|
||||
.describe('Label names'),
|
||||
linearIds: linearIdsSchema.title('Linear IDs').describe('The Linear IDs of the referenced entities'),
|
||||
userId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Botpress User ID')
|
||||
.describe(
|
||||
'The Botpress User ID of the individual who initiated the issue. If not provided, it indicates the issue was generated by the bot.'
|
||||
),
|
||||
conversationId: z.string().title('Botpress Conversation ID').describe('Botpress Conversation ID of the issue'),
|
||||
})
|
||||
|
||||
assert<Equals<z.infer<typeof userProfileSchema>, LinearUserProfile>>()
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { userProfileSchema } from './schemas'
|
||||
|
||||
const oauthCredentialsSchema = z.object({
|
||||
accessToken: z.string().title('Access Token').describe('The access token for Linear'),
|
||||
refreshToken: z.string().title('Refresh Token').describe('The refresh token needed when the access token expires'),
|
||||
expiresAt: z.string().title('Expires At').describe('The time when the access token expires'),
|
||||
})
|
||||
|
||||
export const states = {
|
||||
credentials: {
|
||||
type: 'integration',
|
||||
schema: oauthCredentialsSchema,
|
||||
},
|
||||
adminCredentials: {
|
||||
type: 'integration',
|
||||
schema: oauthCredentialsSchema.describe(
|
||||
'User-actor OAuth credentials used only for admin operations (webhook register/unregister)'
|
||||
),
|
||||
},
|
||||
environment: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
env: z
|
||||
.enum(['preview', 'production'])
|
||||
.title('Environment')
|
||||
.describe('The environment where the integration is installed'),
|
||||
source: z.string().optional().title('Source').describe('The source of the OAuth request, eg: "desk"'),
|
||||
wizardPhase: z
|
||||
.enum(['admin', 'app'])
|
||||
.optional()
|
||||
.title('Wizard Phase')
|
||||
.describe('Which leg of the two-phase OAuth wizard is currently expected on /oauth callback'),
|
||||
runtimeActor: z
|
||||
.enum(['user', 'app'])
|
||||
.optional()
|
||||
.title('Runtime Actor')
|
||||
.describe('Which Linear OAuth actor type was used for the runtime credentials'),
|
||||
}),
|
||||
},
|
||||
|
||||
// TODO: delete these 2 states when the backend stop considering state deletion as breaking change
|
||||
configuration: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
botUserId: z.string().optional().title('Bot User ID').describe('The ID of the bot user'),
|
||||
}),
|
||||
},
|
||||
profile: {
|
||||
type: 'user',
|
||||
schema: userProfileSchema,
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
parse_json!(.body).organizationId
|
||||
@@ -0,0 +1,40 @@
|
||||
The Linear integration brings powerful project management capabilities to your AI-powered chatbot. Seamlessly connect Botpress with Linear, a modern issue tracking and workflow management tool. With this integration, you can automate task creation, track progress, and collaborate on projects directly within your chatbot. Empower your chatbot to create, update, and retrieve Linear issues, assign tasks to team members, track due dates, and more. Streamline your project management processes and enhance team productivity with the Linear Integration for Botpress.
|
||||
|
||||
## Migrating from version `0.x` to `1.x`
|
||||
|
||||
Version `1.0` of the Linear integration now requires users to provide a webhook signing secret. If you use OAuth authentication, you are unaffected by this change. If you use an API key to authenticate with Linear, you must provide a webhook signing secret to ensure secure communication between Botpress and Linear. To obtain the webhook signing secret, follow the instructions in the _Manual configuration with an API key_ section below.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Automatic configuration with OAuth (recommended)
|
||||
|
||||
This is the simplest way to set up the integration. To set up the Linear integration using OAuth, click the authorization button and follow the instructions to connect your Botpress chatbot to Linear. This method is recommended as it simplifies the configuration process and ensures secure communication between your chatbot and Linear.
|
||||
|
||||
When using this configuration mode, a Botpress-managed Linear application will be used to connect to your Linear workspace. The application will have the necessary permissions to administer issues, comments, and perform operations on behalf of your users. If you require more granular control over the permissions, you can opt for the manual configuration mode instead.
|
||||
|
||||
### Manual configuration with an API key
|
||||
|
||||
If you prefer to manually configure the integration, you can provide an API key to connect your personal Linear account to Botpress. Keep in mind that when you use an API key, actions taken by the bot will be attributed to your personal Linear account. If you wish for actions to be attributed to your organization instead of to your personal account, you must use OAuth authentication. OAuth authentication offers a lot of advantages over API keys and do not consume a seat within your Linear organization.
|
||||
|
||||
To set up the Linear integration using a personal API key, follow these steps:
|
||||
|
||||
### Creating a Linear API key
|
||||
|
||||
1. On Linear, navigate to your account settings and select the API tab in the navigation sidebar.
|
||||
2. Under _Personal API keys_, input a name for your API key and click the _Create new API key_ button.
|
||||
3. Save this API key in a secure location. You will need it to configure the Linear integration in Botpress.
|
||||
|
||||
### Configuring the Linear integration in Botpress
|
||||
|
||||
1. In Botpress, navigate to the integration configuration page for Linear.
|
||||
2. Select the _Configure Linear with an API Key_ option.
|
||||
3. Enter the API key you obtained from Linear in the _API Key_ field.
|
||||
4. Enter the webhook signing secret you obtained from Linear in the _Webhook Signing Secret_ field.
|
||||
5. Save the configuration and enable the integration.
|
||||
6. Copy the webhook URL generated by Botpress.
|
||||
|
||||
## Limitations
|
||||
|
||||
Standard Linear API limitations apply to the Linear integration in Botpress. These limitations include rate limits, payload size restrictions, and other constraints imposed by the Linear platform. Ensure that your bot adheres to these limitations to maintain optimal performance and reliability.
|
||||
|
||||
More details are available in the [Linear API documentation](https://developers.linear.app/docs/graphql/working-with-the-graphql-api/rate-limiting).
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg version="1.1" id="Layer_1"
|
||||
xmlns:x="ns_extend;"
|
||||
xmlns:i="ns_ai;"
|
||||
xmlns:graph="ns_graphs;"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#5E6AD2;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="50" width="50" x="50" y="50">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g>
|
||||
<path class="st0" d="M41.3,44c0.5-0.4,0.9-0.8,1.3-1.3c9.8-9.8,9.8-25.6,0-35.4c-9.8-9.8-25.6-9.8-35.4,0C6.8,7.8,6.4,8.2,6,8.7
|
||||
L41.3,44z">
|
||||
</path>
|
||||
<path class="st0" d="M38.3,46.2L3.8,11.7c-0.7,1.1-1.3,2.2-1.8,3.4L34.9,48C36.1,47.5,37.2,46.9,38.3,46.2z">
|
||||
</path>
|
||||
<path class="st0" d="M31.1,49.3L0.7,18.9c-0.4,1.5-0.6,3-0.7,4.5L26.6,50C28.1,49.9,29.6,49.7,31.1,49.3z">
|
||||
</path>
|
||||
<path class="st0" d="M21.1,49.8L0.2,28.9c0.8,5.1,3.1,9.9,7,13.9C11.2,46.6,16.1,49,21.1,49.8z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,89 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import proactiveConversation from 'bp_modules/proactive-conversation'
|
||||
import deletable from './bp_modules/deletable'
|
||||
import filesReadonly from './bp_modules/files-readonly'
|
||||
import listable from './bp_modules/listable'
|
||||
|
||||
import { actions, channels, events, configuration, configurations, user, states, entities } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'linear'
|
||||
export const INTEGRATION_VERSION = '2.6.1'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
title: 'Linear',
|
||||
description:
|
||||
'Manage your projects autonomously. Have your bot participate in discussions, manage issues and teams, and track progress.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
configuration,
|
||||
configurations,
|
||||
channels,
|
||||
identifier: {
|
||||
extractScript: 'extract.vrl',
|
||||
},
|
||||
user,
|
||||
actions,
|
||||
events,
|
||||
states,
|
||||
entities,
|
||||
secrets: {
|
||||
CLIENT_ID: {
|
||||
description: 'The client ID of your Linear OAuth app.',
|
||||
},
|
||||
CLIENT_SECRET: {
|
||||
description: 'The client secret of your Linear OAuth app.',
|
||||
},
|
||||
DESK_CLIENT_ID: {
|
||||
description: 'The client ID of your Linear OAuth app.',
|
||||
},
|
||||
DESK_CLIENT_SECRET: {
|
||||
description: 'The client secret of your Linear OAuth app.',
|
||||
},
|
||||
WEBHOOK_SIGNING_SECRET: {
|
||||
description: 'The signing secret of your Linear webhook.',
|
||||
},
|
||||
...posthogHelper.COMMON_SECRET_NAMES,
|
||||
},
|
||||
attributes: {
|
||||
category: 'Project Management',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
.extend(listable, ({ entities }) => ({
|
||||
entities: { item: entities.issue },
|
||||
actions: {
|
||||
list: { name: 'issueList' },
|
||||
},
|
||||
}))
|
||||
.extend(deletable, ({ entities }) => ({
|
||||
entities: { item: entities.issue },
|
||||
actions: {
|
||||
delete: { name: 'issueDelete' },
|
||||
},
|
||||
events: {
|
||||
deleted: { name: 'issueDeleted' },
|
||||
},
|
||||
}))
|
||||
.extend(proactiveConversation, ({ entities }) => ({
|
||||
entities: {
|
||||
conversation: entities.issueConversation,
|
||||
},
|
||||
actions: { getOrCreateConversation: { name: 'getOrCreateIssueConversation' } },
|
||||
}))
|
||||
.extend(filesReadonly, ({}) => ({
|
||||
entities: {},
|
||||
actions: {
|
||||
listItemsInFolder: {
|
||||
name: 'filesReadonlyListItemsInFolder',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
transferFileToBotpress: {
|
||||
name: 'filesReadonlyTransferFileToBotpress',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,6 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
env = to_string!(.env)
|
||||
source = to_string!(.source)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}&source={{ source }}&env={{ env }}"
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@botpresshub/linear",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@linear/sdk": "^65.2.0",
|
||||
"axios": "^1.4.0",
|
||||
"query-string": "^6.14.1",
|
||||
"tsafe": "^1.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/deletable": "workspace:*",
|
||||
"@botpresshub/listable": "workspace:*",
|
||||
"@botpresshub/proactive-conversation": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"listable": "../../interfaces/listable",
|
||||
"deletable": "../../interfaces/deletable",
|
||||
"proactive-conversation": "../../interfaces/proactive-conversation",
|
||||
"files-readonly": "../../interfaces/files-readonly"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { getIssueTags, getLinearClient, getTeam } from '../misc/utils'
|
||||
import { getIssueFields } from './get-issue'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const createIssue: bp.IntegrationProps['actions']['createIssue'] = async (args) => {
|
||||
const {
|
||||
ctx,
|
||||
client,
|
||||
input: { title, description, priority, teamName, labels, project },
|
||||
} = args
|
||||
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
const team = await getTeam(linearClient, undefined, teamName)
|
||||
|
||||
if (!team.id) {
|
||||
throw new RuntimeError(`Could not find team "${teamName}"`)
|
||||
}
|
||||
|
||||
const labelIds = labels ? await team.findLabelIds(labels) : undefined
|
||||
const projectId = project ? await team.findProjectId(project) : undefined
|
||||
|
||||
let createAsUser: string | undefined = undefined
|
||||
let displayIconUrl: string | undefined = undefined
|
||||
if (ctx.configurationType === null) {
|
||||
createAsUser = ctx.configuration.displayName
|
||||
displayIconUrl = ctx.configuration.avatarUrl
|
||||
}
|
||||
|
||||
const { issue: issueFetch } = await linearClient.createIssue({
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
teamId: team.id,
|
||||
labelIds,
|
||||
projectId,
|
||||
createAsUser,
|
||||
displayIconUrl,
|
||||
})
|
||||
|
||||
const fullIssue = await issueFetch
|
||||
if (!fullIssue) {
|
||||
throw new RuntimeError('Could not create issue')
|
||||
}
|
||||
|
||||
const issue = getIssueFields(fullIssue)
|
||||
const issueTags = await getIssueTags(fullIssue)
|
||||
|
||||
await client.getOrCreateConversation({
|
||||
channel: 'issue',
|
||||
tags: issueTags,
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
return {
|
||||
issue,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LinearError, LinearErrorType } from '@linear/sdk'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const deleteIssue: bp.IntegrationProps['actions']['deleteIssue'] = async (args) => {
|
||||
const {
|
||||
input: { id: issueId },
|
||||
} = args
|
||||
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
const existingIssue = await linearClient.issue(issueId).catch((thrown) => {
|
||||
if (thrown instanceof LinearError && thrown.type === LinearErrorType.InvalidInput) {
|
||||
return undefined
|
||||
}
|
||||
throw thrown
|
||||
})
|
||||
|
||||
if (!existingIssue) {
|
||||
args.logger.warn(`Issue "${issueId}" not found`)
|
||||
return {}
|
||||
}
|
||||
|
||||
await linearClient.deleteIssue(issueId)
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IssueConnection } from '@linear/sdk'
|
||||
|
||||
import { Target } from '../../definitions/actions'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const findIssues = async (issues: IssueConnection, targets: Target[]) => {
|
||||
const data = await issues.fetchNext()
|
||||
|
||||
const allIssues = data.nodes.map<Target>((issue) => ({
|
||||
displayName: issue.title,
|
||||
tags: { id: issue.id },
|
||||
channel: 'issue',
|
||||
}))
|
||||
|
||||
targets.push(...allIssues)
|
||||
|
||||
if (data.pageInfo.hasNextPage) {
|
||||
await findIssues(issues, targets)
|
||||
}
|
||||
}
|
||||
|
||||
export const findTarget: bp.IntegrationProps['actions']['findTarget'] = async (args) => {
|
||||
const targets: Target[] = []
|
||||
|
||||
const { input } = args
|
||||
|
||||
const linearClient = await getLinearClient(args)
|
||||
const issues = await linearClient.issues({
|
||||
filter: {
|
||||
or: [
|
||||
{ title: { contains: input.query } },
|
||||
{ description: { contains: input.query } },
|
||||
{ number: { eq: Number(input.query) } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await findIssues(issues, targets)
|
||||
|
||||
return {
|
||||
targets,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { Issue } from '@linear/sdk'
|
||||
|
||||
import { issueSchema } from '../../definitions/schemas'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getIssueFields = (issue: Issue): z.infer<typeof issueSchema> => ({
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
priority: issue.priority,
|
||||
estimate: issue.estimate,
|
||||
url: issue.url,
|
||||
createdAt: issue.createdAt.toISOString(),
|
||||
updatedAt: issue.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
export const getIssue: bp.IntegrationProps['actions']['getIssue'] = async (args) => {
|
||||
const {
|
||||
input: { issueId },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
const issue = await linearClient.issue(issueId)
|
||||
|
||||
return getIssueFields(issue)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
import { userProfileSchema } from '../../definitions/schemas'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getUser: bp.IntegrationProps['actions']['getUser'] = async (args) => {
|
||||
const {
|
||||
input: { linearUserId },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
const user = linearUserId ? await linearClient.user(linearUserId) : await linearClient.viewer
|
||||
|
||||
return userProfileSchema.parse({
|
||||
linearId: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
displayName: user.displayName,
|
||||
guest: user.guest,
|
||||
isMe: user.isMe,
|
||||
url: user.url,
|
||||
archivedAt: user.archivedAt?.toISOString(),
|
||||
description: user.description,
|
||||
timezone: user.timezone,
|
||||
} satisfies z.infer<typeof userProfileSchema>)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { filesReadonlyListItemsInFolder, filesReadonlyTransferFileToBotpress } from '../files-readonly/actions'
|
||||
import { createIssue } from './create-issue'
|
||||
import { deleteIssue } from './delete-issue'
|
||||
import { findTarget } from './find-target'
|
||||
import { getIssue } from './get-issue'
|
||||
import { getUser } from './get-user'
|
||||
import { issueDelete } from './issue-delete'
|
||||
import { issueList } from './issue-list'
|
||||
import { listIssues } from './list-issues'
|
||||
import { listStates } from './list-states'
|
||||
import { listTeams } from './list-teams'
|
||||
import { listUsers } from './list-users'
|
||||
import { markAsDuplicate } from './mark-as-duplicate'
|
||||
import { getOrCreateIssueConversation } from './proactive-conversation'
|
||||
import { resolveComment } from './resolve-comment'
|
||||
import { sendRawGraphqlQuery } from './send-raw-graphql-query'
|
||||
import { updateIssue } from './update-issue'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
findTarget,
|
||||
getIssue,
|
||||
updateIssue,
|
||||
getUser,
|
||||
listIssues,
|
||||
listTeams,
|
||||
listUsers,
|
||||
listStates,
|
||||
markAsDuplicate,
|
||||
createIssue,
|
||||
deleteIssue,
|
||||
sendRawGraphqlQuery,
|
||||
resolveComment,
|
||||
getOrCreateIssueConversation,
|
||||
issueDelete,
|
||||
issueList,
|
||||
filesReadonlyListItemsInFolder,
|
||||
filesReadonlyTransferFileToBotpress,
|
||||
} satisfies Partial<bp.IntegrationProps['actions']>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { deleteIssue } from './delete-issue'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const issueDelete: bp.IntegrationProps['actions']['issueDelete'] = async (args) => {
|
||||
return deleteIssue({
|
||||
...args,
|
||||
type: 'deleteIssue',
|
||||
input: { id: args.input.id },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { listIssues } from './list-issues'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const issueList: bp.IntegrationProps['actions']['issueList'] = async (args) => {
|
||||
const count = 20
|
||||
const startCursor = args.input.nextToken
|
||||
const res = await listIssues({
|
||||
...args,
|
||||
type: 'listIssues',
|
||||
input: {
|
||||
count,
|
||||
startCursor,
|
||||
},
|
||||
})
|
||||
return {
|
||||
items: res.issues.map(({ linearIds: _, ...item }) => item),
|
||||
meta: { nextToken: res.nextCursor },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LinearDocument } from '@linear/sdk'
|
||||
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import { getIssueFields } from './get-issue'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const listIssues: bp.IntegrationProps['actions']['listIssues'] = async (args) => {
|
||||
const {
|
||||
input: { count, startCursor, startDate, teamId },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
const query = await linearClient.issues({
|
||||
orderBy: LinearDocument.PaginationOrderBy.UpdatedAt,
|
||||
first: count || 10,
|
||||
after: startCursor,
|
||||
filter: {
|
||||
updatedAt: startDate ? { gt: new Date(startDate) } : undefined,
|
||||
team: teamId ? { id: { eq: teamId } } : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const issues = query.nodes.map((issue) => ({
|
||||
...getIssueFields(issue),
|
||||
linearIds: {
|
||||
issueId: issue.id,
|
||||
creatorId: issue['_creator']?.id,
|
||||
teamId: issue['_team']?.id,
|
||||
assigneeId: issue['_assignee']?.id,
|
||||
projectId: issue['_project']?.id,
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
issues,
|
||||
nextCursor: query.pageInfo.hasNextPage ? query.pageInfo.endCursor : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const listStates: bp.IntegrationProps['actions']['listStates'] = async (args) => {
|
||||
const {
|
||||
input: { count, startCursor },
|
||||
} = args
|
||||
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
try {
|
||||
const states = await linearClient.workflowStates({ after: startCursor, first: count })
|
||||
return {
|
||||
nextCursor: states.pageInfo.endCursor,
|
||||
states: states.nodes.map((state) => {
|
||||
return { id: state.id, name: state.name }
|
||||
}),
|
||||
}
|
||||
} catch (thrown: unknown) {
|
||||
const msg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
throw new RuntimeError(`Failed to list states: ${msg}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const listTeams: bp.IntegrationProps['actions']['listTeams'] = async (args) => {
|
||||
const {
|
||||
input: {},
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
const teams = await linearClient.teams()
|
||||
|
||||
return {
|
||||
teams: teams.nodes.map((x) => ({
|
||||
id: x.id,
|
||||
key: x.key,
|
||||
name: x.name,
|
||||
description: x.description,
|
||||
icon: x.icon,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LinearDocument } from '@linear/sdk'
|
||||
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const listUsers: bp.IntegrationProps['actions']['listUsers'] = async (args) => {
|
||||
const {
|
||||
input: { count, startCursor },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
const query = await linearClient.users({
|
||||
orderBy: LinearDocument.PaginationOrderBy.UpdatedAt,
|
||||
first: count || 10,
|
||||
after: startCursor,
|
||||
})
|
||||
|
||||
const users: bp.actions.listUsers.output.Output['users'] = query.nodes.map((user) => ({
|
||||
...user,
|
||||
lastSeen: user.lastSeen?.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
return {
|
||||
users,
|
||||
nextCursor: query.pageInfo.hasNextPage ? query.pageInfo.endCursor : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LinearDocument } from '@linear/sdk'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const markAsDuplicate: bp.IntegrationProps['actions']['markAsDuplicate'] = async (args) => {
|
||||
const {
|
||||
input: { issueId, relatedIssueId },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
await linearClient.createIssueRelation({
|
||||
issueId,
|
||||
relatedIssueId,
|
||||
type: LinearDocument.IssueRelationType.Duplicate,
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { getIssueTags, getLinearClient } from 'src/misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getOrCreateIssueConversation: bp.IntegrationProps['actions']['getOrCreateIssueConversation'] = async (
|
||||
args
|
||||
) => {
|
||||
const { client, input } = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
const issue = await linearClient.issue(input.conversation.id).catch((thrown) => {
|
||||
const message = thrown instanceof Error ? thrown.message : new Error(thrown).message
|
||||
throw new RuntimeError(`Failed to get issue with ID ${input.conversation.id}: ${message}`)
|
||||
})
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'issue',
|
||||
tags: await getIssueTags(issue),
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const resolveComment: bp.IntegrationProps['actions']['resolveComment'] = async (args) => {
|
||||
const {
|
||||
input: { id },
|
||||
} = args
|
||||
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
try {
|
||||
const { success } = await linearClient.commentResolve(id)
|
||||
return { success }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
args.logger.error('An error occured while trying to resolve comment: ', error.message)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { getLinearClient } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const sendRawGraphqlQuery: bp.IntegrationProps['actions']['sendRawGraphqlQuery'] = async (args) => {
|
||||
const {
|
||||
input: { query, parameters },
|
||||
} = args
|
||||
|
||||
const mappedParams = parameters?.reduce(
|
||||
(mapped, param) => {
|
||||
mapped[param.name] = param.value
|
||||
return mapped
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
try {
|
||||
const linearClient = await getLinearClient(args)
|
||||
const result = await linearClient.client.rawRequest(query, mappedParams)
|
||||
return { result: result.data }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to query the Linear API: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getLinearClient, getTeam } from '../misc/utils'
|
||||
import { getIssueFields } from './get-issue'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const updateIssue: bp.IntegrationProps['actions']['updateIssue'] = async (args) => {
|
||||
const {
|
||||
input: { issueId, teamName, labels, project, priority },
|
||||
} = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
|
||||
const existingIssue = await linearClient.issue(issueId)
|
||||
const team = await getTeam(linearClient, await existingIssue.team, teamName)
|
||||
|
||||
const labelIds = labels ? await team.findLabelIds(labels) : undefined
|
||||
const projectId = project ? await team.findProjectId(project) : undefined
|
||||
|
||||
const { issue: issueFetch } = await linearClient.updateIssue(issueId, {
|
||||
priority,
|
||||
teamId: teamName ? team?.id : undefined,
|
||||
labelIds,
|
||||
projectId,
|
||||
// slaBreachesAt: stringToDate(input.slaBreachesAt),
|
||||
})
|
||||
|
||||
const issue = await issueFetch
|
||||
return issue ? { issue: getIssueFields(issue) } : {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { createComment, getCardContent } from './misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
issue: {
|
||||
messages: {
|
||||
text: ({ payload, ...props }) => createComment({ ...props, content: payload.text }),
|
||||
image: ({ payload, ...props }) => createComment({ ...props, content: payload.imageUrl }),
|
||||
markdown: ({ payload, ...props }) => createComment({ ...props, content: payload.markdown }),
|
||||
audio: ({ payload, ...props }) => createComment({ ...props, content: payload.audioUrl }),
|
||||
video: ({ payload, ...props }) => createComment({ ...props, content: payload.videoUrl }),
|
||||
file: ({ payload, ...props }) => createComment({ ...props, content: payload.fileUrl }),
|
||||
location: ({ payload, ...props }) =>
|
||||
createComment({ ...props, content: `${payload.latitude},${payload.longitude}` }),
|
||||
card: ({ payload, ...props }) => createComment({ ...props, content: getCardContent(payload) }),
|
||||
carousel: async ({ payload, ...props }) => {
|
||||
await Promise.all(payload.items.map((item) => createComment({ ...props, content: getCardContent(item) })))
|
||||
},
|
||||
dropdown: ({ payload, ...props }) => createComment({ ...props, content: payload.text }),
|
||||
choice: ({ payload, ...props }) => createComment({ ...props, content: payload.text }),
|
||||
bloc: () => {
|
||||
throw new RuntimeError('Not implemented')
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LinearIssueEvent } from '../misc/linear'
|
||||
import { getUserAndConversation } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type IssueProps = {
|
||||
linearEvent: LinearIssueEvent
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
|
||||
type IssueCreated = bp.events.issueCreated.IssueCreated
|
||||
|
||||
export const fireIssueCreated = async ({ linearEvent, client, ctx }: IssueProps) => {
|
||||
const payload: Omit<IssueCreated, 'conversationId' | 'userId'> = {
|
||||
title: linearEvent.data.title,
|
||||
identifier: linearEvent.data.identifier,
|
||||
url: linearEvent.data.url,
|
||||
priority: linearEvent.data.priority,
|
||||
status: linearEvent.data.state.name,
|
||||
statusColor: linearEvent.data.state.color,
|
||||
statusType: linearEvent.data.state.type,
|
||||
description: linearEvent.data.description ?? undefined,
|
||||
number: linearEvent.data.number,
|
||||
updatedAt: linearEvent.data.updatedAt,
|
||||
createdAt: linearEvent.data.createdAt,
|
||||
teamKey: linearEvent.data.team?.key,
|
||||
teamName: linearEvent.data.team?.name,
|
||||
labels: linearEvent.data.labels?.map((x: any) => x.name) ?? [],
|
||||
linearIds: {
|
||||
creatorId: linearEvent.data.creatorId,
|
||||
labelIds: linearEvent.data.labelIds ?? [],
|
||||
issueId: linearEvent.data.id,
|
||||
teamId: linearEvent.data.team?.id,
|
||||
projectId: linearEvent.data.project?.id,
|
||||
assigneeId: linearEvent.data.assignee?.id,
|
||||
subscriberIds: linearEvent.data.subscriberIds,
|
||||
},
|
||||
targets: {
|
||||
issue: { id: linearEvent.data.id },
|
||||
},
|
||||
}
|
||||
|
||||
const { conversationId, userId } = await getUserAndConversation({
|
||||
linearIssueId: linearEvent.data.id,
|
||||
linearUserId: linearEvent.data.creatorId,
|
||||
integrationId: ctx.integrationId,
|
||||
client,
|
||||
ctx,
|
||||
})
|
||||
|
||||
await client.createEvent({
|
||||
type: 'issueCreated',
|
||||
payload: { ...payload, conversationId, userId },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { LinearIssueEvent } from '../misc/linear'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type IssueProps = {
|
||||
linearEvent: LinearIssueEvent
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
|
||||
export const fireIssueDeleted = async ({ linearEvent, client }: IssueProps) => {
|
||||
await client.createEvent({
|
||||
type: 'issueDeleted',
|
||||
payload: {
|
||||
id: linearEvent.data.id,
|
||||
title: linearEvent.data.title,
|
||||
identifier: linearEvent.data.identifier,
|
||||
url: linearEvent.data.url,
|
||||
priority: linearEvent.data.priority,
|
||||
status: linearEvent.data.state.name,
|
||||
statusColor: linearEvent.data.state.color,
|
||||
statusType: linearEvent.data.state.type,
|
||||
description: linearEvent.data.description ?? '',
|
||||
number: linearEvent.data.number,
|
||||
updatedAt: linearEvent.data.updatedAt,
|
||||
createdAt: linearEvent.data.createdAt,
|
||||
teamKey: linearEvent.data.team?.key,
|
||||
teamName: linearEvent.data.team?.name,
|
||||
labels: linearEvent.data.labels?.map((x) => x.name) ?? [],
|
||||
linearIds: {
|
||||
creatorId: linearEvent.data.creatorId,
|
||||
labelIds: linearEvent.data.labelIds ?? [],
|
||||
issueId: linearEvent.data.id,
|
||||
teamId: linearEvent.data.team?.id,
|
||||
projectId: linearEvent.data.project?.id,
|
||||
assigneeId: linearEvent.data.assignee?.id,
|
||||
subscriberIds: linearEvent.data.subscriberIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { LinearIssueEvent } from '../misc/linear'
|
||||
import { getUserAndConversation } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type IssueProps = {
|
||||
linearEvent: LinearIssueEvent
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
|
||||
type IssueUpdated = bp.events.issueUpdated.IssueUpdated
|
||||
|
||||
export const fireIssueUpdated = async ({ linearEvent, client, ctx }: IssueProps) => {
|
||||
const payload: Omit<IssueUpdated, 'conversationId' | 'userId'> = {
|
||||
title: linearEvent.data.title,
|
||||
identifier: linearEvent.data.identifier,
|
||||
url: linearEvent.data.url,
|
||||
priority: linearEvent.data.priority,
|
||||
status: linearEvent.data.state.name,
|
||||
statusColor: linearEvent.data.state.color,
|
||||
statusType: linearEvent.data.state.type,
|
||||
description: linearEvent.data.description ?? '',
|
||||
number: linearEvent.data.number,
|
||||
updatedAt: linearEvent.data.updatedAt,
|
||||
createdAt: linearEvent.data.createdAt,
|
||||
teamKey: linearEvent.data.team?.key,
|
||||
teamName: linearEvent.data.team?.name,
|
||||
labels: linearEvent.data.labels?.map((x) => x.name) ?? [],
|
||||
linearIds: {
|
||||
creatorId: linearEvent.data.creatorId,
|
||||
labelIds: linearEvent.data.labelIds ?? [],
|
||||
issueId: linearEvent.data.id,
|
||||
teamId: linearEvent.data.team?.id,
|
||||
projectId: linearEvent.data.project?.id,
|
||||
assigneeId: linearEvent.data.assignee?.id,
|
||||
subscriberIds: linearEvent.data.subscriberIds,
|
||||
},
|
||||
targets: {
|
||||
issue: { id: linearEvent.data.id },
|
||||
},
|
||||
}
|
||||
|
||||
const { conversationId, userId } = await getUserAndConversation({
|
||||
linearIssueId: linearEvent.data.id,
|
||||
linearUserId: linearEvent.data.creatorId,
|
||||
integrationId: ctx.integrationId,
|
||||
forceUpdate: true,
|
||||
client,
|
||||
ctx,
|
||||
})
|
||||
|
||||
await client.createEvent({
|
||||
type: 'issueUpdated',
|
||||
payload: { ...payload, conversationId, userId },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { filesReadonlyListItemsInFolder } from './list-items-in-folder'
|
||||
export { filesReadonlyTransferFileToBotpress } from './transfer-file-to-botpress'
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { getLinearClient } from '../../misc/utils'
|
||||
import * as mapping from '../mapping'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export const filesReadonlyListItemsInFolder: bp.IntegrationProps['actions']['filesReadonlyListItemsInFolder'] = async ({
|
||||
input,
|
||||
client,
|
||||
ctx,
|
||||
}) => {
|
||||
const { folderId, nextToken: prevToken } = input
|
||||
|
||||
try {
|
||||
const linearClient = await getLinearClient({ client, ctx })
|
||||
|
||||
if (!folderId) {
|
||||
return await _listTeams(linearClient, prevToken)
|
||||
}
|
||||
|
||||
if (folderId.startsWith(mapping.PREFIXES.TEAM)) {
|
||||
return await _listTeamIssues(linearClient, folderId, prevToken)
|
||||
}
|
||||
|
||||
if (folderId.startsWith(mapping.PREFIXES.PROJECT)) {
|
||||
return await _listProjectIssues(linearClient, folderId, prevToken)
|
||||
}
|
||||
|
||||
throw new sdk.RuntimeError(`Invalid folderId: ${folderId}`)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof sdk.RuntimeError) {
|
||||
throw err
|
||||
}
|
||||
throw new sdk.RuntimeError(`Failed to list items in folder: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _listTeams = async (linearClient: any, prevToken?: string) => {
|
||||
const teams = await linearClient.teams({ after: prevToken, first: PAGE_SIZE })
|
||||
const nodes = teams.nodes ?? []
|
||||
|
||||
const items = nodes.map((team: any) => mapping.mapTeamToFolder({ id: team.id, name: team.name, key: team.key }))
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: { nextToken: teams.pageInfo?.hasNextPage ? teams.pageInfo.endCursor : undefined },
|
||||
}
|
||||
}
|
||||
|
||||
const _listTeamIssues = async (linearClient: any, folderId: string, prevToken?: string) => {
|
||||
const teamId = folderId.slice(mapping.PREFIXES.TEAM.length)
|
||||
const team = await linearClient.team(teamId)
|
||||
|
||||
if (!team) {
|
||||
throw new sdk.RuntimeError(`Team not found: ${teamId}`)
|
||||
}
|
||||
|
||||
const issues = await team.issues({ after: prevToken, first: PAGE_SIZE })
|
||||
const nodes = issues.nodes ?? []
|
||||
|
||||
const items = nodes.map((issue: any) =>
|
||||
mapping.mapIssueToFile({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
updatedAt: issue.updatedAt?.toISOString?.() ?? new Date(issue.updatedAt).toISOString(),
|
||||
teamKey: team.key,
|
||||
})
|
||||
)
|
||||
|
||||
const projects = await team.projects({ first: PAGE_SIZE })
|
||||
const projectNodes = projects?.nodes ?? []
|
||||
if (!prevToken && projectNodes.length > 0) {
|
||||
const projectFolders = projectNodes.map((project: any) =>
|
||||
mapping.mapProjectToFolder({ id: project.id, name: project.name }, team.key)
|
||||
)
|
||||
items.unshift(...projectFolders)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: { nextToken: issues.pageInfo?.hasNextPage ? issues.pageInfo.endCursor : undefined },
|
||||
}
|
||||
}
|
||||
|
||||
const _listProjectIssues = async (linearClient: any, folderId: string, prevToken?: string) => {
|
||||
const projectId = folderId.slice(mapping.PREFIXES.PROJECT.length)
|
||||
const project = await linearClient.project(projectId)
|
||||
|
||||
if (!project) {
|
||||
throw new sdk.RuntimeError(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
const issues = await project.issues({ after: prevToken, first: PAGE_SIZE })
|
||||
const nodes = issues.nodes ?? []
|
||||
|
||||
const items = await Promise.all(
|
||||
nodes.map(async (issue: any) => {
|
||||
const team = await issue.team
|
||||
return mapping.mapIssueToFile({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
updatedAt: issue.updatedAt?.toISOString?.() ?? new Date(issue.updatedAt).toISOString(),
|
||||
teamKey: team?.key,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: { nextToken: issues.pageInfo?.hasNextPage ? issues.pageInfo.endCursor : undefined },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { getLinearClient } from '../../misc/utils'
|
||||
import * as mapping from '../mapping'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const filesReadonlyTransferFileToBotpress: bp.IntegrationProps['actions']['filesReadonlyTransferFileToBotpress'] =
|
||||
async ({ input, client, ctx }) => {
|
||||
const { file, fileKey, shouldIndex } = input
|
||||
|
||||
if (!file.id.startsWith(mapping.PREFIXES.ISSUE)) {
|
||||
throw new sdk.RuntimeError(`Invalid file ID: ${file.id}. Only issues can be transferred.`)
|
||||
}
|
||||
|
||||
try {
|
||||
const issueId = file.id.slice(mapping.PREFIXES.ISSUE.length)
|
||||
const linearClient = await getLinearClient({ client, ctx })
|
||||
const issue = await linearClient.issue(issueId)
|
||||
|
||||
if (!issue) {
|
||||
throw new sdk.RuntimeError(`Issue not found: ${issueId}`)
|
||||
}
|
||||
|
||||
const markdown = _buildIssueMarkdown(issue)
|
||||
|
||||
const { file: uploadedFile } = await client.uploadFile({
|
||||
key: fileKey,
|
||||
content: markdown,
|
||||
index: shouldIndex,
|
||||
})
|
||||
|
||||
return { botpressFileId: uploadedFile.id }
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof sdk.RuntimeError) {
|
||||
throw err
|
||||
}
|
||||
throw new sdk.RuntimeError(
|
||||
`Failed to transfer file to Botpress: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const _buildIssueMarkdown = (issue: any): string => {
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`# ${issue.identifier} - ${issue.title}`)
|
||||
parts.push('')
|
||||
|
||||
if (issue.description) {
|
||||
parts.push(issue.description)
|
||||
parts.push('')
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type FilesReadonlyFile = bp.events.Events['fileCreated']['file']
|
||||
type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder']
|
||||
|
||||
export const PREFIXES = {
|
||||
TEAM: 'team:',
|
||||
PROJECT: 'project:',
|
||||
ISSUE: 'issue:',
|
||||
} as const
|
||||
|
||||
export type LinearTeam = {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export type LinearProject = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type LinearIssue = {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
description?: string | null
|
||||
updatedAt: string
|
||||
teamId?: string
|
||||
teamKey?: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export const mapTeamToFolder = (team: LinearTeam): FilesReadonlyFolder => ({
|
||||
id: `${PREFIXES.TEAM}${team.id}`,
|
||||
name: team.name,
|
||||
type: 'folder',
|
||||
absolutePath: `/${team.key}`,
|
||||
})
|
||||
|
||||
export const mapProjectToFolder = (project: LinearProject, teamKey: string): FilesReadonlyFolder => ({
|
||||
id: `${PREFIXES.PROJECT}${project.id}`,
|
||||
name: project.name,
|
||||
type: 'folder',
|
||||
absolutePath: `/${teamKey}/projects/${project.name}`,
|
||||
})
|
||||
|
||||
export const mapIssueToFile = (issue: LinearIssue): FilesReadonlyFile => ({
|
||||
id: `${PREFIXES.ISSUE}${issue.id}`,
|
||||
name: `${issue.identifier} - ${issue.title}`,
|
||||
type: 'file',
|
||||
absolutePath: `/${issue.teamKey ?? 'unknown'}/${issue.identifier}.md`,
|
||||
lastModifiedDate: issue.updatedAt,
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { generateRedirection } from '@botpress/common/src/oauth-wizard'
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { Request, RuntimeError, OAUTH_IDENTIFIER_HEADER } from '@botpress/sdk'
|
||||
import { LinearWebhookClient } from '@linear/sdk/webhooks'
|
||||
|
||||
import { fireIssueCreated } from './events/issueCreated'
|
||||
import { fireIssueDeleted } from './events/issueDeleted'
|
||||
import { fireIssueUpdated } from './events/issueUpdated'
|
||||
import * as mapping from './files-readonly/mapping'
|
||||
import { LinearEvent, LinearIssueEvent } from './misc/linear'
|
||||
import { Result } from './misc/types'
|
||||
import { getLinearClient, getUserAndConversation } from './misc/utils'
|
||||
import { buildOAuthWizard } from './oauth-wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const LINEAR_WEBHOOK_SIGNATURE_HEADER = 'linear-signature'
|
||||
const LINEAR_WEBHOOK_TS_FIELD = 'webhookTimestamp'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, ctx, client, logger } = props
|
||||
logger
|
||||
.forBot()
|
||||
.debug(
|
||||
`Linear handler invoked (method="${req.method ?? ''}", path="${req.path ?? ''}", hasBody=${Boolean(req.body)})`
|
||||
)
|
||||
|
||||
if (oauthWizard.isOAuthWizardUrl(req.path)) {
|
||||
try {
|
||||
return await buildOAuthWizard(props).handleRequest()
|
||||
} catch (thrown: unknown) {
|
||||
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().error('Error while processing OAuth wizard request', errMsg)
|
||||
return generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
if (req.path === '/oauth') {
|
||||
logger.forBot().info('Linear OAuth callback received')
|
||||
const modifiedProps = { ...props, req: { ...props.req, path: '/oauth/wizard/oauth-callback' } }
|
||||
try {
|
||||
const wizardResult = await buildOAuthWizard(modifiedProps).handleRequest()
|
||||
const identifier = wizardResult.headers?.[OAUTH_IDENTIFIER_HEADER]
|
||||
return identifier
|
||||
? {
|
||||
status: 200,
|
||||
headers: { [OAUTH_IDENTIFIER_HEADER]: identifier },
|
||||
}
|
||||
: wizardResult
|
||||
} catch (thrown: unknown) {
|
||||
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().error('Error while processing OAuth callback', errMsg)
|
||||
return generateRedirection(oauthWizard.getInterstitialUrl(false, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
return
|
||||
}
|
||||
|
||||
const linearEvent: LinearEvent = JSON.parse(req.body)
|
||||
linearEvent.type = linearEvent.type.toLowerCase() as LinearEvent['type']
|
||||
|
||||
const result = _safeCheckWebhookSignature({ req, linearEvent, ctx })
|
||||
if (!result.success) {
|
||||
const message = `Error while verifying webhook signature: ${result.message}`
|
||||
logger.forBot().error(message)
|
||||
throw new RuntimeError(message)
|
||||
}
|
||||
|
||||
const linearBotId = await _getLinearBotId({ client, ctx })
|
||||
if (linearEvent.data.userId === linearBotId || linearEvent.data.user?.id === linearBotId) {
|
||||
logger.forBot().debug('Received a webhook event from the bot itself, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
// ============ EVENTS ==============
|
||||
if (linearEvent.type === 'issue' && (linearEvent.action === 'create' || linearEvent.action === 'restore')) {
|
||||
await fireIssueCreated({ linearEvent, client, ctx })
|
||||
await _emitFileChangeEvent(client, logger, linearEvent, 'created')
|
||||
return
|
||||
}
|
||||
|
||||
if (linearEvent.type === 'issue' && linearEvent.action === 'update') {
|
||||
await fireIssueUpdated({ linearEvent, client, ctx })
|
||||
await _emitFileChangeEvent(client, logger, linearEvent, 'updated')
|
||||
return
|
||||
}
|
||||
|
||||
if (linearEvent.type === 'issue' && linearEvent.action === 'remove') {
|
||||
await fireIssueDeleted({ linearEvent, client, ctx })
|
||||
await _emitFileChangeEvent(client, logger, linearEvent, 'deleted')
|
||||
return
|
||||
}
|
||||
|
||||
// ============ MESSAGES ==============
|
||||
|
||||
const linearUserId = linearEvent.data.userId ?? linearEvent.data.user?.id
|
||||
if (!linearUserId) {
|
||||
// this means the message is actually coming from the bot itself, so we don't want to process it
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
linearEvent.type === 'comment' &&
|
||||
linearEvent.action === 'create' &&
|
||||
// Comment can be added in projects which are not issues. Therefore they don't have issueId or
|
||||
// issue.id. Comments in projects are currently ignored.
|
||||
(linearEvent.data.issue || linearEvent.data.issueId)
|
||||
) {
|
||||
const linearCommentId = linearEvent.data.id
|
||||
const issueConversationId = linearEvent.data.issueId || linearEvent.data.issue.id
|
||||
const content = linearEvent.data.body
|
||||
|
||||
const { userId, conversationId } = await getUserAndConversation({
|
||||
linearIssueId: issueConversationId,
|
||||
linearUserId,
|
||||
integrationId: ctx.integrationId,
|
||||
client,
|
||||
ctx,
|
||||
})
|
||||
|
||||
await client.createMessage({
|
||||
tags: { id: linearCommentId },
|
||||
type: 'text',
|
||||
payload: { text: content },
|
||||
conversationId,
|
||||
userId: userId as string, // TODO: fix this
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const _safeCheckWebhookSignature = ({
|
||||
req,
|
||||
linearEvent,
|
||||
ctx,
|
||||
}: {
|
||||
req: Request
|
||||
linearEvent: LinearEvent
|
||||
ctx: bp.Context
|
||||
}): Result<undefined> => {
|
||||
const webhookSignatureHeader = req.headers[LINEAR_WEBHOOK_SIGNATURE_HEADER]
|
||||
|
||||
if (!webhookSignatureHeader || !req.body) {
|
||||
return { success: false, message: 'missing signature header or request body' }
|
||||
}
|
||||
|
||||
const webhookHandler = new LinearWebhookClient(_getWebhookSigningSecret({ ctx }))
|
||||
const bodyBuffer = Buffer.from(req.body)
|
||||
const timeStampHeader = linearEvent[LINEAR_WEBHOOK_TS_FIELD]
|
||||
try {
|
||||
const result = webhookHandler.verify(bodyBuffer, webhookSignatureHeader, timeStampHeader)
|
||||
if (result) {
|
||||
return { success: true, result: undefined }
|
||||
}
|
||||
return { success: false, message: 'webhook signature verification failed' }
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return {
|
||||
success: false,
|
||||
message: `Webhook signature verification failed: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _getWebhookSigningSecret = ({ ctx }: { ctx: bp.Context }) =>
|
||||
ctx.configurationType === 'apiKey' && ctx.configuration.webhookSigningSecret
|
||||
? ctx.configuration.webhookSigningSecret
|
||||
: bp.secrets.WEBHOOK_SIGNING_SECRET
|
||||
|
||||
const _getLinearBotId = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => {
|
||||
const linearClient = await getLinearClient({ client, ctx })
|
||||
const me = await linearClient.viewer
|
||||
return me.id
|
||||
}
|
||||
|
||||
const _emitFileChangeEvent = async (
|
||||
client: bp.Client,
|
||||
logger: bp.Logger,
|
||||
linearEvent: LinearIssueEvent,
|
||||
changeType: 'created' | 'updated' | 'deleted'
|
||||
) => {
|
||||
try {
|
||||
const file = mapping.mapIssueToFile({
|
||||
id: linearEvent.data.id,
|
||||
identifier: `${linearEvent.data.team?.key ?? 'UNK'}-${linearEvent.data.number}`,
|
||||
title: linearEvent.data.title,
|
||||
description: linearEvent.data.description,
|
||||
updatedAt: linearEvent.data.updatedAt ?? new Date().toISOString(),
|
||||
teamKey: linearEvent.data.team?.key,
|
||||
})
|
||||
|
||||
const emptyFiles: (typeof file)[] = []
|
||||
|
||||
await client.createEvent({
|
||||
type: 'aggregateFileChanges',
|
||||
payload: {
|
||||
modifiedItems: {
|
||||
created: changeType === 'created' ? [file] : emptyFiles,
|
||||
updated: changeType === 'updated' ? [file] : emptyFiles,
|
||||
deleted: changeType === 'deleted' ? [file] : emptyFiles,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().error('Failed to emit file-change event; swallowing to prevent webhook retries', errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import actions from './actions'
|
||||
import channels from './channels'
|
||||
import { handler } from './handler'
|
||||
import { register, unregister } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const posthogConfig: posthogHelper.PostHogConfig = {
|
||||
integrationName: INTEGRATION_NAME,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
}
|
||||
|
||||
const integrationConfig: bp.IntegrationProps = {
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
}
|
||||
|
||||
export default posthogHelper.wrapIntegration(posthogConfig, integrationConfig)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from '@botpress/common'
|
||||
import { z, RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
|
||||
const linearOAuthErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const redactLinearError = (thrown: unknown, genericErrorMessage: string): RuntimeError => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
|
||||
if (error instanceof RuntimeError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data
|
||||
const parsed = linearOAuthErrorSchema.safeParse(data)
|
||||
|
||||
if (parsed.success) {
|
||||
const { error: code, error_description: description } = parsed.data
|
||||
const detail = description ? `${code}: ${description}` : code
|
||||
return new RuntimeError(`${genericErrorMessage}: ${detail}${status ? ` (HTTP ${status})` : ''}`)
|
||||
}
|
||||
|
||||
const fallback = typeof data === 'string' && data.length > 0 ? data : data ? JSON.stringify(data) : error.message
|
||||
return new RuntimeError(`${genericErrorMessage}: ${fallback}${status ? ` (HTTP ${status})` : ''}`)
|
||||
}
|
||||
|
||||
return new RuntimeError(`${genericErrorMessage}: ${error.message}`)
|
||||
}
|
||||
|
||||
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(redactLinearError)
|
||||
|
||||
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
|
||||
@@ -0,0 +1,339 @@
|
||||
import { RuntimeError, z } from '@botpress/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import axios from 'axios'
|
||||
import { handleErrorsDecorator as handleErrors } from './error-handling'
|
||||
import { useDeskOAuth } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type Credentials = bp.states.States['credentials']['payload']
|
||||
|
||||
type BaseEvent = {
|
||||
action: 'create' | 'update' | 'remove' | 'restore'
|
||||
type: string
|
||||
webhookTimestamp: number
|
||||
data: {
|
||||
issueId?: string
|
||||
userId?: string
|
||||
user?: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type LinearIssueEvent = {
|
||||
type: 'issue'
|
||||
data: {
|
||||
id: string
|
||||
identifier: string
|
||||
url: string
|
||||
creatorId: string
|
||||
labelIds?: string[]
|
||||
number: number
|
||||
title: string
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
description: string | null
|
||||
priority: number
|
||||
labels: {
|
||||
name: string
|
||||
}[]
|
||||
subscriberIds: string[]
|
||||
assignee?: {
|
||||
id: string
|
||||
}
|
||||
team?: {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
state: {
|
||||
name: string
|
||||
color: string
|
||||
type: string
|
||||
}
|
||||
project?: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
} & BaseEvent
|
||||
|
||||
export type LinearCommentEvent = {
|
||||
type: 'comment'
|
||||
data: {
|
||||
id: string
|
||||
body: string
|
||||
issue: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
} & BaseEvent
|
||||
|
||||
export type LinearEvent = LinearCommentEvent | LinearIssueEvent
|
||||
|
||||
const linearEndpoint = 'https://api.linear.app'
|
||||
|
||||
const oauthHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
} as const
|
||||
|
||||
const oauthSchema = z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
})
|
||||
|
||||
type OAuthResponse = z.infer<typeof oauthSchema>
|
||||
|
||||
export type Actor = 'user' | 'app'
|
||||
|
||||
const tokenRequestSchema = z.object({
|
||||
actor: z.enum(['user', 'app']),
|
||||
redirect_uri: z.string(),
|
||||
})
|
||||
|
||||
const getAccessTokenRequestSchema = tokenRequestSchema.extend({
|
||||
grant_type: z.literal('authorization_code'),
|
||||
code: z.string(),
|
||||
})
|
||||
|
||||
const refreshTokenRequestSchema = tokenRequestSchema.extend({
|
||||
grant_type: z.literal('refresh_token'),
|
||||
refresh_token: z.string(),
|
||||
})
|
||||
|
||||
const migrateTokenRequestSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
|
||||
export class LinearOauthClient {
|
||||
private _clientId: string
|
||||
private _clientSecret: string
|
||||
private _redirectUri: string
|
||||
|
||||
public constructor(useDeskOAuth?: boolean) {
|
||||
this._clientId = useDeskOAuth ? bp.secrets.DESK_CLIENT_ID : bp.secrets.CLIENT_ID
|
||||
this._clientSecret = useDeskOAuth ? bp.secrets.DESK_CLIENT_SECRET : bp.secrets.CLIENT_SECRET
|
||||
this._redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth`
|
||||
}
|
||||
|
||||
private async _handleOAuthRequest<TSchema extends z.ZodObject>(
|
||||
url: string,
|
||||
body: z.infer<TSchema>
|
||||
): Promise<OAuthResponse> {
|
||||
const form = new URLSearchParams({
|
||||
client_id: this._clientId,
|
||||
client_secret: this._clientSecret,
|
||||
...body,
|
||||
})
|
||||
try {
|
||||
const response = await axios.post(url, form.toString(), { headers: oauthHeaders })
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const message = err.response?.data?.error_description || err.message
|
||||
throw new RuntimeError(`OAuth request failed: ${message}`)
|
||||
}
|
||||
throw new RuntimeError(`OAuth request failed: ${String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _parseCredentials(res: OAuthResponse): Credentials {
|
||||
const { data, error } = oauthSchema.safeParse(res)
|
||||
if (error) {
|
||||
throw new RuntimeError(`Failed to parse OAuth token response: ${error.message}`)
|
||||
}
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in)
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
@handleErrors('Failed to migrate old Linear OAuth token')
|
||||
public async migrateOldToken(oldAccessToken: string): Promise<Credentials> {
|
||||
const data = await this._handleOAuthRequest<typeof migrateTokenRequestSchema>(
|
||||
`${linearEndpoint}/oauth/migrate_old_token`,
|
||||
{
|
||||
access_token: oldAccessToken,
|
||||
}
|
||||
)
|
||||
return this._parseCredentials(data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to refresh Linear OAuth access token')
|
||||
public async getAccessTokenFromRefreshToken(oldRefreshToken: string, actor: Actor): Promise<Credentials> {
|
||||
const data = await this._handleOAuthRequest<typeof refreshTokenRequestSchema>(`${linearEndpoint}/oauth/token`, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: oldRefreshToken,
|
||||
actor,
|
||||
redirect_uri: this._redirectUri,
|
||||
})
|
||||
return this._parseCredentials(data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to obtain Linear OAuth access token from authorization code')
|
||||
public async getAccessTokenFromOAuthCode(code: string, actor: Actor) {
|
||||
const data = await this._handleOAuthRequest<typeof getAccessTokenRequestSchema>(`${linearEndpoint}/oauth/token`, {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
actor,
|
||||
redirect_uri: this._redirectUri,
|
||||
})
|
||||
if (!data.refresh_token) {
|
||||
return this.migrateOldToken(data.access_token)
|
||||
}
|
||||
return this._parseCredentials(data)
|
||||
}
|
||||
|
||||
@handleErrors('Failed to resolve valid Linear OAuth credentials')
|
||||
public async resolveValidCredentials(current: Credentials, actor: Actor): Promise<Credentials> {
|
||||
const FIVE_MINUTES_MS = 5 * 60 * 1000
|
||||
const isExpired = new Date(current.expiresAt).getTime() <= Date.now() + FIVE_MINUTES_MS
|
||||
|
||||
if (isExpired) {
|
||||
return this.getAccessTokenFromRefreshToken(current.refreshToken, actor)
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
@handleErrors('Failed to create Linear client')
|
||||
public static async create(props: { client: bp.Client; ctx: bp.Context }) {
|
||||
return LinearOauthClient._createFromStoredCredentials(props, 'credentials')
|
||||
}
|
||||
|
||||
public static async createAdmin(props: { client: bp.Client; ctx: bp.Context }) {
|
||||
return LinearOauthClient._createFromStoredCredentials(props, 'adminCredentials')
|
||||
}
|
||||
|
||||
private static async _createFromStoredCredentials(
|
||||
props: { client: bp.Client; ctx: bp.Context },
|
||||
stateName: 'credentials' | 'adminCredentials'
|
||||
) {
|
||||
const { ctx, client } = props
|
||||
if (ctx.configurationType === 'apiKey') {
|
||||
return new LinearClient({ apiKey: ctx.configuration.apiKey })
|
||||
}
|
||||
|
||||
const {
|
||||
state: { payload },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: stateName,
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
|
||||
let effectiveStateName = stateName
|
||||
let effectivePayload = payload
|
||||
if (stateName === 'adminCredentials' && !payload.accessToken) {
|
||||
const {
|
||||
state: { payload: fallbackPayload },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
effectiveStateName = 'credentials'
|
||||
effectivePayload = fallbackPayload
|
||||
}
|
||||
|
||||
const {
|
||||
state: { payload: environment },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
const useDesk = useDeskOAuth(environment)
|
||||
const linearOauthClient = new LinearOauthClient(useDesk)
|
||||
const actor: Actor = effectiveStateName === 'adminCredentials' ? 'user' : (environment.runtimeActor ?? 'app')
|
||||
const credentials = await linearOauthClient.resolveValidCredentials(effectivePayload, actor)
|
||||
|
||||
if (credentials.accessToken !== effectivePayload.accessToken) {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: effectiveStateName,
|
||||
id: ctx.integrationId,
|
||||
payload: credentials,
|
||||
})
|
||||
}
|
||||
|
||||
return new LinearClient({ accessToken: credentials.accessToken })
|
||||
}
|
||||
}
|
||||
|
||||
const _findWebhookByUrl = async (linearClient: LinearClient, url: string) => {
|
||||
let page = await linearClient.webhooks()
|
||||
while (true) {
|
||||
const match = page.nodes.find((w) => w.url === url)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
if (!page.pageInfo.hasNextPage) {
|
||||
return undefined
|
||||
}
|
||||
page = await page.fetchNext()
|
||||
}
|
||||
}
|
||||
|
||||
export const unregisterWebhook = async ({
|
||||
linearClient,
|
||||
logger,
|
||||
url,
|
||||
}: {
|
||||
linearClient: LinearClient
|
||||
logger: bp.Logger
|
||||
url: string
|
||||
}) => {
|
||||
const webhook = await _findWebhookByUrl(linearClient, url)
|
||||
if (!webhook) {
|
||||
logger.forBot().info('No Linear webhook found to unregister, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
logger.forBot().info('Unregistering Linear webhook...')
|
||||
await linearClient.deleteWebhook(webhook.id)
|
||||
logger.forBot().info('Linear webhook unregistered successfully.')
|
||||
}
|
||||
|
||||
export const registerWebhook = async ({
|
||||
linearClient,
|
||||
logger,
|
||||
url,
|
||||
}: {
|
||||
linearClient: LinearClient
|
||||
logger: bp.Logger
|
||||
url: string
|
||||
}) => {
|
||||
const existing = await _findWebhookByUrl(linearClient, url)
|
||||
if (existing) {
|
||||
logger.forBot().info('Linear webhook already registered, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
logger.forBot().info('Registering Linear webhook...')
|
||||
await linearClient.createWebhook({
|
||||
url,
|
||||
resourceTypes: ['Issue', 'Comment'],
|
||||
secret: bp.secrets.WEBHOOK_SIGNING_SECRET,
|
||||
allPublicTeams: true,
|
||||
label: 'Botpress',
|
||||
})
|
||||
logger.forBot().info('Linear webhook registered successfully.')
|
||||
}
|
||||
|
||||
export const revokeToken = async (token: string, tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token') => {
|
||||
const form = new URLSearchParams({ token, token_type_hint: tokenTypeHint })
|
||||
try {
|
||||
await axios.post(`${linearEndpoint}/oauth/revoke`, form.toString(), { headers: oauthHeaders })
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const message = err.response?.data?.error_description || err.message
|
||||
throw new RuntimeError(`Failed to revoke token: ${message}`)
|
||||
}
|
||||
throw new RuntimeError(`Failed to revoke token: ${String(err)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type Handler = bp.IntegrationProps['handler']
|
||||
export type HandlerProps = bp.HandlerProps
|
||||
export type IntegrationCtx = bp.Context
|
||||
export type Logger = bp.Logger
|
||||
|
||||
export type Client = bp.Client
|
||||
export type Conversation = bp.ClientResponses['getConversation']['conversation']
|
||||
export type Message = bp.ClientResponses['getMessage']['message']
|
||||
export type User = bp.ClientResponses['getUser']['user']
|
||||
export type Event = bp.ClientResponses['getEvent']['event']
|
||||
|
||||
export type EventDefinition = sdk.EventDefinition
|
||||
export type ActionDefinition = sdk.ActionDefinition
|
||||
export type ChannelDefinition = sdk.ChannelDefinition
|
||||
export type MessageDefinition = sdk.MessageDefinition
|
||||
|
||||
export type ActionProps = bp.AnyActionProps
|
||||
export type MessageHandlerProps = bp.AnyMessageProps
|
||||
export type AckFunction = bp.AnyAckFunction
|
||||
|
||||
export type Result<T> =
|
||||
| {
|
||||
success: true
|
||||
result: T
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Issue, LinearClient, Team } from '@linear/sdk'
|
||||
import { LinearOauthClient } from './linear'
|
||||
import { AckFunction, MessageHandlerProps } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type LinearClientProps = {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
export async function getLinearClient({ client, ctx }: LinearClientProps) {
|
||||
return await LinearOauthClient.create({ client, ctx })
|
||||
}
|
||||
|
||||
export const useDeskOAuth = ({ env, source }: bp.states.environment.Environment['payload']) =>
|
||||
env === 'production' && source === 'desk'
|
||||
|
||||
type ValueOf<T> = T[keyof T]
|
||||
type CreateCommentProps = Omit<ValueOf<bp.MessageProps['issue']>, 'payload'> & { content: string }
|
||||
export async function createComment(args: CreateCommentProps) {
|
||||
const { ctx, conversation, ack, content } = args
|
||||
const linearClient = await getLinearClient(args)
|
||||
const issueId = getIssueId(conversation)
|
||||
|
||||
let createAsUser: string | undefined = undefined
|
||||
let displayIconUrl: string | undefined = undefined
|
||||
if (ctx.configurationType === null) {
|
||||
createAsUser = ctx.configuration.displayName
|
||||
displayIconUrl = ctx.configuration.avatarUrl
|
||||
}
|
||||
|
||||
const res = await linearClient.createComment({
|
||||
issueId,
|
||||
body: content,
|
||||
createAsUser,
|
||||
displayIconUrl,
|
||||
})
|
||||
const comment = await res.comment
|
||||
if (!comment) {
|
||||
return
|
||||
}
|
||||
await ackMessage(comment.id, ack)
|
||||
}
|
||||
|
||||
export function getCardContent(card: any) {
|
||||
// TODO: fixme
|
||||
return `*${card.title}*${card.subtitle ? '\n' + card.subtitle : ''}`
|
||||
}
|
||||
|
||||
export function getIssueId(conversation: MessageHandlerProps['conversation']): string {
|
||||
const issueId = conversation.tags.id
|
||||
|
||||
if (!issueId) {
|
||||
throw Error(`No issue found for conversation ${conversation.id}`)
|
||||
}
|
||||
|
||||
return issueId
|
||||
}
|
||||
|
||||
export async function ackMessage(commentId: string, ack: AckFunction) {
|
||||
await ack({ tags: { id: commentId } })
|
||||
}
|
||||
|
||||
export const getIssueTags = async (issue: Issue) => {
|
||||
const parent = await issue.parent
|
||||
|
||||
return {
|
||||
id: issue.id,
|
||||
url: issue.url,
|
||||
title: issue.title,
|
||||
parentId: parent?.id || '',
|
||||
parentTitle: parent?.title || '',
|
||||
parentUrl: parent?.url || '',
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserAndConversation = async (props: {
|
||||
linearUserId: string
|
||||
linearIssueId: string
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
integrationId: string
|
||||
forceUpdate?: boolean
|
||||
}): Promise<{ conversationId: string; userId?: string }> => {
|
||||
const { conversation } = await props.client.getOrCreateConversation({
|
||||
channel: 'issue',
|
||||
tags: {
|
||||
id: props.linearIssueId,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
const linearClient = await getLinearClient(props)
|
||||
|
||||
// TODO: better way to know if the conversation was just created
|
||||
if (props.forceUpdate || !conversation.tags.url) {
|
||||
const existingIssue = await linearClient.issue(props.linearIssueId)
|
||||
const newTags = await getIssueTags(existingIssue)
|
||||
|
||||
await props.client.updateConversation({ id: conversation.id, tags: newTags })
|
||||
}
|
||||
|
||||
if (!props.linearUserId) {
|
||||
return { conversationId: conversation.id }
|
||||
}
|
||||
|
||||
const { user } = await props.client.getOrCreateUser({ tags: { id: props.linearUserId } })
|
||||
if (!user.name) {
|
||||
const linearUser = await linearClient.user(props.linearUserId)
|
||||
|
||||
await props.client.updateUser({
|
||||
id: user.id,
|
||||
name: linearUser.name,
|
||||
pictureUrl: linearUser.avatarUrl,
|
||||
tags: user.tags,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
|
||||
export const getTeamByName = async (linearClient: LinearClient, name?: string) => {
|
||||
const team = await linearClient.teams({ filter: { name: { contains: name } } })
|
||||
return team.nodes?.[0]
|
||||
}
|
||||
|
||||
export const getTeam = async (linearClient: LinearClient, teamInstance?: Team, name?: string) => {
|
||||
const team = name ? await getTeamByName(linearClient, name) : teamInstance
|
||||
|
||||
return {
|
||||
...team,
|
||||
findLabelIds: async (names: string[]) => {
|
||||
const labels = await team?.labels({ filter: { name: { in: names } } })
|
||||
return labels?.nodes?.map((label) => label.id)
|
||||
},
|
||||
findProjectId: async (projectName: string) => {
|
||||
const projects = await team?.projects({ filter: { name: { contains: projectName } } })
|
||||
return projects?.nodes?.map((project) => project.id)?.[0]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { z } from '@botpress/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import { LinearOauthClient, registerWebhook } from './misc/linear'
|
||||
import { useDeskOAuth } from './misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
|
||||
const APP_SCOPES = 'read,write,issues:create,comments:create'
|
||||
const ADMIN_SCOPES = 'read,write,admin,issues:create,comments:create'
|
||||
const USER_ACTOR_FALLBACK_STEP = 'use-user-actor'
|
||||
|
||||
const _buildAuthorizeUrl = ({
|
||||
clientId,
|
||||
actor,
|
||||
scopes,
|
||||
state,
|
||||
}: {
|
||||
clientId: string
|
||||
actor: 'user' | 'app'
|
||||
scopes: string
|
||||
state: string
|
||||
}) =>
|
||||
'https://linear.app/oauth/authorize' +
|
||||
`?client_id=${clientId}` +
|
||||
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
|
||||
'&response_type=code' +
|
||||
'&prompt=consent' +
|
||||
`&actor=${actor}` +
|
||||
`&state=${state}` +
|
||||
`&scope=${scopes}`
|
||||
|
||||
const _saveUserActorRuntimeCredentials = async ({
|
||||
ctx,
|
||||
client,
|
||||
responses,
|
||||
setIntegrationIdentifier,
|
||||
}: Pick<bp.HandlerProps, 'ctx' | 'client'> &
|
||||
Pick<oauthWizard.WizardStepInputProps, 'responses' | 'setIntegrationIdentifier'>) => {
|
||||
const {
|
||||
state: { payload: environment },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
const {
|
||||
state: { payload: adminCredentials },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'adminCredentials',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
|
||||
if (!adminCredentials.accessToken) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Cannot install with user actor because user OAuth credentials are missing.',
|
||||
})
|
||||
}
|
||||
|
||||
const linearClient = new LinearClient({ accessToken: adminCredentials.accessToken })
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: adminCredentials,
|
||||
})
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...environment, runtimeActor: 'user' },
|
||||
})
|
||||
|
||||
const organization = await linearClient.organization
|
||||
setIntegrationIdentifier(organization.id)
|
||||
return responses.endWizard({ success: true })
|
||||
}
|
||||
|
||||
const _startStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({ ctx, client, query, responses }) => {
|
||||
const searchParams = new URLSearchParams(query)
|
||||
const payload = {
|
||||
source: searchParams.get('source') ?? undefined,
|
||||
env: z.enum(['preview', 'production']).catch('preview').parse(searchParams.get('env')),
|
||||
wizardPhase: 'admin',
|
||||
} as bp.states.environment.Environment['payload']
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
payload,
|
||||
})
|
||||
|
||||
const isDesk = useDeskOAuth(payload)
|
||||
const clientId = isDesk ? bp.secrets.DESK_CLIENT_ID : bp.secrets.CLIENT_ID
|
||||
|
||||
return responses.redirectToExternalUrl(
|
||||
_buildAuthorizeUrl({ clientId, actor: 'user', scopes: ADMIN_SCOPES, state: ctx.webhookId })
|
||||
)
|
||||
}
|
||||
|
||||
const _oauthCallbackStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
query,
|
||||
responses,
|
||||
setIntegrationIdentifier,
|
||||
}) => {
|
||||
const {
|
||||
state: { payload: environment },
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
|
||||
const error = query.get('error')
|
||||
if (error) {
|
||||
const description = query.get('error_description') ?? ''
|
||||
if (environment.wizardPhase === 'app') {
|
||||
logger.forBot().warn(`Linear app-actor OAuth failed (${error}: ${description}). Asking for user-actor fallback.`)
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Linear app authorization failed',
|
||||
htmlOrMarkdownPageContents:
|
||||
'You can continue without automatic webhooks. Botpress will still be able to call Linear using your account, but Linear events will not be sent back to Botpress unless webhooks are configured by a workspace admin.',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Install without webhooks',
|
||||
buttonType: 'primary',
|
||||
action: 'navigate',
|
||||
navigateToStep: USER_ACTOR_FALLBACK_STEP,
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
buttonType: 'secondary',
|
||||
action: 'close',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return responses.endWizard({ success: false, errorMessage: `OAuth error: ${error} - ${description}` })
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
if (!code) {
|
||||
return responses.endWizard({ success: false, errorMessage: 'Authorization code not present in OAuth callback' })
|
||||
}
|
||||
|
||||
const useDesk = useDeskOAuth(environment)
|
||||
const linearOauthClient = new LinearOauthClient(useDesk)
|
||||
const tokenActor = environment.wizardPhase === 'app' ? 'app' : 'user'
|
||||
const credentials = await linearOauthClient.getAccessTokenFromOAuthCode(code, tokenActor)
|
||||
|
||||
if (environment.wizardPhase === 'app') {
|
||||
logger.forBot().info('Received app-actor OAuth callback, saving runtime credentials...')
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: credentials,
|
||||
})
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...environment, runtimeActor: 'app' },
|
||||
})
|
||||
|
||||
const linearClient = new LinearClient({ accessToken: credentials.accessToken })
|
||||
const organization = await linearClient.organization
|
||||
setIntegrationIdentifier(organization.id)
|
||||
return responses.endWizard({ success: true })
|
||||
}
|
||||
|
||||
logger.forBot().info('Received user-actor OAuth callback, saving admin credentials...')
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'adminCredentials',
|
||||
id: ctx.integrationId,
|
||||
payload: credentials,
|
||||
})
|
||||
|
||||
const linearClient = new LinearClient({ accessToken: credentials.accessToken })
|
||||
const webhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
|
||||
try {
|
||||
await registerWebhook({ linearClient, logger, url: webhookUrl })
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().warn('Failed to register webhook:', errorMessage)
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'environment',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...environment, wizardPhase: 'app' },
|
||||
})
|
||||
|
||||
const clientId = useDesk ? bp.secrets.DESK_CLIENT_ID : bp.secrets.CLIENT_ID
|
||||
return responses.redirectToExternalUrl(
|
||||
_buildAuthorizeUrl({
|
||||
clientId,
|
||||
actor: 'app',
|
||||
scopes: APP_SCOPES,
|
||||
state: ctx.webhookId,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const _useUserActorStep: oauthWizard.WizardStepHandler<bp.HandlerProps> = async ({
|
||||
ctx,
|
||||
client,
|
||||
responses,
|
||||
setIntegrationIdentifier,
|
||||
}) =>
|
||||
_saveUserActorRuntimeCredentials({
|
||||
ctx,
|
||||
client,
|
||||
responses,
|
||||
setIntegrationIdentifier,
|
||||
})
|
||||
|
||||
export const buildOAuthWizard = (props: bp.HandlerProps) =>
|
||||
new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startStep })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackStep })
|
||||
.addStep({ id: USER_ACTOR_FALLBACK_STEP, handler: _useUserActorStep })
|
||||
.build()
|
||||
@@ -0,0 +1,114 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { LinearOauthClient, registerWebhook, revokeToken, unregisterWebhook } from './misc/linear'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const INSUFFICIENT_LINEAR_ROLE_ERROR = 'Invalid role: admin required'
|
||||
const WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE =
|
||||
'You must be an admin on the Linear workspace to automatically register webhooks. ' +
|
||||
'You may still use the integration without webhooks, but some functionality will be limited or unavailable. ' +
|
||||
'In order to fully configure the integration, please connect to a Linear account on which you are an admin.'
|
||||
|
||||
const _isWebhookManuallyRegistered = (ctx: bp.HandlerProps['ctx']) =>
|
||||
ctx.configurationType === 'apiKey' && ctx.configuration.webhookSigningSecret
|
||||
|
||||
const _revokeCredentials = async (credentials: { accessToken?: string; refreshToken?: string }) => {
|
||||
if (credentials.accessToken) {
|
||||
await revokeToken(credentials.accessToken, 'access_token')
|
||||
}
|
||||
if (credentials.refreshToken) {
|
||||
await revokeToken(credentials.refreshToken, 'refresh_token')
|
||||
}
|
||||
}
|
||||
|
||||
const _getWebhookRegistrationErrorMessage = (errorMessage: string) => {
|
||||
if (errorMessage.includes(INSUFFICIENT_LINEAR_ROLE_ERROR)) {
|
||||
return WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE
|
||||
}
|
||||
|
||||
return `Failed to register webhook: ${errorMessage}`
|
||||
}
|
||||
|
||||
const _reportWebhookRegistrationIssue = (logger: bp.HandlerProps['logger'], errorMessage: string) => {
|
||||
if (!errorMessage.includes(INSUFFICIENT_LINEAR_ROLE_ERROR)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.issue({
|
||||
type: 'issue',
|
||||
title: 'Linear webhook registration requires an admin',
|
||||
description: WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE,
|
||||
category: 'configuration',
|
||||
groupBy: ['linear_webhook_admin_required'],
|
||||
code: 'linear_webhook_admin_required',
|
||||
data: {
|
||||
details: {
|
||||
raw: INSUFFICIENT_LINEAR_ROLE_ERROR,
|
||||
pretty: WEBHOOK_REGISTRATION_ADMIN_REQUIRED_MESSAGE,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
|
||||
const manuallyRegistered = _isWebhookManuallyRegistered(ctx)
|
||||
logger.forBot().info('Registering Linear integration.')
|
||||
|
||||
if (!manuallyRegistered) {
|
||||
const {
|
||||
state: { payload: environment },
|
||||
} = await client.getState({ type: 'integration', name: 'environment', id: ctx.integrationId })
|
||||
|
||||
if (environment.runtimeActor === 'user') {
|
||||
logger.forBot().info('Skipping automatic Linear webhook registration: integration is using user-actor OAuth.')
|
||||
logger.forBot().info(`Linear integration registered successfully (integrationId="${ctx.integrationId}").`)
|
||||
return
|
||||
}
|
||||
|
||||
const linearClient = await LinearOauthClient.createAdmin({ client, ctx })
|
||||
const webhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
|
||||
logger.forBot().info('Registering Linear webhook')
|
||||
try {
|
||||
await registerWebhook({ linearClient, logger, url: webhookUrl })
|
||||
logger.forBot().info('Linear webhook registered')
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
_reportWebhookRegistrationIssue(logger, errorMessage)
|
||||
throw new RuntimeError(_getWebhookRegistrationErrorMessage(errorMessage))
|
||||
}
|
||||
} else {
|
||||
logger
|
||||
.forBot()
|
||||
.info('Skipping automatic Linear webhook registration: webhookSigningSecret is set in apiKey configuration.')
|
||||
}
|
||||
logger.forBot().info(`Linear integration registered successfully (integrationId="${ctx.integrationId}").`)
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ctx, logger }) => {
|
||||
const manuallyRegistered = _isWebhookManuallyRegistered(ctx)
|
||||
logger.forBot().info('Unregistering Linear integration.')
|
||||
|
||||
if (manuallyRegistered) {
|
||||
logger.forBot().info('Skipping Linear webhook unregistration: webhook was manually registered.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const linearClient = await LinearOauthClient.createAdmin({ client, ctx })
|
||||
const webhookUrl = `${process.env.BP_WEBHOOK_URL}/${ctx.webhookId}`
|
||||
logger.forBot().info('Unregistering Linear webhook.')
|
||||
await unregisterWebhook({ linearClient, logger, url: webhookUrl })
|
||||
|
||||
logger.forBot().info('Revoking Linear access tokens.')
|
||||
const [{ state: appState }, { state: adminState }] = await Promise.all([
|
||||
client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId }),
|
||||
client.getState({ type: 'integration', name: 'adminCredentials', id: ctx.integrationId }),
|
||||
])
|
||||
await _revokeCredentials(appState.payload)
|
||||
if (adminState.payload.accessToken !== appState.payload.accessToken) {
|
||||
await _revokeCredentials(adminState.payload)
|
||||
}
|
||||
logger.forBot().info('Linear integration unregistration completed.')
|
||||
} catch (thrown) {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().warn('Failed to unregister webhook:', errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user