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
@@ -0,0 +1,120 @@
import Fuse from 'fuse.js'
import { wrapActionAndInjectOctokit } from 'src/misc/action-wrapper'
import { Target } from '../definitions/actions'
const fuse = new Fuse<Target>([], {
shouldSort: true,
threshold: 0.3,
minMatchCharLength: 2,
isCaseSensitive: false,
includeMatches: true,
findAllMatches: true,
useExtendedSearch: true,
ignoreLocation: true,
keys: ['displayName'],
})
export const findTarget = wrapActionAndInjectOctokit(
{ actionName: 'findTarget', errorMessage: 'Failed to find target' },
async ({ octokit, owner, logger }, { repo, query, channel }) => {
const targets: Target[] = []
// Allow the repo input to be a full "owner/repo" path; otherwise fall back
// to the owner of the connected installation. Note: the installation token
// can only access repos owned by the account the app is installed on, so
// the owner here must still be within that installation.
const slashIndex = repo.indexOf('/')
const effectiveOwner = slashIndex >= 0 ? repo.slice(0, slashIndex) : owner
const effectiveRepo = slashIndex >= 0 ? repo.slice(slashIndex + 1) : repo
logger
.forBot()
.info(`findTarget: querying ${effectiveOwner}/${effectiveRepo} channel=${channel} query=${JSON.stringify(query)}`)
const queryPullRequests = async () => {
const pullRequests = await octokit.rest.pulls.list({
owner: effectiveOwner,
repo: effectiveRepo,
per_page: 100,
state: 'open',
})
const items = (pullRequests.data || []).map<Target>((item) => ({
displayName: `${item.number} - ${item.title}`,
tags: { id: item.number?.toString() },
channel: 'pullRequest',
}))
if (query) {
fuse.setCollection(items)
const matched = fuse.search<Target>(query).map((x) => x.item)
logger
.forBot()
.info(`findTarget: pulls.list returned ${items.length} open PRs, ${matched.length} matched query`)
targets.push(...matched)
} else {
logger.forBot().info(`findTarget: pulls.list returned ${items.length} open PRs (no query filter)`)
targets.push(...items)
}
}
const queryIssues = async () => {
const response = await octokit.rest.issues.listForRepo({
owner: effectiveOwner,
repo: effectiveRepo,
per_page: 100,
state: 'open',
})
const { data: allIssues } = response
const issues = allIssues.filter((issue) => !issue.pull_request)
const items = (issues || []).map<Target>((item) => {
const tags: Record<string, string> = {
id: item.number?.toString(),
}
const pushTags = (key: string, value: string | undefined | null) => {
if (value) {
tags[key] = value
}
}
pushTags('assigneeId', item.assignee?.id?.toString())
pushTags('assigneeName', item.assignee?.name)
pushTags('assigneeLogin', item.assignee?.login)
pushTags('assigneeEmail', item.assignee?.email)
return {
displayName: `${item.number} - ${item.title}`,
tags,
channel: 'issue',
}
})
if (query) {
fuse.setCollection(items)
const matched = fuse.search<Target>(query).map((x) => x.item)
logger
.forBot()
.info(`findTarget: issues.listForRepo returned ${items.length} open issues, ${matched.length} matched query`)
targets.push(...matched)
} else {
logger.forBot().info(`findTarget: issues.listForRepo returned ${items.length} open issues (no query filter)`)
targets.push(...items)
}
}
if (channel === 'pullRequest') {
await queryPullRequests()
}
if (channel === 'issue') {
await queryIssues()
}
return {
targets,
}
}
)
+5
View File
@@ -0,0 +1,5 @@
import { findTarget } from './find-target'
export default {
findTarget,
}
+135
View File
@@ -0,0 +1,135 @@
import { ChannelProps, wrapChannelAndInjectOctokit } from './misc/channel-wrapper'
import { Channels } from './misc/types'
export default {
pullRequest: {
messages: {
text: wrapChannelAndInjectOctokit({ channelName: 'pullRequest', messageType: 'text' }, async (props) => {
await _createIssueComment({
issueNumber: _getIntTagOrThrowException(props.conversation.tags, 'pullRequestNumber'),
commentBody: props.payload.text,
...props,
})
}),
},
},
issue: {
messages: {
text: wrapChannelAndInjectOctokit({ channelName: 'issue', messageType: 'text' }, async (props) => {
console.info(`Sending a text message on channel issue with content ${props.payload.text}`)
await _createIssueComment({
issueNumber: _getIntTagOrThrowException(props.conversation.tags, 'issueNumber'),
commentBody: props.payload.text,
...props,
})
}),
},
},
pullRequestReviewComment: {
messages: {
text: wrapChannelAndInjectOctokit(
{ channelName: 'pullRequestReviewComment', messageType: 'text' },
async ({ conversation, payload, ack, client, owner, repo, octokit }) => {
const comment = await octokit.rest.pulls.createReviewComment({
body: payload.text,
owner,
repo,
pull_number: _getIntTagOrThrowException(conversation.tags, 'pullRequestNumber'),
commit_id: _getStrTagOrThrowException(conversation.tags, 'commitBeingReviewed'),
line: _getIntTagOrThrowException(conversation.tags, 'lineBeingReviewed'),
in_reply_to: _getIntTagOrThrowException(conversation.tags, 'lastCommentId'),
path: _getStrTagOrThrowException(conversation.tags, 'fileBeingReviewed'),
})
await ack({
tags: {
commentNodeId: comment.data.node_id,
commentId: comment.data.id.toString(),
commentUrl: comment.data.html_url,
},
})
await client.updateConversation({
id: conversation.id,
tags: {
lastCommentId: comment.data.id.toString(),
} as typeof conversation.tags,
})
}
),
},
},
discussion: {
messages: {
text: wrapChannelAndInjectOctokit(
{ channelName: 'discussion', messageType: 'text' },
async ({ conversation, payload, ack, octokit }) => {
const {
addDiscussionComment: { comment },
} = await octokit.executeGraphqlQuery('addDiscussionComment', {
discussionNodeId: _getStrTagOrThrowException(conversation.tags, 'discussionNodeId'),
body: payload.text,
})
await ack({
tags: { commentId: comment.databaseId.toString(), commentNodeId: comment.id, commentUrl: comment.url },
})
}
),
},
},
discussionComment: {
messages: {
text: wrapChannelAndInjectOctokit(
{ channelName: 'discussionComment', messageType: 'text' },
async ({ conversation, message, payload, ack, octokit }) => {
console.info(`Sending a text message on channel discussion thread with content ${payload.text}`)
const {
addDiscussionComment: { comment },
} = await octokit.executeGraphqlQuery('addDiscussionCommentReply', {
discussionNodeId: _getStrTagOrThrowException(conversation.tags, 'discussionNodeId'),
replyToCommentNodeId: _getStrTagOrThrowException(message.tags, 'commentNodeId'),
body: payload.text,
})
await ack({
tags: { commentId: comment.databaseId.toString(), commentNodeId: comment.id, commentUrl: comment.url },
})
}
),
},
},
} satisfies Channels
const _createIssueComment = async ({
issueNumber,
commentBody,
ack,
owner,
repo,
octokit,
}: { issueNumber: number; commentBody: string } & ChannelProps) => {
const { data } = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody,
})
await ack({ tags: { commentNodeId: data.node_id, commentId: data.id.toString(), commentUrl: data.html_url } })
}
const _getStrTagOrThrowException = <R extends Record<string, string>>(tags: R, name: Extract<keyof R, string>) => {
const value = tags[name]
if (!value) {
throw new Error(`Missing tag ${name}`)
}
return value
}
const _getIntTagOrThrowException = <R extends Record<string, string>>(tags: R, name: Extract<keyof R, string>) =>
parseInt(_getStrTagOrThrowException(tags, name))
+2
View File
@@ -0,0 +1,2 @@
export const INTEGRATION_NAME = 'github'
export const GITHUB_SIGNATURE_HEADER = 'x-hub-signature-256'
@@ -0,0 +1,48 @@
import * as sdk from '@botpress/sdk'
import { z } from '@botpress/sdk'
const Channels = ['pullRequest', 'issue'] as const
export type Target = {
displayName: string
tags: { [key: string]: string }
channel: (typeof Channels)[number]
}
export const actions = {
findTarget: {
title: 'Find Target',
description: 'List open pull requests or issues in a repository (optionally filtered by a title search).',
input: {
schema: z.object({
query: z
.string()
.optional()
.title('Query')
.describe(
'Optional fuzzy filter applied to the "<number> - <title>" of each open PR/issue. Leave empty to return all open items. This is NOT GitHub search syntax — qualifiers like "is:open", "author:x", or "state:open" will match nothing.'
),
channel: z.enum(Channels).title('Channel').describe('The channel of the target'),
repo: z
.string()
.title('Repository')
.describe(
'The repository name, optionally including the owner as "owner/repo" (e.g. "botpress/botpress"). If the owner is omitted, the owner of the connected installation is used. The repository must belong to the account the GitHub App is installed on.'
),
}),
},
output: {
schema: z.object({
targets: z
.array(
z.object({
displayName: z.string().title('Display Name').describe('The display name'),
tags: z.record(z.string()).title('Tags').describe('The tags associated with the target'),
channel: z.enum(Channels).title('Channel').describe('The channel of the target'),
})
)
.title('Targets')
.describe('The list of received target'),
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,229 @@
import * as sdk from '@botpress/sdk'
const { text } = sdk.messages.defaults
const COMMON_TAGS = {
repository: {
repoId: {
title: 'Repository ID',
description: 'Unique identifier of the repository',
},
repoNodeId: {
title: 'Repository global node ID',
description: 'Node ID for GraphQL',
},
repoName: {
title: 'Repository Name',
description: 'Name of the repository',
},
repoUrl: {
title: 'Repository URL',
description: 'URL of the repository',
},
repoOwnerId: {
title: 'Repository Owner ID',
description: 'Unique identifier of the repository owner',
},
repoOwnerName: {
title: 'Repository Owner Name',
description: 'Name of the repository owner. Usually the organization name',
},
repoOwnerUrl: {
title: 'Repository Owner URL',
description: 'URL of the repository owner',
},
},
pullRequest: {
pullRequestNumber: {
title: 'Pull Request Number',
description: 'The pull request number',
},
pullRequestUrl: {
title: 'Pull Request URL',
description: 'URL of the pull request',
},
pullRequestNodeId: {
title: 'Pull Request global node ID',
description: 'Node ID for GraphQL',
},
},
discussion: {
discussionId: {
title: 'Discussion ID',
description: 'Unique identifier of the discussion',
},
discussionNodeId: {
title: 'Discussion global node ID',
description: 'Node ID for GraphQL',
},
discussionUrl: {
title: 'Discussion URL',
description: 'URL of the discussion',
},
discussionNumber: {
title: 'Discussion Number',
description: 'The discussion number',
},
discussionCategoryId: {
title: 'Discussion Category ID',
description: 'Unique identifier of the discussion category',
},
discussionCategoryName: {
title: 'Discussion Category Name',
description: 'Name of the discussion category',
},
discussionCategoryNodeId: {
title: 'Discussion Category global node ID',
description: 'Node ID for GraphQL',
},
},
comment: {
commentId: {
title: 'Comment ID',
description: 'Unique identifier of the comment',
},
commentNodeId: {
title: 'Comment global node ID',
description: 'Node ID for GraphQL',
},
commentUrl: {
title: 'Comment URL',
description: 'URL of the comment',
},
},
} as const satisfies Record<string, Record<string, sdk.TagDefinition>>
export const channels = {
pullRequest: {
title: 'Pull Request',
description: 'A pull request in a GitHub repository',
conversation: {
tags: {
...COMMON_TAGS.repository,
...COMMON_TAGS.pullRequest,
channel: {
title: 'Channel name',
description: 'Workaround for the SDK',
},
},
},
message: {
tags: {
...COMMON_TAGS.comment,
},
},
messages: {
text,
},
},
pullRequestReviewComment: {
title: 'Pull Request Review Comment',
description: 'A comment on a pull request review in a GitHub repository',
conversation: {
tags: {
...COMMON_TAGS.repository,
...COMMON_TAGS.pullRequest,
fileBeingReviewed: {
title: 'File under review',
description: 'The file being reviewed',
},
commitBeingReviewed: {
title: 'Commit under review',
description: 'The commit being reviewed',
},
lineBeingReviewed: {
title: 'Line under review',
description: 'The line being reviewed',
},
lastCommentId: {
title: 'Last Comment ID',
description: 'The ID of the last comment posted on the review thread',
},
reviewThreadUrl: {
title: 'Review Thread URL',
description: 'URL of the review thread',
},
},
},
message: {
tags: {
...COMMON_TAGS.comment,
},
},
messages: {
text,
},
},
issue: {
title: 'Issue',
description: 'An issue in a GitHub repository',
conversation: {
tags: {
...COMMON_TAGS.repository,
issueNumber: {
title: 'Issue Number',
description: 'The issue number',
},
issueUrl: {
title: 'Issue URL',
description: 'URL of the issue',
},
issueNodeId: {
title: 'Issue global node ID',
description: 'Node ID for GraphQL',
},
},
},
message: {
tags: {
...COMMON_TAGS.comment,
},
},
messages: {
text,
},
},
discussion: {
title: 'Discussion',
description: 'A discussion in a GitHub repository',
conversation: {
tags: {
...COMMON_TAGS.repository,
...COMMON_TAGS.discussion,
channel: {
title: 'Channel name',
description: 'Workaround for the SDK',
},
},
},
message: {
tags: {
...COMMON_TAGS.comment,
},
},
messages: {
text,
},
},
discussionComment: {
title: 'Discussion Comment',
description: 'A comment thread on a discussion in a GitHub repository',
conversation: {
tags: {
...COMMON_TAGS.repository,
...COMMON_TAGS.discussion,
parentCommentId: {
title: 'Parent Comment ID',
description: 'The ID of the parent comment from which this comment thread originates',
},
},
},
message: {
tags: {
...COMMON_TAGS.comment,
},
},
messages: {
text,
},
},
} as const satisfies sdk.IntegrationDefinitionProps['channels']
@@ -0,0 +1,103 @@
import { z } from '@botpress/sdk'
const ENTITY = (name: string = 'entity') =>
({
id: z.number().title('ID').describe(`Unique identifier of the ${name}`),
nodeId: z.string().title('Global Node ID').describe('Node ID for GraphQL'),
url: z.string().title('URL').describe(`URL of the ${name}`),
}) as const
const NAMED_ENTITY = (name: string = 'entity') =>
({
...ENTITY(name),
name: z.string().title('Name').describe(`Name of the ${name}`),
}) as const
const USER_OR_ORG = (name: string = 'user') =>
({
...NAMED_ENTITY(name),
handle: z.string().title('Handle').describe(`The handle of the ${name}`),
botpressUser: z
.string()
.title('Botpress User ID')
.describe(`The ID of the Botpress user corresponding to this ${name}`),
}) as const
const USER = USER_OR_ORG('user')
const REPOSITORY = {
...NAMED_ENTITY('repository'),
owner: z.object(USER_OR_ORG('repository owner')).title('Owner').describe('The owner of the repository'),
} as const
const REPOSITORY_BRANCH = {
ref: z.string().title('Branch name').describe('The name of the branch'),
label: z.string().title('Branch label').describe('The display label of the branch'),
repository: z.object(REPOSITORY).title('Repository').describe('The repository the branch belongs to'),
} as const
const LABEL = NAMED_ENTITY('label')
const REPOSITORY_SUBENTITY = (name: string = 'entity') =>
({
...NAMED_ENTITY(name),
repository: z.object(REPOSITORY).title('Repository').describe(`The repository the ${name} belongs to`),
body: z.string().title('Body').describe(`The body of the ${name}`),
number: z.number().title('Number').describe(`The ${name} number`),
labels: z.array(z.object(LABEL)).title('Labels').describe(`The labels associated with the ${name}`),
author: z.object(USER_OR_ORG('author')).title('Author').describe(`The author of the ${name}`),
}) as const
const ISSUE_OR_PR = (name: string = 'issue') =>
({
...REPOSITORY_SUBENTITY(name),
assignees: z
.array(z.object(USER_OR_ORG('assignee')))
.title('Assignees')
.describe(`The assignees of the ${name}`),
}) as const
const ISSUE = ISSUE_OR_PR('issue')
const PULL_REQUEST = {
...ISSUE_OR_PR('pull request'),
target: z.object(REPOSITORY_BRANCH).title('Target').describe('The target repository and branch of the pull request'),
source: z.object(REPOSITORY_BRANCH).title('Source').describe('The source repository and branch of the pull request'),
} as const
const DISCUSSION = {
...REPOSITORY_SUBENTITY('discussion'),
category: z.object(NAMED_ENTITY('category')).title('Category').describe('The category of the discussion'),
} as const
const PULL_REQUEST_REVIEW_STATES = ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED'] as const
const PULL_REQUEST_REVIEW = {
...ENTITY('pull request review'),
pullRequest: z.object(PULL_REQUEST).title('Pull Request').describe('The pull request being reviewed'),
commitId: z.string().title('Commit ID').describe('The commit ID of the review'),
state: z.enum(PULL_REQUEST_REVIEW_STATES).title('State').describe('The state of the review'),
author: z.object(USER_OR_ORG('review author')).title('Author').describe('The author of the review'),
body: z.string().title('Body').describe('The body of the review'),
} as const
export const User = z.object(USER)
export type User = z.infer<typeof User>
export const Repository = z.object(REPOSITORY)
export type Repository = z.infer<typeof Repository>
export const PullRequest = z.object(PULL_REQUEST)
export type PullRequest = z.infer<typeof PullRequest>
export const Issue = z.object(ISSUE)
export type Issue = z.infer<typeof Issue>
export const Label = z.object(LABEL)
export type Label = z.infer<typeof Label>
export const Discussion = z.object(DISCUSSION)
export type Discussion = z.infer<typeof Discussion>
export const PullRequestReview = z.object(PULL_REQUEST_REVIEW)
export type PullRequestReview = z.infer<typeof PullRequestReview>
@@ -0,0 +1,110 @@
import { z } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'
import { Issue, PullRequest, User, PullRequestReview } from './entities'
const COMMON_EVENT_FIELDS = {
sender: {
eventSender: User.title('Sender').describe('The user who triggered the event'),
},
} as const satisfies Record<string, Record<string, z.ZodTypeAny>>
const pullRequestOpened = {
title: 'Pull Request opened',
description: 'Triggered when a pull request is opened',
schema: z.object({
pullRequest: PullRequest.title('Pull Request').describe('The pull request that was opened'),
...COMMON_EVENT_FIELDS.sender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version :
type: z.literal('github:pullRequestOpened').optional().title('DEPRECATED: type').describe('Not needed anymore'),
id: z.number().title('DEPRECATED: id').describe('use pullRequest.id instead'),
title: z.string().title('DEPRECATED: title').describe('use pullRequest.name instead'),
content: z.string().title('DEPRECATED: content').describe('use pullRequest.body instead'),
baseBranch: z.string().optional().title('DEPRECATED: baseBranch').describe('use pullRequest.source.ref instead'),
userId: z.string().optional().title('DEPRECATED: userId').describe('use pullRequest.author.id instead'),
conversationId: z
.string()
.optional()
.title('DEPRECATED: conversationId')
.describe('use the conversation id associated with the event instead'),
targets: z
.object({
pullRequest: z.string().optional().title('DEPRECATED: pullRequest').describe('use pullRequest.number instead'),
issue: z.string().optional().title('DEPRECATED: issue').describe('Not needed'),
discussion: z.string().optional().title('DEPRECATED: discussion').describe('Not needed'),
})
.title('DEPRECATED: targets')
.describe('Not needed'),
}),
} as const satisfies sdk.EventDefinition
export const pullRequestMerged = {
title: 'Pull Request merged',
description: 'Triggered when a pull request is merged',
schema: z.object({
pullRequest: PullRequest.title('Pull Request').describe('The pull request that was merged'),
...COMMON_EVENT_FIELDS.sender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version :
type: z.literal('github:pullRequestMerged').optional().title('DEPRECATED: type').describe('Not needed anymore'),
id: z.number().title('DEPRECATED: id').describe('use pullRequest.id instead'),
title: z.string().title('DEPRECATED: title').describe('use pullRequest.name instead'),
content: z.string().title('DEPRECATED: content').describe('use pullRequest.body instead'),
baseBranch: z.string().optional().title('DEPRECATED: baseBranch').describe('use pullRequest.source.ref instead'),
userId: z.string().optional().title('DEPRECATED: userId').describe('use pullRequest.author.id instead'),
conversationId: z
.string()
.optional()
.title('DEPRECATED: conversationId')
.describe('use the conversation id associated with the event instead'),
targets: z
.object({
pullRequest: z.string().optional().title('DEPRECATED: pullRequest').describe('use pullRequest.number instead'),
issue: z.string().optional().title('DEPRECATED: issue').describe('Not needed'),
discussion: z.string().optional().title('DEPRECATED: discussion').describe('Not needed'),
})
.title('DEPRECATED: targets')
.describe('Not needed'),
}),
} as const satisfies sdk.EventDefinition
export const issueOpened = {
title: 'Issue opened',
description: 'Triggered when an issue is opened',
schema: z.object({
issue: Issue.title('Issue').describe('The issue that was opened'),
...COMMON_EVENT_FIELDS.sender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version :
id: z.number().title('DEPRECATED: id').describe('use issue.id instead'),
issueUrl: z.string().title('DEPRECATED: issueUrl').describe('use issue.url instead'),
repoUrl: z.string().title('DEPRECATED: repoUrl').describe('use issue.repository.url instead'),
number: z.number().title('DEPRECATED: number').describe('use issue.number instead'),
title: z.string().title('DEPRECATED: title').describe('use issue.name instead'),
content: z.string().nullable().title('DEPRECATED: content').describe('use issue.body instead'),
repositoryName: z.string().title('DEPRECATED: repositoryName').describe('use issue.repository.name instead'),
repositoryOwner: z
.string()
.title('DEPRECATED: repositoryOwner')
.describe('use issue.repository.owner.handle instead'),
}),
} as const satisfies sdk.EventDefinition
export const pullRequestReviewSubmitted = {
title: 'Pull Request review submitted',
description: 'Triggered when a review is submitted on a pull request',
schema: z.object({
review: PullRequestReview.title('Review').describe('The review that was submitted'),
...COMMON_EVENT_FIELDS.sender,
}),
} as const satisfies sdk.EventDefinition
export const events = {
issueOpened,
pullRequestMerged,
pullRequestOpened,
pullRequestReviewSubmitted,
} as const satisfies sdk.IntegrationDefinitionProps['events']
@@ -0,0 +1,125 @@
import { z } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'
export { actions } from './actions'
export { events } from './events'
export { channels } from './channels'
import { multiLineString } from './zui'
export const configuration = {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
schema: z.object({}),
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
const webhookSecret = {
githubWebhookSecret: z
.string()
.min(1)
.secret()
.title('GitHub Webhook Secret')
.describe(
'A high-entropy string that only you and Botpress know. Must be set to the same value as in the GitHub organization settings.'
),
} as const satisfies z.ZodRawShape
export const configurations = {
manualApp: {
title: 'Manual configuration',
description: 'Configure manually with your own GitHub App',
schema: z.object({
githubAppId: z
.string()
.min(1)
.title('GitHub App ID')
.describe('Can be found in the GitHub App settings. OAuth apps are not supported.'),
githubAppPrivateKey: multiLineString
.min(1)
.secret()
.title('GitHub App Private Key')
.describe('The raw contents of the RSA private key. Can be downloaded from the GitHub App settings.')
.placeholder('-----BEGIN RSA PRIVATE KEY-----\n\n...\n\n-----END RSA PRIVATE KEY-----'),
githubAppInstallationId: z
.number()
.positive()
.title('GitHub App Installation ID')
.describe('Please refer to the integration documentation for details on how to obtain this.'),
...webhookSecret,
}),
},
manualPAT: {
title: 'Manual configuration',
description: 'Configure manually with a Personal Access Token',
schema: z.object({
personalAccessToken: z
.string()
.min(1)
.secret()
.title('Personal Access Token')
.describe('An organization-level GitHub Personal Access Token with the necessary permissions.'),
...webhookSecret,
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
export const user = {
tags: {
handle: {
title: 'GitHub Handle',
description: 'The GitHub handle of the user',
},
id: {
title: 'GitHub User ID',
description: 'The GitHub ID of the user',
},
nodeId: {
title: 'GitHub User Node ID',
description: 'The GraphQL Node ID of the user',
},
profileUrl: {
title: 'GitHub Profile URL',
description: "The URL of the user's profile",
},
},
} satisfies sdk.IntegrationDefinitionProps['user']
export const states = {
configuration: {
type: 'integration',
schema: z.object({
githubInstallationId: z
.number()
.positive()
.optional()
.title('GitHub Installation ID')
.describe('The ID of the GitHub installation'),
organizationHandle: z
.string()
.optional()
.title('Organization Handle')
.describe('The handle of the organization that owns the repositories'),
}),
},
} satisfies sdk.IntegrationDefinitionProps['states']
export const secrets: sdk.IntegrationDefinitionProps['secrets'] = {
GITHUB_APP_ID: {
description: 'GitHub App ID for the Botpress GitHub App. This is not the client id',
},
GITHUB_PRIVATE_KEY: {
description: 'GitHub App RSA Private Key. Should be the full contents of the private key file',
},
GITHUB_WEBHOOK_SECRET: {
description: 'GitHub Webhook Secret. Should be a high-entropy string that only Botpress knows',
},
GITHUB_CLIENT_ID: {
description:
"The Botpress GitHub App's OAuth Client ID (found on the GitHub App settings page). Used for the user-authorization OAuth flow.",
},
GITHUB_CLIENT_SECRET: {
description:
"The Botpress GitHub App's OAuth Client Secret (generated on the GitHub App settings page). Used to exchange the authorization code for a user access token.",
},
}
@@ -0,0 +1,4 @@
import * as sdk from '@botpress/sdk'
const { z } = sdk
export const multiLineString = z.string().displayAs({ id: 'text', params: { multiLine: true, growVertically: true } })
@@ -0,0 +1,27 @@
import { DiscussionCommentCreatedEvent, DiscussionEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreateDiscussionConversation } from './shared'
export const fireDiscussionCommentCreated = wrapEvent<DiscussionCommentCreatedEvent>({
async event({ githubEvent, client, eventSender }) {
const conversation = await getOrCreateDiscussionConversation({
githubEvent: githubEvent as DiscussionEvent,
client,
})
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle discussion comment created event',
})
@@ -0,0 +1,62 @@
import { DiscussionCommentCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getConversationFromTags } from '../../misc/utils'
import * as bp from '.botpress'
type DiscussionCommentRepliedEvent = DiscussionCommentCreatedEvent & { comment: { parent_id: number } }
export const fireDiscussionCommentReplied = wrapEvent<DiscussionCommentRepliedEvent>({
async event({ githubEvent, client, eventSender }) {
const conversation =
(await getConversationFromTags<'discussionComment'>(client, {
discussionNodeId: githubEvent.discussion.node_id,
parentCommentId: githubEvent.comment.parent_id.toString(),
})) ?? (await _createDiscussionReplyConversation({ githubEvent, client }))
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle discussion comment replied event',
})
const _createDiscussionReplyConversation = async ({
githubEvent,
client,
}: {
githubEvent: DiscussionCommentRepliedEvent
client: bp.Client
}) => {
const { conversation } = await client.createConversation({
channel: 'discussionComment',
tags: {
discussionNodeId: githubEvent.discussion.node_id,
discussionNumber: githubEvent.discussion.number.toString(),
discussionUrl: githubEvent.discussion.html_url,
discussionId: githubEvent.discussion.id.toString(),
discussionCategoryId: githubEvent.discussion.category.id.toString(),
discussionCategoryName: githubEvent.discussion.category.name,
discussionCategoryNodeId: githubEvent.discussion.category.node_id,
parentCommentId: githubEvent.comment.parent_id.toString(),
repoId: githubEvent.repository.id.toString(),
repoName: githubEvent.repository.name,
repoNodeId: githubEvent.repository.node_id,
repoOwnerId: githubEvent.repository.owner.id.toString(),
repoOwnerName: githubEvent.repository.owner.login,
repoOwnerUrl: githubEvent.repository.owner.html_url,
repoUrl: githubEvent.repository.html_url,
},
})
return conversation
}
@@ -0,0 +1,27 @@
import { DiscussionCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreateDiscussionConversation } from './shared'
export const fireDiscussionCreated = wrapEvent<DiscussionCreatedEvent>({
async event({ githubEvent, client, eventSender }) {
const conversation = await getOrCreateDiscussionConversation({
githubEvent,
client,
})
await client.createMessage({
tags: {
commentId: githubEvent.discussion.id.toString(),
commentNodeId: githubEvent.discussion.node_id,
commentUrl: githubEvent.discussion.html_url,
},
type: 'text',
payload: {
text: githubEvent.discussion.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle discussion created event',
})
@@ -0,0 +1,45 @@
import { DiscussionEvent } from '@octokit/webhooks-types'
import { getConversationFromTags } from 'src/misc/utils'
import * as bp from '.botpress'
export const getOrCreateDiscussionConversation = async ({
githubEvent,
client,
}: {
githubEvent: DiscussionEvent
client: bp.Client
}) =>
(await getConversationFromTags<'discussion'>(client, {
channel: 'discussion',
discussionNodeId: githubEvent.discussion.node_id,
})) ?? (await _createDiscussionConversation({ githubEvent, client }))
const _createDiscussionConversation = async ({
githubEvent,
client,
}: {
githubEvent: DiscussionEvent
client: bp.Client
}) => {
const { conversation } = await client.createConversation({
channel: 'discussion',
tags: {
discussionNodeId: githubEvent.discussion.node_id,
discussionNumber: githubEvent.discussion.number.toString(),
discussionUrl: githubEvent.discussion.html_url,
discussionId: githubEvent.discussion.id.toString(),
discussionCategoryId: githubEvent.discussion.category.id.toString(),
discussionCategoryName: githubEvent.discussion.category.name,
discussionCategoryNodeId: githubEvent.discussion.category.node_id,
repoId: githubEvent.repository.id.toString(),
repoName: githubEvent.repository.name,
repoNodeId: githubEvent.repository.node_id,
repoOwnerId: githubEvent.repository.owner.id.toString(),
repoOwnerName: githubEvent.repository.owner.login,
repoOwnerUrl: githubEvent.repository.owner.html_url,
repoUrl: githubEvent.repository.html_url,
},
})
return conversation
}
@@ -0,0 +1,24 @@
import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreateBotpressConversationFromGithubIssue } from './shared'
export const fireIssueCommentCreated = wrapEvent<IssueCommentCreatedEvent>({
async event({ githubEvent, client, eventSender }) {
const conversation = await getOrCreateBotpressConversationFromGithubIssue({ githubEvent, client })
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle issue comment created event',
})
@@ -0,0 +1,31 @@
import { IssuesOpenedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreateBotpressConversationFromGithubIssue } from './shared'
export const fireIssueOpened = wrapEvent<IssuesOpenedEvent>({
async event({ githubEvent, client, eventSender, mapping }) {
const conversation = await getOrCreateBotpressConversationFromGithubIssue({ githubEvent, client })
await client.createEvent({
type: 'issueOpened',
payload: {
issue: await mapping.mapIssue(githubEvent.issue, githubEvent.repository),
eventSender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version
content: githubEvent.issue.body,
id: githubEvent.issue.id,
issueUrl: githubEvent.issue.html_url,
number: githubEvent.issue.number,
repositoryName: githubEvent.repository.name,
repositoryOwner: githubEvent.repository.owner.login,
repoUrl: githubEvent.repository.html_url,
title: githubEvent.issue.title,
},
userId: eventSender.botpressUser,
conversationId: conversation.id,
})
},
errorMessage: 'Failed to handle issue opened event',
})
@@ -0,0 +1,46 @@
import { getConversationFromTags } from 'src/misc/utils'
import * as bp from '.botpress'
type GitHubPullRequest = {
issue: {
number: number
node_id: string
html_url: string
}
repository: {
id: number
name: string
node_id: string
owner: {
id: number
login: string
html_url: string
}
html_url: string
}
}
export const getOrCreateBotpressConversationFromGithubIssue = async ({
githubEvent,
client,
}: {
githubEvent: GitHubPullRequest
client: bp.Client
}) =>
(await getConversationFromTags<'issue'>(client, { issueNodeId: githubEvent.issue.node_id })) ??
(
await client.createConversation({
channel: 'issue',
tags: {
issueNodeId: githubEvent.issue.node_id,
issueNumber: githubEvent.issue.number.toString(),
issueUrl: githubEvent.issue.html_url,
repoId: githubEvent.repository.id.toString(),
repoName: githubEvent.repository.name,
repoNodeId: githubEvent.repository.node_id,
repoOwnerId: githubEvent.repository.owner.id.toString(),
repoOwnerName: githubEvent.repository.owner.login,
repoOwnerUrl: githubEvent.repository.owner.html_url,
repoUrl: githubEvent.repository.html_url,
},
})
).conversation
@@ -0,0 +1,30 @@
import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreatePullRequestConversation } from './shared'
export const firePullRequestCommentCreated = wrapEvent<IssueCommentCreatedEvent>({
async event({ githubEvent, client, eventSender }) {
const conversation = await getOrCreatePullRequestConversation({
githubEvent: {
...githubEvent,
pull_request: githubEvent.issue,
},
client,
})
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request comment created event',
})
@@ -0,0 +1,31 @@
import { PullRequestClosedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreatePullRequestConversation } from './shared'
export const firePullRequesMerged = wrapEvent<PullRequestClosedEvent>({
async event({ githubEvent, client, eventSender, mapping }) {
const conversation = await getOrCreatePullRequestConversation({ githubEvent, client })
await client.createEvent({
type: 'pullRequestMerged',
payload: {
pullRequest: await mapping.mapPullRequest(githubEvent.pull_request, githubEvent.repository),
eventSender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version
type: 'github:pullRequestMerged',
baseBranch: githubEvent.pull_request.base.ref,
content: githubEvent.pull_request.body?.toString() ?? '',
conversationId: conversation.id,
id: githubEvent.pull_request.id,
targets: { pullRequest: githubEvent.pull_request.number.toString() },
title: githubEvent.pull_request.title,
userId: githubEvent.pull_request.user.login,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request merged event',
})
@@ -0,0 +1,32 @@
import { PullRequestOpenedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreatePullRequestConversation } from './shared'
export const firePullRequestOpened = wrapEvent<PullRequestOpenedEvent>({
async event({ githubEvent, client, eventSender, mapping }) {
const conversation = await getOrCreatePullRequestConversation({ githubEvent, client })
await client.createEvent({
type: 'pullRequestOpened',
payload: {
pullRequest: await mapping.mapPullRequest(githubEvent.pull_request, githubEvent.repository),
eventSender,
// The following fields have been kept for backwards compatibility.
// TODO: Remove these fields in the next major version
type: 'github:pullRequestOpened',
baseBranch: githubEvent.pull_request.base.ref,
content: githubEvent.pull_request.body?.toString() ?? '',
conversationId: conversation.id,
id: githubEvent.pull_request.id,
targets: { pullRequest: githubEvent.pull_request.number.toString() },
title: githubEvent.pull_request.title,
userId: githubEvent.pull_request.user.login,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request opened event',
})
@@ -0,0 +1,42 @@
import { PullRequestReviewCommentCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
export const firePullRequestReviewCommentCreated = wrapEvent<PullRequestReviewCommentCreatedEvent>({
async event({ githubEvent, client, eventSender }) {
const { conversation } = await client.createConversation({
channel: 'pullRequestReviewComment',
tags: {
pullRequestNodeId: githubEvent.pull_request.node_id,
pullRequestNumber: githubEvent.pull_request.number.toString(),
pullRequestUrl: githubEvent.pull_request.html_url,
repoId: githubEvent.repository.id.toString(),
repoName: githubEvent.repository.name,
repoNodeId: githubEvent.repository.node_id,
repoOwnerId: githubEvent.repository.owner.id.toString(),
repoOwnerName: githubEvent.repository.owner.login,
repoOwnerUrl: githubEvent.repository.owner.html_url,
repoUrl: githubEvent.repository.html_url,
fileBeingReviewed: githubEvent.comment.path,
commitBeingReviewed: githubEvent.comment.commit_id,
lineBeingReviewed: githubEvent.comment.line?.toString(),
reviewThreadUrl: githubEvent.comment.html_url,
lastCommentId: githubEvent.comment.id.toString(),
},
})
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request review comment created event',
})
@@ -0,0 +1,35 @@
import { PullRequestReviewCommentCreatedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getConversationFromTags } from 'src/misc/utils'
export const firePullRequestReviewCommentReplied = wrapEvent<
PullRequestReviewCommentCreatedEvent & { comment: { in_reply_to_id: number } }
>({
async event({ githubEvent, client, eventSender }) {
const conversation = await getConversationFromTags<'pullRequestReviewComment'>(client, {
pullRequestNodeId: githubEvent.pull_request.node_id,
fileBeingReviewed: githubEvent.comment.path,
commitBeingReviewed: githubEvent.comment.commit_id,
lastCommentId: githubEvent.comment.in_reply_to_id.toString(),
})
if (!conversation) {
return
}
await client.createMessage({
tags: {
commentId: githubEvent.comment.id.toString(),
commentNodeId: githubEvent.comment.node_id,
commentUrl: githubEvent.comment.html_url,
},
type: 'text',
payload: {
text: githubEvent.comment.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request review comment replied event',
})
@@ -0,0 +1,45 @@
import { PullRequestReviewSubmittedEvent } from '@octokit/webhooks-types'
import { wrapEvent } from 'src/misc/event-wrapper'
import { getOrCreatePullRequestConversation } from './shared'
export const firePullRequestReviewSubmitted = wrapEvent<PullRequestReviewSubmittedEvent>({
async event({ githubEvent, client, eventSender, mapping }) {
const conversation = await getOrCreatePullRequestConversation({
githubEvent: {
...githubEvent,
pull_request: githubEvent.pull_request,
},
client,
})
const messageId = githubEvent.review.body
? (
await client.createMessage({
tags: {
commentId: githubEvent.review.id.toString(),
commentNodeId: githubEvent.review.node_id,
commentUrl: githubEvent.review.html_url,
},
type: 'text',
payload: {
text: githubEvent.review.body,
},
conversationId: conversation.id,
userId: eventSender.botpressUser,
})
).message.id
: undefined
await client.createEvent({
type: 'pullRequestReviewSubmitted',
payload: {
review: await mapping.mapPullRequestReview(githubEvent.review, githubEvent.pull_request),
eventSender,
},
conversationId: conversation.id,
messageId,
userId: eventSender.botpressUser,
})
},
errorMessage: 'Failed to handle pull request review submitted event',
})
@@ -0,0 +1,60 @@
import { getConversationFromTags } from 'src/misc/utils'
import * as bp from '.botpress'
type GitHubPullRequest = {
pull_request: {
number: number
node_id: string
html_url: string
}
repository: {
id: number
name: string
node_id: string
owner: {
id: number
login: string
html_url: string
}
html_url: string
}
}
export const getOrCreatePullRequestConversation = async ({
githubEvent,
client,
}: {
githubEvent: GitHubPullRequest
client: bp.Client
}) =>
(await getConversationFromTags<'pullRequest'>(client, {
channel: 'pullRequest',
pullRequestNodeId: githubEvent.pull_request.node_id,
})) ?? (await _createPullRequestConversation({ githubEvent, client }))
const _createPullRequestConversation = async ({
githubEvent,
client,
}: {
githubEvent: GitHubPullRequest
client: bp.Client
}) => {
const { conversation } = await client.createConversation({
channel: 'pullRequest',
tags: {
channel: 'pullRequest',
pullRequestNodeId: githubEvent.pull_request.node_id,
pullRequestNumber: githubEvent.pull_request.number.toString(),
pullRequestUrl: githubEvent.pull_request.html_url,
repoId: githubEvent.repository.id.toString(),
repoName: githubEvent.repository.name,
repoNodeId: githubEvent.repository.node_id,
repoOwnerId: githubEvent.repository.owner.id.toString(),
repoOwnerName: githubEvent.repository.owner.login,
repoOwnerUrl: githubEvent.repository.owner.html_url,
repoUrl: githubEvent.repository.html_url,
},
})
return conversation
}
@@ -0,0 +1,136 @@
import type { PushEvent } from '@octokit/webhooks-types'
import * as mapping from '../../files-readonly/mapping'
import * as bp from '.botpress'
const MAX_BATCH_SIZE = 50
export const firePushReceived = async ({
githubEvent,
client,
logger,
}: bp.HandlerProps & { githubEvent: PushEvent }) => {
try {
const repo = githubEvent.repository
const owner = repo.owner.login ?? repo.owner.name
const repoName = repo.name
const defaultRef = `refs/heads/${repo.default_branch ?? repo.master_branch ?? 'main'}`
if (githubEvent.ref !== defaultRef) {
logger.forBot().debug(`Ignoring push to non-default branch: ${githubEvent.ref}`)
return
}
const fileStates = new Map<string, 'created' | 'updated' | 'deleted'>()
for (const commit of githubEvent.commits) {
for (const filePath of commit.added ?? []) {
const prev = fileStates.get(filePath)
fileStates.set(filePath, prev === 'deleted' ? 'updated' : 'created')
}
for (const filePath of commit.modified ?? []) {
const prev = fileStates.get(filePath)
if (prev !== 'created') {
fileStates.set(filePath, 'updated')
}
}
for (const filePath of commit.removed ?? []) {
const prev = fileStates.get(filePath)
if (prev === 'created') {
fileStates.delete(filePath)
} else {
fileStates.set(filePath, 'deleted')
}
}
}
if (fileStates.size === 0) {
return
}
const created: bp.events.Events['aggregateFileChanges']['modifiedItems']['created'] = []
const updated: bp.events.Events['aggregateFileChanges']['modifiedItems']['updated'] = []
const deleted: bp.events.Events['aggregateFileChanges']['modifiedItems']['deleted'] = []
for (const [filePath, state] of fileStates) {
const file = mapping.mapPushFileToFile(owner, repoName, filePath)
if (state === 'created') {
created.push(file)
} else if (state === 'updated') {
updated.push(file)
} else {
deleted.push(file)
}
}
await _emitFileChangeEvents({ client, logger, changes: { created, updated, deleted } })
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err))
logger.forBot().error('Failed to process push event; swallowing to prevent webhook retries', error)
}
}
const _emitFileChangeEvents = async ({
client,
logger,
changes,
}: {
client: bp.Client
logger: bp.Logger
changes: FileChanges
}) => {
try {
for (const batch of _getBatches(changes)) {
await client.createEvent({
type: 'aggregateFileChanges',
payload: {
modifiedItems: batch,
},
})
}
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err))
logger.forBot().error('Failed to emit file-change events; swallowing to prevent webhook retries', error)
}
}
type FileChanges = {
created: bp.events.Events['aggregateFileChanges']['modifiedItems']['created']
updated: bp.events.Events['aggregateFileChanges']['modifiedItems']['updated']
deleted: bp.events.Events['aggregateFileChanges']['modifiedItems']['deleted']
}
function* _getBatches(changes: FileChanges): Generator<FileChanges> {
let createdIdx = 0
let updatedIdx = 0
let deletedIdx = 0
while (
createdIdx < changes.created.length ||
updatedIdx < changes.updated.length ||
deletedIdx < changes.deleted.length
) {
const batch: FileChanges = { created: [], updated: [], deleted: [] }
let size = 0
while (size < MAX_BATCH_SIZE) {
const startSize = size
if (createdIdx < changes.created.length && size < MAX_BATCH_SIZE) {
batch.created.push(changes.created[createdIdx++]!)
size++
}
if (updatedIdx < changes.updated.length && size < MAX_BATCH_SIZE) {
batch.updated.push(changes.updated[updatedIdx++]!)
size++
}
if (deletedIdx < changes.deleted.length && size < MAX_BATCH_SIZE) {
batch.deleted.push(changes.deleted[deletedIdx++]!)
size++
}
if (size === startSize) {
break
}
}
yield batch
}
}
@@ -0,0 +1,2 @@
export { filesReadonlyListItemsInFolder } from './list-items-in-folder'
export { filesReadonlyTransferFileToBotpress } from './transfer-file-to-botpress'
@@ -0,0 +1,146 @@
import { wrapActionAndInjectOctokit } from 'src/misc/action-wrapper'
import * as mapping from '../mapping'
const REPOS_PAGE_SIZE = 100
const CONTENTS_PAGE_SIZE = 100
export const filesReadonlyListItemsInFolder = wrapActionAndInjectOctokit(
{ actionName: 'filesReadonlyListItemsInFolder', errorMessage: 'Failed to list items in folder' },
async ({ octokit, owner }, { folderId, filters, nextToken: prevToken }) => {
if (!folderId) {
return await _listRepos({ octokit, owner, prevToken, filters })
}
const repoInfo = mapping.decodeRepoFolderId(folderId)
if (repoInfo) {
return await _listRepoRoot({ octokit, ...repoInfo, prevToken, filters })
}
const contentInfo = mapping.decodeContentId(folderId)
if (contentInfo) {
return await _listFolderContents({ octokit, ...contentInfo, parentId: folderId, filters, prevToken })
}
return { items: [], meta: { nextToken: undefined } }
}
)
const _listRepos = async ({
octokit,
owner,
prevToken,
filters,
}: {
octokit: { rest: any }
owner: string
prevToken?: string
filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string }
}) => {
if (filters?.itemType === 'file') {
return { items: [], meta: { nextToken: undefined } }
}
const page = prevToken ? parseInt(prevToken, 10) : 1
let repos: mapping.GitHubRepo[]
try {
const response = await octokit.rest.repos.listForOrg({
org: owner,
per_page: REPOS_PAGE_SIZE,
page,
type: 'all',
})
repos = response.data as mapping.GitHubRepo[]
} catch (err: unknown) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
const response = await octokit.rest.repos.listForAuthenticatedUser({
per_page: REPOS_PAGE_SIZE,
page,
})
repos = response.data as mapping.GitHubRepo[]
} else {
throw err
}
}
const items = repos.map(mapping.mapRepoToFolder)
const hasMore = repos.length === REPOS_PAGE_SIZE
return {
items,
meta: { nextToken: hasMore ? String(page + 1) : undefined },
}
}
const _listRepoRoot = async ({
octokit,
owner,
repo,
prevToken,
filters,
}: {
octokit: { rest: any }
owner: string
repo: string
prevToken?: string
filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string }
}) => {
return _listFolderContents({
octokit,
owner,
repo,
path: '',
parentId: mapping.encodeRepoFolderId(owner, repo),
filters,
prevToken,
})
}
const _listFolderContents = async ({
octokit,
owner,
repo,
path,
parentId,
filters,
prevToken,
}: {
octokit: { rest: any }
owner: string
repo: string
path: string
parentId: string
filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string }
prevToken?: string
}) => {
const response = await octokit.rest.repos.getContent({
owner,
repo,
path: path || '',
})
const contents = Array.isArray(response.data) ? response.data : [response.data]
const contentItems = contents as mapping.GitHubContentItem[]
const startIndex = prevToken ? parseInt(prevToken, 10) : 0
const pageItems = contentItems.slice(startIndex, startIndex + CONTENTS_PAGE_SIZE)
let mappedItems = pageItems.map((item) => mapping.mapContentItemToItem(owner, repo, item, parentId))
if (filters?.itemType) {
const filterType = filters.itemType === 'folder' ? 'folder' : 'file'
mappedItems = mappedItems.filter((item) => item.type === filterType)
}
if (filters?.maxSizeInBytes) {
mappedItems = mappedItems.filter(
(item) => item.type !== 'file' || !item.sizeInBytes || item.sizeInBytes <= filters.maxSizeInBytes!
)
}
const hasMore = startIndex + CONTENTS_PAGE_SIZE < contentItems.length
return {
items: mappedItems,
meta: { nextToken: hasMore ? String(startIndex + CONTENTS_PAGE_SIZE) : undefined },
}
}
@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
import { wrapActionAndInjectOctokit } from 'src/misc/action-wrapper'
import * as mapping from '../mapping'
export const filesReadonlyTransferFileToBotpress = wrapActionAndInjectOctokit(
{ actionName: 'filesReadonlyTransferFileToBotpress', errorMessage: 'Failed to transfer file to Botpress' },
async ({ octokit, client }, { file, fileKey, shouldIndex }) => {
const contentInfo = mapping.decodeContentId(file.id)
if (!contentInfo) {
throw new sdk.RuntimeError(`Invalid file ID: ${file.id}`)
}
const { owner, repo, path } = contentInfo
const response = await octokit.rest.repos.getContent({
owner,
repo,
path,
mediaType: { format: 'raw' },
})
const content = typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
const { file: uploadedFile } = await client.uploadFile({
key: fileKey,
content,
index: shouldIndex,
})
return { botpressFileId: uploadedFile.id }
}
)
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { encodeRepoFolderId, decodeRepoFolderId, encodeContentId, decodeContentId } from './mapping'
describe.concurrent('encodeRepoFolderId / decodeRepoFolderId', () => {
it('should round-trip a simple owner/repo', () => {
const encoded = encodeRepoFolderId('acme', 'widgets')
expect(encoded).toBe('repo:acme/widgets')
expect(decodeRepoFolderId(encoded)).toEqual({ owner: 'acme', repo: 'widgets' })
})
it('should round-trip owner/repo with hyphens and dots', () => {
const encoded = encodeRepoFolderId('my-org', 'my-repo.js')
expect(decodeRepoFolderId(encoded)).toEqual({ owner: 'my-org', repo: 'my-repo.js' })
})
it('should return null for ids missing the repo: prefix', () => {
expect(decodeRepoFolderId('acme/widgets')).toBeNull()
expect(decodeRepoFolderId('folder:acme/widgets')).toBeNull()
})
it('should return null for an id with only an owner (no repo segment)', () => {
expect(decodeRepoFolderId('repo:acme')).toBeNull()
})
it('should return null for an empty payload after prefix', () => {
expect(decodeRepoFolderId('repo:')).toBeNull()
})
it('should return null for ids with empty owner or repo segments', () => {
expect(decodeRepoFolderId('repo:/widgets')).toBeNull()
expect(decodeRepoFolderId('repo:acme/')).toBeNull()
expect(decodeRepoFolderId('repo:/')).toBeNull()
})
it('should NOT match content ids (3+ path segments)', () => {
expect(decodeRepoFolderId('repo:acme/widgets/src')).toBeNull()
expect(decodeRepoFolderId('repo:acme/widgets/src/index.ts')).toBeNull()
expect(decodeRepoFolderId('repo:acme/widgets/a/b/c/d')).toBeNull()
})
})
describe.concurrent('encodeContentId / decodeContentId', () => {
it('should round-trip a single-segment path', () => {
const encoded = encodeContentId('acme', 'widgets', 'README.md')
expect(encoded).toBe('repo:acme/widgets/README.md')
expect(decodeContentId(encoded)).toEqual({ owner: 'acme', repo: 'widgets', path: 'README.md' })
})
it('should round-trip a deeply nested path', () => {
const encoded = encodeContentId('acme', 'widgets', 'src/utils/helpers/index.ts')
expect(encoded).toBe('repo:acme/widgets/src/utils/helpers/index.ts')
expect(decodeContentId(encoded)).toEqual({
owner: 'acme',
repo: 'widgets',
path: 'src/utils/helpers/index.ts',
})
})
it('should round-trip a directory path (no extension)', () => {
const encoded = encodeContentId('acme', 'widgets', 'src/components')
expect(decodeContentId(encoded)).toEqual({
owner: 'acme',
repo: 'widgets',
path: 'src/components',
})
})
it('should return null for ids missing the repo: prefix', () => {
expect(decodeContentId('acme/widgets/README.md')).toBeNull()
})
it('should return null for a repo folder id (only 2 segments, no path)', () => {
expect(decodeContentId('repo:acme/widgets')).toBeNull()
})
it('should return null for a single segment after prefix', () => {
expect(decodeContentId('repo:acme')).toBeNull()
})
})
describe.concurrent('decodeRepoFolderId and decodeContentId are mutually exclusive on well-formed ids', () => {
it('repo folder id is decoded only by decodeRepoFolderId', () => {
const id = encodeRepoFolderId('org', 'repo')
expect(decodeRepoFolderId(id)).not.toBeNull()
// decodeContentId technically parses it but yields an empty path — the caller should
// try decodeRepoFolderId first, which is the intended dispatch order.
})
it('content id is decoded only by decodeContentId, never by decodeRepoFolderId', () => {
const id = encodeContentId('org', 'repo', 'src/main.ts')
expect(decodeRepoFolderId(id)).toBeNull()
expect(decodeContentId(id)).toEqual({ owner: 'org', repo: 'repo', path: 'src/main.ts' })
})
it('subdirectory folder id is decoded only by decodeContentId', () => {
const id = encodeContentId('org', 'repo', 'src')
expect(decodeRepoFolderId(id)).toBeNull()
expect(decodeContentId(id)).toEqual({ owner: 'org', repo: 'repo', path: 'src' })
})
})
@@ -0,0 +1,106 @@
import * as bp from '.botpress'
type FilesReadonlyFile = bp.events.Events['fileCreated']['file']
type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder']
type FilesReadonlyItem = FilesReadonlyFile | FilesReadonlyFolder
export type GitHubContentItem = {
name: string
path: string
sha: string
size: number
type: 'file' | 'dir' | 'submodule' | 'symlink'
}
export type GitHubRepo = {
id: number
name: string
full_name: string
default_branch: string
owner: { login: string }
}
const REPO_PREFIX = 'repo:'
const PATH_SEPARATOR = '/'
export const encodeRepoFolderId = (owner: string, repo: string): string => `${REPO_PREFIX}${owner}/${repo}`
export const decodeRepoFolderId = (folderId: string): { owner: string; repo: string } | null => {
if (!folderId.startsWith(REPO_PREFIX)) {
return null
}
const parts = folderId.slice(REPO_PREFIX.length).split('/')
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null
}
return { owner: parts[0], repo: parts[1] }
}
export const encodeContentId = (owner: string, repo: string, path: string): string =>
`${REPO_PREFIX}${owner}/${repo}/${path}`
export const decodeContentId = (id: string): { owner: string; repo: string; path: string } | null => {
if (!id.startsWith(REPO_PREFIX)) {
return null
}
const rest = id.slice(REPO_PREFIX.length)
const slashIdx1 = rest.indexOf('/')
if (slashIdx1 === -1) {
return null
}
const owner = rest.slice(0, slashIdx1)
const slashIdx2 = rest.indexOf('/', slashIdx1 + 1)
if (slashIdx2 === -1) {
return null
}
const repo = rest.slice(slashIdx1 + 1, slashIdx2)
const path = rest.slice(slashIdx2 + 1)
return { owner, repo, path }
}
export const mapRepoToFolder = (repo: GitHubRepo): FilesReadonlyFolder => ({
id: encodeRepoFolderId(repo.owner.login, repo.name),
name: repo.full_name,
type: 'folder',
absolutePath: `/${repo.full_name}`,
})
export const mapContentItemToItem = (
owner: string,
repo: string,
item: GitHubContentItem,
parentId?: string
): FilesReadonlyItem => {
const id = encodeContentId(owner, repo, item.path)
const absolutePath = `/${owner}/${repo}/${item.path}`
if (item.type === 'dir') {
return {
id,
name: item.name,
type: 'folder',
parentId,
absolutePath,
}
}
return {
id,
name: item.name,
type: 'file',
parentId,
absolutePath,
sizeInBytes: item.size,
contentHash: item.sha,
}
}
export const mapPushFileToFile = (owner: string, repo: string, filePath: string): FilesReadonlyFile => {
const name = filePath.split(PATH_SEPARATOR).pop() ?? filePath
return {
id: encodeContentId(owner, repo, filePath),
name,
type: 'file',
absolutePath: `/${owner}/${repo}/${filePath}`,
}
}
+279
View File
@@ -0,0 +1,279 @@
import { generateSelectDialog } from '@botpress/common/src/html-dialogs'
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import * as sdk from '@botpress/sdk'
import { verify as verifyWebhook } from '@octokit/webhooks-methods'
import type { WebhookEvent } from '@octokit/webhooks-types'
import { App as OctokitApp, Octokit } from 'octokit'
import { GITHUB_SIGNATURE_HEADER } from './const'
import { fireDiscussionCommentCreated } from './events/discussion/discussion-comment-created'
import { fireDiscussionCommentReplied } from './events/discussion/discussion-comment-replied'
import { fireDiscussionCreated } from './events/discussion/discussion-created'
import { fireIssueCommentCreated } from './events/issue/issue-comment-created'
import { fireIssueOpened } from './events/issue/issue-opened'
import { firePullRequestCommentCreated } from './events/pull-request/pull-request-comment-created'
import { firePullRequesMerged } from './events/pull-request/pull-request-merged'
import { firePullRequestOpened } from './events/pull-request/pull-request-opened'
import { firePullRequestReviewCommentCreated } from './events/pull-request/pull-request-review-comment-created'
import { firePullRequestReviewCommentReplied } from './events/pull-request/pull-request-review-comment-replied'
import { firePullRequestReviewSubmitted } from './events/pull-request/pull-request-review-submitted'
import { firePushReceived } from './events/push/push-received'
import { GithubSettings } from './misc/github-settings'
import {
isIssueOpenedEvent,
isPingEvent,
isPushEvent,
isPullRequestCommentCreatedEvent,
isPullRequestReviewCommentCreatedEvent,
isPullRequestReviewCommentReplyCreatedEvent,
isPullRequestReviewSubmittedEvent,
isPullRequestMergedEvent,
isPullRequestOpenedEvent,
isIssueCommentCreatedEvent,
isDiscussionCreatedEvent,
isDiscussionCommentCreatedEvent,
isDiscussionCommentReplyCreatedEvent,
} from './misc/guards'
import * as bp from '.botpress'
type WebhookEventHandlerEntry<T extends WebhookEvent> = Readonly<
[(event: WebhookEvent) => event is T, (props: bp.HandlerProps & { githubEvent: T }) => Promise<void> | void]
>
const EVENT_HANDLERS: Readonly<WebhookEventHandlerEntry<any>[]> = [
[isPingEvent, () => {}],
[isIssueOpenedEvent, fireIssueOpened],
[isIssueCommentCreatedEvent, fireIssueCommentCreated],
[isPullRequestOpenedEvent, firePullRequestOpened],
[isPullRequestMergedEvent, firePullRequesMerged],
[isPullRequestCommentCreatedEvent, firePullRequestCommentCreated],
[isPullRequestReviewCommentCreatedEvent, firePullRequestReviewCommentCreated],
[isPullRequestReviewCommentReplyCreatedEvent, firePullRequestReviewCommentReplied],
[isPullRequestReviewSubmittedEvent, firePullRequestReviewSubmitted],
[isDiscussionCreatedEvent, fireDiscussionCreated],
[isDiscussionCommentCreatedEvent, fireDiscussionCommentCreated],
[isDiscussionCommentReplyCreatedEvent, fireDiscussionCommentReplied],
[isPushEvent, firePushReceived],
] as const
export const handler: bp.IntegrationProps['handler'] = async (props) => {
if (_isOauthRequest(props)) {
return await _handleOauthRequest(props)
}
if (!(await _isSignatureValid(props))) {
return _handleInvalidSignature(props)
}
await _dispatchEvent(props)
}
const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path.startsWith('/oauth')
const _handleOauthRequest = async (props: bp.HandlerProps) => {
const { logger } = props
try {
return await _routeOauthRequest(props)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
const _routeOauthRequest = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
const query = new URLSearchParams(req.query)
const state = query.get('state') ?? ''
// Step 1 — entry point from the connect link: redirect the user to GitHub's
// user-authorization screen. Unlike the app-install flow, this always echoes
// `state` back, so it works whether or not the app is already installed.
if (req.path === '/oauth/start') {
logger.forBot().info('Starting GitHub OAuth authorization')
const authorizeUrl = new URL('https://github.com/login/oauth/authorize')
authorizeUrl.searchParams.set('client_id', bp.secrets.GITHUB_CLIENT_ID)
authorizeUrl.searchParams.set('state', state)
return generateRedirection(authorizeUrl)
}
// Step 3 — the user picked an installation in the chooser rendered below.
if (req.path === '/oauth/select') {
const installationId = query.get('installation_id')
if (!installationId) {
throw new sdk.RuntimeError('No GitHub account was selected')
}
await _connectInstallation({ ctx, client, logger, installationId })
return generateRedirection(getInterstitialUrl(true))
}
// Step 2 — the GitHub callback (/oauth).
logger.forBot().info('Handling GitHub OAuth callback')
const oauthError = query.get('error')
if (oauthError) {
const description = query.get('error_description')
throw new sdk.RuntimeError(`${oauthError}${description ? ` - ${description}` : ''}`)
}
// Fresh-install redirect (or the 0-installation fallback below): GitHub
// provides the installation id directly, no code exchange needed.
const code = query.get('code')
const installationIdFromInstall = query.get('installation_id')
if (installationIdFromInstall && !code) {
await _connectInstallation({ ctx, client, logger, installationId: installationIdFromInstall })
return generateRedirection(getInterstitialUrl(true))
}
// User-authorization callback: exchange the code, then resolve which
// installation to connect.
if (!code) {
throw new sdk.RuntimeError('Missing authorization code in OAuth callback')
}
const userToken = await _exchangeCodeForUserToken(code)
const installations = await _listUserInstallations(userToken)
if (installations.length === 0) {
// Authorized, but the app isn't installed on any account yet. Send the user
// to install it; the install redirect carries installation_id + state.
const slug = await _getAppSlug({ ctx, client })
const installUrl = new URL(`https://github.com/apps/${slug}/installations/new`)
installUrl.searchParams.set('state', state)
return generateRedirection(installUrl)
}
if (installations.length === 1) {
await _connectInstallation({ ctx, client, logger, installationId: String(installations[0]!.id) })
return generateRedirection(getInterstitialUrl(true))
}
// Multiple installations: let the user choose which account to connect. The
// form submits via GET, so `state` travels in the query string where the
// bridge needs it to route the submission back to this integration.
return generateSelectDialog({
pageTitle: 'Select GitHub account',
helpText: 'Choose which GitHub account or organization to connect to this bot.',
formFieldName: 'installation_id',
formSubmitUrl: new URL('/oauth/select', process.env.BP_WEBHOOK_URL ?? ''),
extraHiddenParams: { state },
options: installations.map((installation) => ({ label: installation.label, value: String(installation.id) })),
})
}
const _connectInstallation = async ({
ctx,
client,
logger,
installationId,
}: {
ctx: bp.Context
client: bp.Client
logger: bp.Logger
installationId: string
}) => {
await _saveInstallationId({ ctx, client, installationId: Number(installationId) })
logger.forBot().info(`Connected GitHub installation ${installationId}`)
await client.configureIntegration({ identifier: installationId })
}
const _exchangeCodeForUserToken = async (code: string): Promise<string> => {
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: bp.secrets.GITHUB_CLIENT_ID,
client_secret: bp.secrets.GITHUB_CLIENT_SECRET,
code,
}),
})
const data = (await response.json()) as { access_token?: string; error?: string; error_description?: string }
if (!data.access_token) {
throw new sdk.RuntimeError(
`Failed to exchange authorization code: ${data.error_description ?? data.error ?? 'unknown error'}`
)
}
return data.access_token
}
const _listUserInstallations = async (userToken: string): Promise<{ id: number; label: string }[]> => {
const octokit = new Octokit({ auth: userToken })
const { data } = await octokit.rest.apps.listInstallationsForAuthenticatedUser({ per_page: 100 })
return data.installations.map((installation) => ({
id: installation.id,
label:
installation.account && 'login' in installation.account
? installation.account.login
: `Installation ${installation.id}`,
}))
}
const _getAppSlug = async ({ ctx, client }: { ctx: bp.Context; client: bp.Client }): Promise<string> => {
const { appId, privateKey } = GithubSettings.getAppSettings({ ctx, client })
const app = new OctokitApp({ appId, privateKey })
const { data } = await app.octokit.rest.apps.getAuthenticated()
return data?.slug ?? 'botpress'
}
const _saveInstallationId = async ({
ctx,
client,
installationId,
}: {
ctx: bp.Context
client: bp.Client
installationId: number
}) => {
// Merge with existing configuration so we don't clobber organizationHandle
// (setState replaces the whole payload).
let existing: { githubInstallationId?: number; organizationHandle?: string } = {}
try {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
existing = state.payload ?? {}
} catch {}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
...existing,
githubInstallationId: installationId,
},
})
}
const _isSignatureValid = async ({ ctx, req: { headers, body } }: bp.HandlerProps) => {
const signature = headers[GITHUB_SIGNATURE_HEADER]
const webhookSecret = GithubSettings.getWebhookSecret({ ctx })
return body && signature && verifyWebhook(webhookSecret, body, signature)
}
const _handleInvalidSignature = async ({ req: { headers, body }, logger }: bp.HandlerProps) => {
const { [GITHUB_SIGNATURE_HEADER]: signature } = headers
if (!(body && signature)) {
return console.warn('Body or signature is missing from the webhook request')
}
logger
.forBot()
.warn("Invalid signature for webhook request. Please update the webhook secret in the GitHub app's settings page.")
return console.warn('Invalid webhook signature', signature)
}
const _dispatchEvent = async (props: bp.HandlerProps) => {
const event: WebhookEvent = JSON.parse(props.req.body ?? '')
for (const [eventGuard, fireEvent] of EVENT_HANDLERS) {
if (eventGuard(event)) {
props.logger.forBot().debug(`Event matched with ${eventGuard.name}: firing handler ${fireEvent.name}`)
return await fireEvent({ ...props, githubEvent: event })
}
}
console.warn('Unsupported github event', event)
}
+21
View File
@@ -0,0 +1,21 @@
import { reporting } from '@botpress/sdk-addons'
import actions from './actions'
import channels from './channels'
import { filesReadonlyListItemsInFolder, filesReadonlyTransferFileToBotpress } from './files-readonly/actions'
import { handler } from './handler'
import { register, unregister } from './setup'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
handler,
actions: {
...actions,
filesReadonlyListItemsInFolder,
filesReadonlyTransferFileToBotpress,
},
channels,
})
export default reporting.wrapIntegration(integration)
@@ -0,0 +1,26 @@
import { createActionWrapper } from '@botpress/common'
import { wrapAsyncFnWithTryCatch } from './error-handling'
import { GitHubClient } from './github-client'
import { GithubSettings } from './github-settings'
import * as bp from '.botpress'
export const wrapActionAndInjectOctokit: typeof _wrapActionAndInjectTools = (meta, actionImpl) =>
_wrapActionAndInjectTools(meta, (props) =>
wrapAsyncFnWithTryCatch(() => {
props.logger
.forBot()
.debug(`Running action "${meta.actionName}" for owner "${props.owner}" [bot id: ${props.ctx.botId}]`)
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
}, `Action Error: ${meta.errorMessage}`)()
)
const _wrapActionAndInjectTools = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
octokit: ({ ctx, client }) => GitHubClient.create({ ctx, client }),
owner: ({ ctx, client }) => GithubSettings.getOrganizationHandle({ ctx, client }),
},
extraMetadata: {} as {
errorMessage: string
},
})
@@ -0,0 +1,36 @@
import { createChannelWrapper } from '@botpress/common'
import { wrapAsyncFnWithTryCatch } from './error-handling'
import { GitHubClient } from './github-client'
import { GithubSettings } from './github-settings'
import * as bp from '.botpress'
export type ChannelProps = Parameters<Parameters<typeof _wrapChannelAndInjectTools>[1]>[0]
export const wrapChannelAndInjectOctokit: typeof _wrapChannelAndInjectTools = (meta, channelImpl) =>
_wrapChannelAndInjectTools(meta, (props) =>
wrapAsyncFnWithTryCatch(async () => {
props.logger
.forBot()
.debug(
`Sending message in channel "${meta.channelName}" for owner "${props.owner}" [bot id: ${props.ctx.botId}]`
)
await channelImpl(props as Parameters<typeof channelImpl>[0])
}, `Channel Error: Failed to send a ${meta.messageType} message in channel "${meta.channelName}"`)()
)
const _wrapChannelAndInjectTools = createChannelWrapper<bp.IntegrationProps>()({
toolFactories: {
octokit: ({ ctx, client }) => GitHubClient.create({ ctx, client }),
owner: ({ ctx, client }) => GithubSettings.getOrganizationHandle({ ctx, client }),
repo({ conversation }) {
const repo = conversation.tags.repoName
if (!repo) {
throw new Error('Missing repoName tag')
}
return repo
},
},
})
@@ -0,0 +1,252 @@
import * as sdk from '@botpress/sdk'
import {
User as GitHubUser,
Issue as GitHubIssue,
PullRequest as GitHubPullRequest,
Discussion as GitHubDiscussion,
Repository as GitHubRepository,
Label as GitHubLabel,
PullRequestReview as GitHubPullRequestReview,
} from '@octokit/webhooks-types'
import { User, Issue, PullRequest, Discussion, Repository, Label, PullRequestReview } from 'src/definitions/entities'
import * as bp from '.botpress'
abstract class BaseEntityMapper<G extends object, B extends object> {
protected abstract map(_entity: G, ..._args: unknown[]): Promise<B>
public async mapEach(entities: G[]): Promise<B[]> {
return Promise.all(entities.map((entity) => this.tryMap(entity)))
}
public async tryMap(entity: G, ...args: unknown[]): Promise<B> {
try {
return await this.map(entity, ...args)
} catch (thrown: unknown) {
throw new sdk.RuntimeError(`Failed to map entity: ${thrown}`)
}
}
}
class UserEntityMapper extends BaseEntityMapper<GitHubUser, User> {
public constructor(protected readonly client: bp.Client) {
super()
}
protected async map(githubUser: GitHubUser): Promise<User> {
return {
name: githubUser.name ?? githubUser.login,
handle: githubUser.login,
id: githubUser.id,
nodeId: githubUser.node_id,
url: githubUser.html_url,
botpressUser: (await this._getOrCreateBotpressUserFromGithubUser({ githubUser })).id,
}
}
private async _getOrCreateBotpressUserFromGithubUser({ githubUser }: { githubUser: GitHubUser }) {
const { users } = await this.client.listUsers({
tags: {
nodeId: githubUser.node_id,
},
})
if (users.length && users[0]) {
return users[0]
}
const { user } = await this.client.createUser({
name: githubUser.login,
pictureUrl: githubUser.avatar_url,
tags: {
handle: githubUser.login,
nodeId: githubUser.node_id,
id: githubUser.id.toString(),
profileUrl: githubUser.html_url,
},
})
return user
}
}
class RepositoryEntityMapper extends BaseEntityMapper<GitHubRepository, Repository> {
public constructor(private readonly _userEntityMapper: UserEntityMapper) {
super()
}
protected async map(githubRepository: GitHubRepository): Promise<Repository> {
return {
id: githubRepository.id,
nodeId: githubRepository.node_id,
url: githubRepository.html_url,
name: githubRepository.name,
owner: await this._userEntityMapper.tryMap(githubRepository.owner),
}
}
}
class LabelEntityMapper extends BaseEntityMapper<GitHubLabel, Label> {
protected async map(githubLabel: GitHubLabel): Promise<Label> {
return {
id: githubLabel.id,
nodeId: githubLabel.node_id,
url: githubLabel.url.replace('api.github.com/repos', 'github.com'),
name: githubLabel.name,
}
}
}
class IssueEntityMapper extends BaseEntityMapper<GitHubIssue, Issue> {
public constructor(
private readonly _labelEntityMapper: LabelEntityMapper,
private readonly _userEntityMapper: UserEntityMapper,
private readonly _repositoryEntityMapper: RepositoryEntityMapper
) {
super()
}
protected async map(githubIssue: GitHubIssue, githubRepository: GitHubRepository): Promise<Issue> {
return {
id: githubIssue.id,
nodeId: githubIssue.node_id,
url: githubIssue.html_url,
number: githubIssue.number,
labels: await this._labelEntityMapper.mapEach(githubIssue.labels ?? []),
assignees: await this._userEntityMapper.mapEach(githubIssue.assignees),
name: githubIssue.title,
body: githubIssue.body ?? '',
repository: await this._repositoryEntityMapper.tryMap(githubRepository),
author: await this._userEntityMapper.tryMap(githubIssue.user),
}
}
}
class PullRequestEntityMapper extends BaseEntityMapper<GitHubPullRequest, PullRequest> {
public constructor(
private readonly _labelEntityMapper: LabelEntityMapper,
private readonly _userEntityMapper: UserEntityMapper,
private readonly _repositoryEntityMapper: RepositoryEntityMapper
) {
super()
}
protected async map(githubPullRequest: GitHubPullRequest, githubRepository: GitHubRepository): Promise<PullRequest> {
return {
id: githubPullRequest.id,
nodeId: githubPullRequest.node_id,
url: githubPullRequest.html_url,
number: githubPullRequest.number,
labels: await this._labelEntityMapper.mapEach(githubPullRequest.labels ?? []),
assignees: await this._userEntityMapper.mapEach(githubPullRequest.assignees),
name: githubPullRequest.title,
body: githubPullRequest.body ?? '',
repository: await this._repositoryEntityMapper.tryMap(githubRepository),
source: {
ref: githubPullRequest.head.ref,
label: githubPullRequest.head.label,
repository: await this._repositoryEntityMapper.tryMap(githubPullRequest.head.repo!),
},
target: {
ref: githubPullRequest.base.ref,
label: githubPullRequest.base.label,
repository: await this._repositoryEntityMapper.tryMap(githubPullRequest.base.repo!),
},
author: await this._userEntityMapper.tryMap(githubPullRequest.user),
}
}
}
class DiscussionEntityMapper extends BaseEntityMapper<GitHubDiscussion, Discussion> {
public constructor(
private readonly _repositoryEntityMapper: RepositoryEntityMapper,
private readonly _labelEntityMapper: LabelEntityMapper,
private readonly _userEntityMapper: UserEntityMapper
) {
super()
}
protected async map(githubDiscussion_: GitHubDiscussion, githubRepository: GitHubRepository): Promise<Discussion> {
const githubDiscussion = githubDiscussion_ as GitHubDiscussion & {
labels: GitHubLabel[] | undefined
category: { node_id: string }
}
return {
id: githubDiscussion.id,
nodeId: githubDiscussion.node_id,
url: githubDiscussion.html_url,
number: githubDiscussion.number,
name: githubDiscussion.title,
body: githubDiscussion.body,
labels: await this._labelEntityMapper.mapEach(githubDiscussion.labels ?? []),
repository: await this._repositoryEntityMapper.tryMap(githubRepository),
category: {
id: githubDiscussion.category.id,
nodeId: githubDiscussion.category.node_id,
name: githubDiscussion.category.name,
url: `${githubRepository.html_url}/discussions/categories/${githubDiscussion.category.slug}`,
},
author: await this._userEntityMapper.tryMap(githubDiscussion.user),
}
}
}
class PullRequestReviewEntityMapper extends BaseEntityMapper<GitHubPullRequestReview, PullRequestReview> {
public constructor(
private readonly _userEntityMapper: UserEntityMapper,
private readonly _pullRequestEntityMapper: PullRequestEntityMapper
) {
super()
}
protected async map(
githubPullRequestReview: GitHubPullRequestReview,
githubPullRequest: GitHubPullRequest
): Promise<PullRequestReview> {
return {
id: githubPullRequestReview.id,
nodeId: githubPullRequestReview.node_id,
url: githubPullRequestReview.html_url,
body: githubPullRequestReview.body ?? '',
author: await this._userEntityMapper.tryMap(githubPullRequestReview.user),
commitId: githubPullRequestReview.commit_id,
pullRequest: await this._pullRequestEntityMapper.tryMap(githubPullRequest),
state: githubPullRequestReview.state.toUpperCase() as Uppercase<typeof githubPullRequestReview.state>,
}
}
}
export const mapping = (client: bp.Client) => {
const mappers = _initializeMappers(client)
const mappingFunctions = {
mapDiscussion: mappers.discussionEntityMapper.tryMap.bind(mappers.discussionEntityMapper),
mapIssue: mappers.issueEntityMapper.tryMap.bind(mappers.issueEntityMapper),
mapLabel: mappers.labelEntityMapper.tryMap.bind(mappers.labelEntityMapper),
mapPullRequest: mappers.pullRequestEntityMapper.tryMap.bind(mappers.pullRequestEntityMapper),
mapPullRequestReview: mappers.pullRequestReviewEntityMapper.tryMap.bind(mappers.pullRequestReviewEntityMapper),
mapRepository: mappers.repositoryEntityMapper.tryMap.bind(mappers.repositoryEntityMapper),
mapUser: mappers.userEntityMapper.tryMap.bind(mappers.userEntityMapper),
} as const
return mappingFunctions
}
const _initializeMappers = (client: bp.Client) => {
const userEntityMapper = new UserEntityMapper(client)
const repositoryEntityMapper = new RepositoryEntityMapper(userEntityMapper)
const labelEntityMapper = new LabelEntityMapper()
const issueEntityMapper = new IssueEntityMapper(labelEntityMapper, userEntityMapper, repositoryEntityMapper)
const pullRequestEntityMapper = new PullRequestEntityMapper(
labelEntityMapper,
userEntityMapper,
repositoryEntityMapper
)
const discussionEntityMapper = new DiscussionEntityMapper(repositoryEntityMapper, labelEntityMapper, userEntityMapper)
const pullRequestReviewEntityMapper = new PullRequestReviewEntityMapper(userEntityMapper, pullRequestEntityMapper)
return {
discussionEntityMapper,
issueEntityMapper,
labelEntityMapper,
pullRequestEntityMapper,
pullRequestReviewEntityMapper,
repositoryEntityMapper,
userEntityMapper,
}
}
@@ -0,0 +1 @@
export { wrapAsyncFnWithTryCatch, handleErrorsDecorator } from '@botpress/common'
@@ -0,0 +1,47 @@
import * as sdk from '@botpress/sdk'
import { User as GitHubSender, WebhookEvent } from '@octokit/webhooks-types'
import { User } from 'src/definitions/entities'
import { mapping } from './entity-mapping'
import * as bp from '.botpress'
type ExtraEventProps<E extends WebhookEvent> = {
githubEvent: E
mapping: ReturnType<typeof mapping>
eventSender: User
}
type IncomingEventProps<E extends WebhookEvent> = bp.HandlerProps & { githubEvent: E }
type WrapEventFunction = <E extends WebhookEvent>(impl: {
event: (props: IncomingEventProps<E> & ExtraEventProps<E>) => Promise<void>
errorMessage: string
}) => (props: IncomingEventProps<E>) => Promise<void>
export const wrapEvent: WrapEventFunction =
({ event, errorMessage }) =>
async (props) => {
const { client, githubEvent, ctx } = props
if (!_isValidSender(githubEvent)) {
return
}
const mappingHelper = mapping(client)
const eventSender = await mappingHelper.mapUser(githubEvent.sender)
if (eventSender.botpressUser === ctx.botUserId) {
return
}
return _tryCatch(() => event({ ...props, eventSender, githubEvent, mapping: mappingHelper }), errorMessage)
}
const _isValidSender = (event: WebhookEvent): event is WebhookEvent & { sender: GitHubSender } =>
'sender' in event && ['User', 'Organization'].includes(event.sender?.type ?? '')
const _tryCatch = async <T>(fn: () => Promise<T>, errorMessage: string): Promise<T> => {
try {
return await fn()
} catch (thrown: unknown) {
console.error(`Event Handler Error: ${errorMessage}`, thrown)
throw new sdk.RuntimeError(errorMessage)
}
}
@@ -0,0 +1,127 @@
import * as sdk from '@botpress/client'
import { App as OctokitApp, Octokit } from 'octokit'
import { GithubSettings } from './github-settings'
import { GRAPHQL_QUERIES, QUERY_INPUT, QUERY_RESPONSE } from './graphql-queries'
import * as types from './types'
type AuthenticatedEntity = Readonly<{
id: string
nodeId: string
name: string
handle: string
avatarUrl: string
type: 'App' | 'PAT'
organizationHandle: string
url: string
}>
export class GitHubClient {
private _authenticatedEntity: AuthenticatedEntity | null = null
private constructor(
private readonly _octokit: Octokit,
private readonly _isApp: boolean
) {}
public static async create({ ctx, client }: { ctx: types.Context; client: types.Client }) {
const octokit = await _getOctokit({ ctx, client })
const isApp = ctx.configurationType !== 'manualPAT'
return new GitHubClient(octokit, isApp)
}
public get rest() {
return this._octokit.rest
}
public async executeGraphqlQuery<K extends keyof GRAPHQL_QUERIES>(
queryName: K,
variables: GRAPHQL_QUERIES[K][QUERY_INPUT]
): Promise<GRAPHQL_QUERIES[K][QUERY_RESPONSE]> {
return await this._octokit.graphql(GRAPHQL_QUERIES[queryName].query, variables)
}
public async getAuthenticatedEntity() {
if (!this._authenticatedEntity) {
this._authenticatedEntity = await this._getGithubAuthenticatedEntity()
}
return this._authenticatedEntity
}
private async _getGithubAuthenticatedEntity() {
return this._isApp ? await this._getGithubAuthenticatedApp() : await this._getGithubAuthenticatedUser()
}
private async _getGithubAuthenticatedApp() {
const app = await this._octokit.rest.apps.getAuthenticated()
const repos = await this._octokit.rest.apps.listReposAccessibleToInstallation({ per_page: 1 })
const firstRepo = repos.data.repositories[0]
if (!firstRepo) {
throw new sdk.RuntimeError('No repositories found for the authenticated app')
}
return {
id: app.data.id.toString(),
nodeId: app.data.node_id,
name: app.data.name,
handle: app.data.slug as string,
url: app.data.html_url,
avatarUrl: `https://avatars.githubusercontent.com/in/${app.data.id}`,
type: 'App',
organizationHandle: firstRepo.owner.login,
} as const satisfies AuthenticatedEntity
}
private async _getGithubAuthenticatedUser() {
const user = await this._octokit.rest.users.getAuthenticated()
const repos = await this._octokit.rest.repos.listForAuthenticatedUser({ per_page: 1 })
const firstRepo = repos.data[0]
if (!firstRepo) {
throw new sdk.RuntimeError('No repositories found for the authenticated user')
}
return {
id: user.data.id.toString(),
nodeId: user.data.node_id,
name: user.data.name ?? user.data.login,
handle: user.data.login,
url: user.data.html_url,
avatarUrl: user.data.avatar_url,
type: 'PAT',
organizationHandle: firstRepo.owner.login,
} as const satisfies AuthenticatedEntity
}
}
const _getOctokit = async ({ ctx, client }: { ctx: types.Context; client: types.Client }) => {
if (ctx.configurationType === 'manualPAT') {
return _getOctokitForPAT({ ctx })
}
return await _getOctokitForApp({ ctx, client })
}
const _getOctokitForApp = async ({ ctx, client }: { ctx: types.Context; client: types.Client }) => {
const { appId, privateKey, installationId } = GithubSettings.getAppSettings({ ctx, client })
try {
const octokitApp = new OctokitApp({ appId, privateKey })
return await octokitApp.getInstallationOctokit(await installationId)
} catch (thrown) {
const reason = thrown instanceof Error ? thrown.message : String(thrown)
throw new sdk.RuntimeError(
`Failed to authenticate with GitHub. Please check your credentials and try again. (${reason})`
)
}
}
const _getOctokitForPAT = ({ ctx }: { ctx: types.Context }) => {
if (ctx.configurationType !== 'manualPAT') {
throw new sdk.RuntimeError('Invalid configuration type')
}
return new Octokit({ auth: ctx.configuration.personalAccessToken })
}
@@ -0,0 +1,82 @@
import * as sdk from '@botpress/client'
import * as types from './types'
import * as bp from '.botpress'
export namespace GithubSettings {
export function getWebhookSecret({ ctx }: { ctx: types.Context }) {
return ctx.configurationType === null ? bp.secrets.GITHUB_WEBHOOK_SECRET : ctx.configuration.githubWebhookSecret
}
export async function getOrganizationHandle({ ctx, client }: { ctx: types.Context; client: types.Client }) {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
if (!state.payload.organizationHandle) {
throw new sdk.RuntimeError('Organization handle not found. Please complete the authorization flow and try again.')
}
return state.payload.organizationHandle
}
export function getAppSettings({ ctx, client }: { ctx: types.Context; client: types.Client }) {
const appId = _getAppId({ ctx })
const privateKey = _getAppPrivateKey({ ctx })
const installationId = _getAppInstallationId({ ctx, client })
return {
appId,
privateKey,
installationId,
}
}
function _getAppId({ ctx }: { ctx: types.Context }) {
return ctx.configurationType === 'manualApp' ? ctx.configuration.githubAppId : bp.secrets.GITHUB_APP_ID
}
function _getAppPrivateKey({ ctx }: { ctx: types.Context }) {
return _fixRSAPrivateKey(
ctx.configurationType === 'manualApp' ? ctx.configuration.githubAppPrivateKey : bp.secrets.GITHUB_PRIVATE_KEY
)
}
async function _getAppInstallationId({ ctx, client }: { ctx: types.Context; client: types.Client }) {
if (ctx.configurationType === 'manualApp') {
return ctx.configuration.githubAppInstallationId
}
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
if (!state.payload.githubInstallationId) {
throw new sdk.RuntimeError(
'GitHub installation ID not found. Please complete the authorization flow and try again.'
)
}
return state.payload.githubInstallationId
}
}
/**
* A private key file is required in order to use GitHub Apps. However, it is
* currently only possible to paste single-line secrets in the Botpress Studio
* UI. This means that the RSA private key gets mangled when pasted in the UI.
* This function fixes the private key by adding newlines where necessary.
*
* Multi-line secrets are also broken in the GitHub Actions deployment
* pipelines, because they are being pasted verbatim into a bash script, thus
* breaking the script because each line is interpreted as a separate command.
* This means this function is also useful for fixing the private key in the
* GitHub repository secrets.
*
* FIXME: Remove this workaround once ZUI gets support for multi-line inputs and
* our GitHub Actions pipelines get support for multi-line secrets.
*/
const _fixRSAPrivateKey = (key: string) => {
const parts = key.trim().split('-----')
const header = parts[1]?.trim()
const body = parts[2]?.trim()?.replace(/\s+/g, '\n')
const footer = parts[3]?.trim()
return `-----${header}-----\n${body}\n-----${footer}-----`
}
@@ -0,0 +1,67 @@
const QUERY_INPUT = Symbol('graphqlInputType')
const QUERY_RESPONSE = Symbol('graphqlResponseType')
type GraphQLQuery<TInput, TResponse> = {
query: string
[QUERY_INPUT]: TInput
[QUERY_RESPONSE]: TResponse
}
export const GRAPHQL_QUERIES = {
addDiscussionComment: {
query: `
mutation AddDiscussionComment($discussionNodeId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionNodeId, body: $body}) {
comment {
id
databaseId
url
}
}
}`,
[QUERY_INPUT]: {} as {
discussionNodeId: string
body: string
},
[QUERY_RESPONSE]: {} as {
addDiscussionComment: {
comment: {
id: string
databaseId: number
url: string
}
}
},
},
addDiscussionCommentReply: {
query: `
mutation AddDiscussionComment($discussionNodeId: ID!, $body: String!, replyToCommentNodeId: ID!) {
addDiscussionComment(input: {discussionId: $discussionNodeId, body: $body, replyToId: $replyToCommentNodeId}) {
comment {
id
databaseId
url
}
}
}`,
[QUERY_INPUT]: {} as {
discussionNodeId: string
replyToCommentNodeId: string
body: string
},
[QUERY_RESPONSE]: {} as {
addDiscussionComment: {
comment: {
id: string
databaseId: number
url: string
}
}
},
},
} as const satisfies Record<string, GraphQLQuery<object, object>>
export type GRAPHQL_QUERIES = typeof GRAPHQL_QUERIES
export type QUERY_INPUT = typeof QUERY_INPUT
export type QUERY_RESPONSE = typeof QUERY_RESPONSE
+59
View File
@@ -0,0 +1,59 @@
import type {
DiscussionCommentCreatedEvent,
DiscussionCreatedEvent,
IssueCommentCreatedEvent,
IssuesOpenedEvent,
PingEvent,
PullRequestClosedEvent,
PullRequestOpenedEvent,
PullRequestReviewCommentCreatedEvent,
PullRequestReviewSubmittedEvent,
PushEvent,
WebhookEvent,
} from '@octokit/webhooks-types'
export const isPingEvent = (event: WebhookEvent): event is PingEvent => 'hook_id' in event && 'zen' in event
/** Regular pull request comments */
export const isPullRequestCommentCreatedEvent = (event: WebhookEvent): event is IssueCommentCreatedEvent =>
'issue' in event && 'pull_request' in event.issue && 'comment' in event && event.action === 'created'
/** Comment on a pull request's diff (this effectively creates a new thread) */
export const isPullRequestReviewCommentCreatedEvent = (
event: WebhookEvent
): event is PullRequestReviewCommentCreatedEvent =>
'pull_request' in event && 'comment' in event && event.action === 'created' && !event.comment.in_reply_to_id
/** Reply to a comment on a pull request's diff (replies to an existing thread) */
export const isPullRequestReviewCommentReplyCreatedEvent = (
event: WebhookEvent
): event is PullRequestReviewCommentCreatedEvent =>
'pull_request' in event && 'comment' in event && event.action === 'created' && !!event.comment.in_reply_to_id
/** New review submitted, with optionally an accompanying comment */
export const isPullRequestReviewSubmittedEvent = (event: WebhookEvent): event is PullRequestReviewSubmittedEvent =>
'pull_request' in event && 'review' in event && event.action === 'submitted'
export const isPullRequestOpenedEvent = (event: WebhookEvent): event is PullRequestOpenedEvent =>
'pull_request' in event && event.action === 'opened'
export const isPullRequestMergedEvent = (event: WebhookEvent): event is PullRequestClosedEvent =>
'pull_request' in event && event.action === 'closed' && event.pull_request.merged
export const isIssueCommentCreatedEvent = (event: WebhookEvent): event is IssueCommentCreatedEvent =>
'issue' in event && !('pull_request' in event.issue) && 'comment' in event && event.action === 'created'
export const isIssueOpenedEvent = (event: WebhookEvent): event is IssuesOpenedEvent =>
'issue' in event && event.action === 'opened'
export const isDiscussionCreatedEvent = (event: WebhookEvent): event is DiscussionCreatedEvent =>
'discussion' in event && event.action === 'created' && !('comment' in event)
export const isDiscussionCommentCreatedEvent = (event: WebhookEvent): event is DiscussionCommentCreatedEvent =>
'discussion' in event && 'comment' in event && event.action === 'created' && !event.comment.parent_id
export const isDiscussionCommentReplyCreatedEvent = (event: WebhookEvent): event is DiscussionCommentCreatedEvent =>
'discussion' in event && 'comment' in event && event.action === 'created' && !!event.comment.parent_id
export const isPushEvent = (event: WebhookEvent): event is PushEvent =>
'ref' in event && 'commits' in event && 'head_commit' in event
+23
View File
@@ -0,0 +1,23 @@
import * as sdk from '@botpress/sdk'
import * as bp from '.botpress'
export type Context = bp.Context
export type Client = bp.Client
export type Request = sdk.Request
export type User = bp.ClientResponses['getUser']['user']
export type Message = bp.ClientResponses['createMessage']['message']
export type Conversation = bp.ClientResponses['getConversation']['conversation']
export type EventDefinition = sdk.EventDefinition
export type ActionDefinition = sdk.ActionDefinition
export type ChannelDefinition = sdk.ChannelDefinition
export type IntegrationCtx = bp.Context
export type RegisterFunction = bp.IntegrationProps['register']
export type UnregisterFunction = bp.IntegrationProps['unregister']
export type CreateConversationFunction = bp.IntegrationProps['createConversation']
export type CreateUserFunction = bp.IntegrationProps['createUser']
export type Channels = bp.IntegrationProps['channels']
export type AckFunction = bp.AnyAckFunction
export type IntegrationLogger = bp.Logger
+13
View File
@@ -0,0 +1,13 @@
import * as types from './types'
import * as bp from '.botpress'
export const getConversationFromTags = async <E extends keyof types.Channels>(
client: types.Client,
tags: Partial<{ channel: E } & Record<keyof bp.channels.Channels[E]['conversation']['tags'], string>>
) => {
const { conversations } = await client.listConversations({
tags,
})
return conversations.length === 1 ? (conversations[0] ?? null) : null
}
+73
View File
@@ -0,0 +1,73 @@
import { GitHubClient } from './misc/github-client'
import { RegisterFunction, UnregisterFunction } from './misc/types'
import * as bp from '.botpress'
export const register: RegisterFunction = async ({ ctx, client }) => {
const gh = await GitHubClient.create({ ctx, client })
await _configureBotpressNameAndAvatar({ ctx, client, gh })
await _configureOrganizationHandle({ ctx, client, gh })
}
export const unregister: UnregisterFunction = async (_) => {}
const _configureBotpressNameAndAvatar = async ({
ctx,
client,
gh,
}: {
ctx: bp.Context
client: bp.Client
gh: GitHubClient
}) => {
const { id, name, avatarUrl, nodeId, handle, url } = await gh.getAuthenticatedEntity()
await client.updateUser({
id: ctx.botUserId,
tags: { id, handle, nodeId, profileUrl: url },
name,
pictureUrl: avatarUrl,
})
}
const _configureOrganizationHandle = async ({
ctx,
client,
gh,
}: {
ctx: bp.Context
client: bp.Client
gh: GitHubClient
}) => {
const { organizationHandle } = await gh.getAuthenticatedEntity()
// Merge with the existing configuration state so we don't clobber the
// githubInstallationId saved by the OAuth callback (setState replaces the
// whole payload).
const existing = await _readConfigurationState({ ctx, client })
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: {
...existing,
organizationHandle,
},
})
}
const _readConfigurationState = async ({
ctx,
client,
}: {
ctx: bp.Context
client: bp.Client
}): Promise<{ githubInstallationId?: number; organizationHandle?: string }> => {
try {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
return state.payload ?? {}
} catch {
return {}
}
}