chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { findTarget } from './find-target'
|
||||
|
||||
export default {
|
||||
findTarget,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user