chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
# This is only used to extract the installation id if the integration was
|
||||
# installed using Botpress's own GitHub app
|
||||
|
||||
# If the installation was done using a custom GitHub App or a PAT, the webhook
|
||||
# is configured by the bot owner and uses the Botpress-provided webhook URL, so
|
||||
# it does not use the /integration/global/github route
|
||||
|
||||
to_string!(parse_json!(.body).installation.id)
|
||||
@@ -0,0 +1,126 @@
|
||||
Streamline your development workflow by seamlessly integrating GitHub with Botpress, the open-source conversational AI platform. This integration enables you to effortlessly manage and automate your GitHub repositories, issues, and pull requests directly from Botpress, empowering you to collaborate effectively, track progress, and ensure smooth development cycles. Take advantage of powerful features like real-time notifications, issue management, pull request merging, and more, all within the familiar Botpress environment. Boost productivity, streamline collaboration, and unleash the full potential of your conversational AI projects with the GitHub Integration for Botpress.
|
||||
|
||||
## Migrating from version `0.x` to `1.x`
|
||||
|
||||
Version `1.0` of the GitHub integration introduces several changes and improvements over the previous version. If you are migrating from version `0.x` to `1.x`, please note the following changes:
|
||||
|
||||
> The integration now supports both GitHub Apps and personal access tokens for authentication. GitHub Apps are recommended for organizations, while personal access tokens are suitable for individual users. If you wish to continue using a personal access token, you must enable fine-grained personal access tokens for your organization on GitHub. For more information on setting up the integration with a personal access token, refer to the "[manual configuration with a personal access token](#manual-configuration-with-a-personal-access-token-not-recommended)" section.
|
||||
|
||||
> The "Find Target" action now requires the `repo` field to be specified. This field should contain the name of the repository. Furthermore, the `discussion` channel was removed from this action, as it was not implemented and did nothing.
|
||||
|
||||
> The `number` tag on channels has been removed. Instead, pull requests have a `pullRequestNumber` tag, and issues have an `issueNumber` tag.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Automatic configuration with OAuth (recommended)
|
||||
|
||||
This is the simplest way to set up the integration. To set up the GitHub integration using OAuth, click the authorization button and follow the instructions to connect your Botpress chatbot to GitHub. This method is recommended as it simplifies the configuration process and ensures secure communication between your chatbot and GitHub.
|
||||
|
||||
When using this configuration mode, a Botpress-managed GitHub application will be used to connect to your GitHub organizations and repositories. The application will have the necessary permissions to listen to pull request and issue events and to create new comments on issues, pull requests and discussions. If you require more granular control over the permissions or prefer to use your own GitHub application, you can opt for the manual configuration mode instead.
|
||||
|
||||
### Manual configuration with a custom GitHub App
|
||||
|
||||
If you prefer to manually configure the integration, you can connect your own GitHub application to Botpress. To set up the GitHub integration manually, follow these steps:
|
||||
|
||||
1. In the integration settings, copy the webhook URL provided to you by Botpress.
|
||||
2. On GitHub, navigate to your organization's settings page.
|
||||
3. Under the "Developer settings" section, click on "GitHub Apps" and then click the "New GitHub App" button.
|
||||
4. Fill in the required fields to create a new GitHub App. You can customize the app's name, description, homepage URL, and other settings as needed.
|
||||
5. In the "Webhook" section, paste the webhook URL provided by Botpress into the "Webhook URL" field.
|
||||
6. Use a password generator or other secure method to generate a high entropy string to be used as your GitHub App's webhook secret key. The secret key will be used to sign the payloads sent to your webhook URL. Make sure to keep this key secure and do not share it with others.
|
||||
7. Paste the secret key into the "Webhook Secret" field on GitHub. Make sure to keep this key secure and do not share it with others.
|
||||
8. Paste the same key into the "GitHub Webhook Secret" field in the Botpress integration settings.
|
||||
9. Configure the permissions for your GitHub App as needed:
|
||||
|
||||
- Repository Permissions:
|
||||
- `Discussions`: select the "Read & write" permission to allow the bot to read and create comments on discussions.
|
||||
- `Issues`: select the "Read & write" permission to allow the bot to read and create comments on issues.
|
||||
- `Pull requests`: select the "Read & write" permission to allow the bot to read and create comments on pull requests.
|
||||
- Organization Permissions:
|
||||
- `Team discussions`: select the "Read & write" permission to allow the bot to read and create comments on team discussions.
|
||||
|
||||
10. Depending on the permissions you choose to grant, you may now subscribe to webhook events as needed:
|
||||
|
||||
- `Discussion`
|
||||
- `Discussion comment`
|
||||
- `Issues`
|
||||
- `Issue comment`
|
||||
- `Pull request`
|
||||
- `Pull request review`
|
||||
- `Pull request review comment`
|
||||
- `Pull request review thread`
|
||||
|
||||
11. Save the changes on GitHub.
|
||||
12. In your newly created GitHub App, navigate to the "General" tab and generate a new private key. Save the private key to a secure location.
|
||||
13. In the integration settings, paste the contents of the private key file you downloaded from GitHub into the "GitHub App Private Key" field.
|
||||
14. On GitHub, in the "General" tab of your GitHub App, copy the App ID and paste it into the "GitHub App ID" field in the Botpress integration settings.
|
||||
15. Install the GitHub App on your organization's repositories as needed.
|
||||
16. Once the GitHub App is installed, navigate to your organization's installed GitHub Apps page and click on the newly installed GitHub App.
|
||||
17. The URL of the page should end with `/installations/:installation_id`. For example, in the URL `https://github.com/organizations/.../settings/installations/123456`, the installation ID is `123456`. Copy the installation ID and paste it into the "GitHub App Installation ID" field in the Botpress integration settings.
|
||||
18. Save the configuration in Botpress and enable the integration.
|
||||
|
||||
## Manual configuration with a personal access token (not recommended)
|
||||
|
||||
If you prefer to manually configure the integration, you can provide a personal access token to connect your personal GitHub account to Botpress. Keep in mind that when you use a personal access token, actions taken by the bot will be attributed to your personal GitHub account. If you wish for actions to be attributed to your organization instead of to your personal account, you must use a GitHub App. GitHub applications offer a lot of advantages over personal access tokens and do not consume a seat within your GitHub organization. As such, we do not recommend using personal access tokens.
|
||||
|
||||
To set up the GitHub integration using a personal access token, follow these steps:
|
||||
|
||||
### Enabling personal access tokens for your organization
|
||||
|
||||
1. On GitHub, navigate to the settings page of your organization.
|
||||
2. Under the "Third-party Access" section, click on "Personal access tokens".
|
||||
3. In the setup page, select "Allow access via fine-grained personal access tokens".
|
||||
4. Go through the rest of the setup process to enable fine-grained personal access tokens for your organization. It is not necessary to enable _classic_ personal access tokens.
|
||||
|
||||
### Subscribing to webhook events at the org level
|
||||
|
||||
1. On Botpress, in the integration settings, copy the webhook URL provided to you by Botpress.
|
||||
2. Navigate to your organization's settings page on GitHub.
|
||||
3. Under the "Webhooks" section, click on "Add webhook".
|
||||
4. Paste the webhook URL provided by Botpress into the "Payload URL" field.
|
||||
5. Select the `application/json` content type.
|
||||
6. Use a password generator or other secure method to generate a high entropy string to be used as your webhook secret key. The secret key will be used to sign the payloads sent to your webhook URL. Make sure to keep this key secure and do not share it with others.
|
||||
7. Paste the secret key into the "Secret" field on GitHub
|
||||
8. Choose "Let me select individual events" and select the following events:
|
||||
|
||||
- `Discussion`
|
||||
- `Discussion comment`
|
||||
- `Issues`
|
||||
- `Issue comment`
|
||||
- `Pull request`
|
||||
- `Pull request review`
|
||||
- `Pull request review comment`
|
||||
- `Pull request review thread`
|
||||
|
||||
9. Save the webhook on GitHub.
|
||||
|
||||
### Creating an organization-level personal access token
|
||||
|
||||
1. On GitHub, navigate to your account settings page.
|
||||
2. Under the "Developer settings" section, click on "Personal access tokens" and then choose "Fine-grained tokens".
|
||||
3. Click the "Generate new token" button and provide a name and description for the token.
|
||||
4. Select your organization as the resource owner for the token.
|
||||
5. Choose a sufficient expiration date for the token. You will need to regenerate the token and update your integration settings accordingly when it expires.
|
||||
6. Under "Repository access", select the repositories you want the bot to have access to. Please note that if you choose "Public Repositories", the bot will not be able to reply to issues or pull requests.
|
||||
7. Configure the permissions for your personal access token as needed:
|
||||
|
||||
- Repository Permissions:
|
||||
- `Discussions`: select the "Read & write" permission to allow the bot to read and create comments on discussions.
|
||||
- `Issues`: select the "Read & write" permission to allow the bot to read and create comments on issues.
|
||||
- `Pull requests`: select the "Read & write" permission to allow the bot to read and create comments on pull requests.
|
||||
- Organization Permissions:
|
||||
- `Team discussions`: select the "Read & write" permission to allow the bot to read and create comments on team discussions.
|
||||
|
||||
8. Generate your personal access token and copy it to a secure location.
|
||||
|
||||
### Configuring the integration in Botpress
|
||||
|
||||
1. In the integration settings, paste the personal access token you generated into the "GitHub Personal Access Token" field.
|
||||
2. Paste the webhook secret key you generated into the "GitHub Webhook Secret" field.
|
||||
3. Save the configuration in Botpress and enable the integration.
|
||||
|
||||
## Limitations
|
||||
|
||||
Standard GitHub API limitations apply to the GitHub integration in Botpress. These limitations include rate limits, message size restrictions, and other constraints imposed by the GitHub platform. Ensure that your chatbot adheres to these limitations to maintain optimal performance and reliability.
|
||||
|
||||
More details are available in the [GitHub API documentation](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="98" height="96"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 970 B |
@@ -0,0 +1,41 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import filesReadonly from './bp_modules/files-readonly'
|
||||
|
||||
import { INTEGRATION_NAME } from './src/const'
|
||||
import { actions, events, configuration, configurations, channels, user, secrets, states } from './src/definitions'
|
||||
|
||||
export default new sdk.IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
title: 'GitHub',
|
||||
version: '1.3.0',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
description: 'Manage GitHub issues, pull requests, and repositories.',
|
||||
configuration,
|
||||
configurations,
|
||||
actions,
|
||||
events,
|
||||
channels,
|
||||
user,
|
||||
states,
|
||||
identifier: {
|
||||
extractScript: 'extract.vrl',
|
||||
},
|
||||
secrets: { ...secrets },
|
||||
attributes: {
|
||||
category: 'Developer Tools',
|
||||
repo: 'botpress',
|
||||
},
|
||||
}).extend(filesReadonly, ({}) => ({
|
||||
entities: {},
|
||||
actions: {
|
||||
listItemsInFolder: {
|
||||
name: 'filesReadonlyListItemsInFolder',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
transferFileToBotpress: {
|
||||
name: 'filesReadonlyTransferFileToBotpress',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,9 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
# Send the user to the integration's own OAuth start step, which builds the
|
||||
# GitHub user-authorization URL from the OAuth client id secret. The
|
||||
# user-authorization flow (unlike the app-install flow) always echoes `state`
|
||||
# back, so it works whether or not the GitHub App is already installed.
|
||||
|
||||
"{{ webhookUrl }}/oauth/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@botpresshub/github",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"@octokit/webhooks-methods": "^3.0.2",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"fuse.js": "^6.6.2",
|
||||
"octokit": "^2.0.19",
|
||||
"preact": "^10.26.6",
|
||||
"preact-render-to-string": "^6.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"files-readonly": "../../interfaces/files-readonly"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"types": ["preact"]
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user