chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+300
View File
@@ -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']
+30
View File
@@ -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']
+102
View File
@@ -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']
+138
View File
@@ -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>>()
+53
View File
@@ -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']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
parse_json!(.body).organizationId
+40
View File
@@ -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).
+29
View File
@@ -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 },
},
},
}))
+6
View File
@@ -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 }}"
+32
View File
@@ -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>)
}
+39
View File
@@ -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) } : {}
}
+27
View File
@@ -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,
})
+209
View File
@@ -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)
}
}
+23
View File
@@ -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)
+339
View File
@@ -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)}`)
}
}
+32
View File
@@ -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
}
+143
View File
@@ -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]
},
}
}
+231
View File
@@ -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()
+114
View File
@@ -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)
}
}
+12
View File
@@ -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"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config