chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
+9
View File
@@ -0,0 +1,9 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
.oxlintrc.json linguist-language=jsonc gitlab-language=jsonc
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Botpress V12
url: https://github.com/botpress/v12
about: If you're experiencing issues when using the on premise version of Botpress, please report the issue on the v12 repo.
- name: Botpress Cloud
url: https://discord.gg/botpress
about: To get assistance as fast as possible, please reach us on our Discord server.
+25
View File
@@ -0,0 +1,25 @@
name: Botpress Cloud
description: File a bug report or feature request for Botpress Cloud
body:
- type: markdown
attributes:
value: "Thank you for taking the time to fill out this issue report.\n\nKeep in mind that you will get a faster response time by talking to us on [our Discord Server](https://discord.gg/botpress)."
- type: checkboxes
id: dx-related-confirmation
attributes:
label: 'Make sure the issue is related to Botpress Cloud.'
description: 'When in doubt, please reach out on [our Discord Server](https://discord.gg/botpress).'
options:
- label: 'I confirm that the reported bug or feature request is not related to Botpress v12 and below (on premise version)'
required: true
- type: input
id: report-id
attributes:
label: 'Report ID'
description: 'Directly in the Studio, click on the "Report a Problem" button in the bottom left corner to create a report. You can then copy the report ID. Providing a report ID will allow us to help you faster. This information is safe to share publicly.'
placeholder: 'e.g. report_0123456789ABCDEFGHIJKLMNOPQRS'
- type: textarea
id: issue-description
attributes:
label: 'Description of the bug or feature request'
description: 'What is your bug or feature request ? Make sure to add logs, context or any information that you think might help understand and solve the issue.'
@@ -0,0 +1,33 @@
name: 'Create or Update Secret'
description: 'Creates or updates a secret in the repository'
inputs:
secret_name:
description: 'Secret name'
required: true
secret_value:
description: 'Secret value'
required: true
sync_secret_app_id:
description: 'Sync secret app id'
required: true
sync_secret_app_private_key:
description: 'Sync secret app private key'
required: true
runs:
using: 'composite'
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ inputs.sync_secret_app_id }}
private-key: ${{ inputs.sync_secret_app_private_key }}
- name: Set secret
shell: bash
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
SECRET_NAME: ${{ inputs.secret_name }}
SECRET_VALUE: ${{ inputs.secret_value }}
run: |
gh secret set "$SECRET_NAME" --body "$SECRET_VALUE"
@@ -0,0 +1,135 @@
name: Deploy Integrations
description: Deploys integrations
inputs:
environment:
type: choice
description: 'Environment to deploy to'
required: true
options:
- staging
- production
extra_filter:
type: string
description: 'Pnpm additional filters to select integrations to deploy'
required: false
default: ''
force:
type: boolean
description: 'Force re-deploying integrations'
default: false
required: false
dry_run:
type: boolean
description: 'Ask the backend to perform validation without actually deploying'
default: false
required: false
token_cloud_ops_account:
description: 'Cloud Ops account token'
required: true
cloud_ops_workspace_id:
description: 'Cloud Ops workspace id'
required: true
shopify_admin_automation_token:
description: 'Shopify app automation token for the Shopify Admin app (app-scoped, env-specific)'
required: false
default: ''
shopify_storefront_automation_token:
description: 'Shopify app automation token for the Shopify Storefront app (app-scoped, env-specific)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Deploys Integrations
env:
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_FORCE: ${{ inputs.force }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
INPUT_EXTRA_FILTER: ${{ inputs.extra_filter }}
TOKEN_CLOUD_OPS_ACCOUNT: ${{ inputs.token_cloud_ops_account }}
CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }}
SHOPIFY_ADMIN_AUTOMATION_TOKEN: ${{ inputs.shopify_admin_automation_token }}
SHOPIFY_STOREFRONT_AUTOMATION_TOKEN: ${{ inputs.shopify_storefront_automation_token }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
shell: bash
run: |
if [ "$INPUT_ENVIRONMENT" = "staging" ]; then
api_url="https://api.botpress.dev"
else
api_url="https://api.botpress.cloud"
fi
# login
echo "### Logging in to $api_url ###"
pnpm bp login -y --api-url "$api_url" --workspaceId "$CLOUD_OPS_WORKSPACE_ID" --token "$TOKEN_CLOUD_OPS_ACCOUNT"
# deploy
if [ "$INPUT_FORCE" = "true" ]; then
redeploy=1
else
redeploy=0
fi
if [ "$INPUT_DRY_RUN" = "true" ]; then
dryrun="--dryRun"
is_dry_run=1
else
dryrun=""
is_dry_run=0
fi
all_filters="-F '{integrations/*}' $INPUT_EXTRA_FILTER"
list_integrations_cmd="pnpm list $all_filters --json"
integration_paths=$(eval "$list_integrations_cmd" | jq -r 'map(.path) | .[]')
for integration_path in $integration_paths; do
integration=$(basename "$integration_path")
exists=$(./.github/scripts/integration-exists.sh "$integration")
base_command="bp deploy -v -y --noBuild --visibility public --allowDeprecated $dryrun"
integration_deployed=false
if [ "$exists" -eq 0 ]; then
echo -e "\nDeploying integration: ### $integration ###\n"
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command"
integration_deployed=true
elif [ "$redeploy" -eq 1 ]; then
echo -e "\nRe-deploying integration: ### $integration ###\n"
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$base_command"
integration_deployed=true
else
echo -e "\nSkipping integration: ### $integration ###\n"
fi
# upload sandbox scripts
integration_implements_sandbox=$(./.github/scripts/integration-implements-sandbox.sh "$integration")
if [ "$integration_implements_sandbox" = "true" ] && [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ]; then
echo -e "\nUploading integration sandbox scripts\n"
base_upload_command="uploadSandboxScripts --apiUrl=$api_url --workspaceId=$CLOUD_OPS_WORKSPACE_ID --token=$TOKEN_CLOUD_OPS_ACCOUNT --userEmail=cloud-ops@botpress.com"
# shellcheck disable=SC2086 # base_upload_command contains multiple space-separated arguments
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" run -- $base_upload_command
fi
# deploy shopify app manifest (config-as-code) for the current environment
manifest_file="$integration_path/shopify.app.$INPUT_ENVIRONMENT.toml"
if [ "$integration_deployed" = "true" ] && [ "$is_dry_run" -eq 0 ] && [ -f "$manifest_file" ]; then
case "$integration" in
shopify-admin) shopify_token="$SHOPIFY_ADMIN_AUTOMATION_TOKEN" ;;
shopify-storefront) shopify_token="$SHOPIFY_STOREFRONT_AUTOMATION_TOKEN" ;;
*) shopify_token="" ;;
esac
if [ -z "$shopify_token" ]; then
echo "::warning::Found $manifest_file but no Shopify automation token for $integration - skipping manifest deploy"
else
echo -e "\nDeploying Shopify app manifest ($INPUT_ENVIRONMENT): ### $integration ###\n"
shopify_deploy_command="pnpm shopify app deploy --config $INPUT_ENVIRONMENT --allow-updates --source-control-url $COMMIT_URL"
SHOPIFY_APP_AUTOMATION_TOKEN="$shopify_token" \
pnpm retry -n 2 -- pnpm -F "{integrations/$integration}" -c exec -- "$shopify_deploy_command"
fi
fi
done
@@ -0,0 +1,78 @@
name: Deploy Interfaces
description: Deploys interfaces
inputs:
environment:
type: choice
description: 'Environment to deploy to'
required: true
options:
- staging
- production
extra_filter:
type: string
description: 'Pnpm additional filters to select interfaces to deploy'
required: false
default: ''
force:
type: boolean
description: 'Force re-deploying interfaces'
default: false
required: false
token_cloud_ops_account:
description: 'Cloud Ops account token'
required: true
cloud_ops_workspace_id:
description: 'Cloud Ops workspace id'
required: true
runs:
using: 'composite'
steps:
- name: Deploys Interfaces
shell: bash
env:
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_FORCE: ${{ inputs.force }}
INPUT_EXTRA_FILTER: ${{ inputs.extra_filter }}
TOKEN_CLOUD_OPS_ACCOUNT: ${{ inputs.token_cloud_ops_account }}
CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }}
run: |
if [ "$INPUT_ENVIRONMENT" = "staging" ]; then
api_url="https://api.botpress.dev"
else
api_url="https://api.botpress.cloud"
fi
# login
echo "### Logging in to $api_url ###"
pnpm bp login -y --api-url "$api_url" --workspaceId "$CLOUD_OPS_WORKSPACE_ID" --token "$TOKEN_CLOUD_OPS_ACCOUNT"
# deploy
if [ "$INPUT_FORCE" = "true" ]; then
redeploy=1
else
redeploy=0
fi
all_filters="-F '{interfaces/*}' $INPUT_EXTRA_FILTER"
list_interfaces_cmd="pnpm list $all_filters --json"
interface_paths=$(eval "$list_interfaces_cmd" | jq -r 'map(.path) | .[]')
for interface_path in $interface_paths; do
interface=$(basename "$interface_path")
exists=$(./.github/scripts/interface-exists.sh "$interface")
base_command="bp deploy -v -y --visibility public"
if [ "$exists" -eq 0 ]; then
echo -e "\nDeploying interface: ### $interface ###\n"
pnpm retry -n 2 -- pnpm -F "{interfaces/$interface}" -c exec -- "$base_command"
elif [ "$redeploy" -eq 1 ]; then
echo -e "\nRe-deploying interface: ### $interface ###\n"
pnpm retry -n 2 -- pnpm -F "{interfaces/$interface}" -c exec -- "$base_command"
else
echo -e "\nSkipping interface: ### $interface ###\n"
fi
done
+79
View File
@@ -0,0 +1,79 @@
name: Deploy Plugins
description: Deploys plugins
inputs:
environment:
type: choice
description: 'Environment to deploy to'
required: true
options:
- staging
- production
extra_filter:
type: string
description: 'Pnpm additional filters to select plugins to deploy'
required: false
default: ''
force:
type: boolean
description: 'Force re-deploying plugins'
default: false
required: false
token_cloud_ops_account:
description: 'Cloud Ops account token'
required: true
cloud_ops_workspace_id:
description: 'Cloud Ops workspace id'
required: true
runs:
using: 'composite'
steps:
- name: Deploys Plugins
shell: bash
env:
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_FORCE: ${{ inputs.force }}
INPUT_EXTRA_FILTER: ${{ inputs.extra_filter }}
TOKEN_CLOUD_OPS_ACCOUNT: ${{ inputs.token_cloud_ops_account }}
CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }}
run: |
if [ "$INPUT_ENVIRONMENT" = "staging" ]; then
api_url="https://api.botpress.dev"
else
api_url="https://api.botpress.cloud"
fi
# login
echo "### Logging in to $api_url ###"
pnpm bp login -y --api-url "$api_url" --workspaceId "$CLOUD_OPS_WORKSPACE_ID" --token "$TOKEN_CLOUD_OPS_ACCOUNT"
# deploy
if [ "$INPUT_FORCE" = "true" ]; then
redeploy=1
else
redeploy=0
fi
all_filters="-F '{plugins/*}' $INPUT_EXTRA_FILTER"
list_plugins_cmd="pnpm list $all_filters --json"
plugin_paths=$(eval "$list_plugins_cmd" | jq -r 'map(.path) | .[]')
for plugin_path in $plugin_paths; do
plugin=$(basename "$plugin_path")
exists=$(./.github/scripts/plugin-exists.sh "$plugin")
base_command="bp deploy -v -y --visibility public"
if [ "$exists" -eq 0 ]; then
echo -e "\nDeploying plugin: ### $plugin ###\n"
pnpm retry -n 2 -- pnpm -F "{plugins/$plugin}" -c exec -- "$base_command"
elif [ "$redeploy" -eq 1 ]; then
echo -e "\nRe-deploying plugin: ### $plugin ###\n"
pnpm retry -n 2 -- pnpm -F "{plugins/$plugin}" -c exec -- "$base_command"
else
echo -e "\nSkipping plugin: ### $plugin ###\n"
fi
done
+85
View File
@@ -0,0 +1,85 @@
name: Docker Build
description: 'Build and push a Docker image to the registry'
inputs:
repository:
description: 'The name of the repository to build'
required: true
dockerfile:
description: 'The name of the Dockerfile to build'
required: true
push:
description: 'Whether to push the image to the registry'
required: false
default: false
minify:
description: 'Whether to minify the build'
required: false
default: true
tag:
description: 'Optional Docker tag'
required: false
default: ''
depot-project:
description: 'Depot project ID (required if no depot.json; get it from depot.dev)'
required: false
default: ''
runs:
using: 'composite'
steps:
- uses: aws-actions/configure-aws-credentials@v3
with:
role-session-name: container_pusher
role-to-assume: arn:aws:iam::986677156374:role/actions/build/container_pusher
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v1
id: ecr
with:
mask-password: true
- uses: docker/metadata-action@v4
id: meta
with:
images: ${{ steps.ecr.outputs.registry }}/${{ inputs.repository }}
flavor: |
latest=false
tags: |
type=raw,enable=${{ inputs.tag != '' }},value=${{ inputs.tag }}
type=semver,pattern={{version}}
type=sha,enable=${{ !startsWith(github.ref, 'refs/tags') }},prefix=,format=long
- name: Set BUILD_DATE
id: meta_date
shell: bash
run: |
export TZ=America/Toronto
echo "timestamp=$(date +"%Y-%m-%d %H:%M:%S")" >> "$GITHUB_OUTPUT"
- name: Create ECR Registry
shell: bash
env:
ECR_REPOSITORY: ${{ inputs.repository }}
ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
run: |
aws --version
aws ecr create-repository --repository-name "$ECR_REPOSITORY" || true
aws ssm get-parameter --name '/cloud/container-registry/ecr-policy-document' --query 'Parameter.Value' | jq -r > repository-policy.json
aws ecr set-repository-policy --repository-name "$ECR_REPOSITORY" --policy-text file://repository-policy.json &> /dev/null
- name: Set up Depot CLI
uses: depot/setup-action@v1
- uses: depot/build-push-action@v1
with:
project: ${{ inputs.depot-project }}
build-args: |
MINIFY=${{ inputs.minify }}
DOCKER_TAG=${{ inputs.tag }}
BUILD_DATE=${{ steps.meta_date.outputs.timestamp }}
file: ${{ inputs.dockerfile }}
context: .
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -0,0 +1,53 @@
name: 'Refresh Instagram Access Tokens'
description: 'Refreshes Instagram access token, updates the integrations secrets and update the token GitHub secrets'
inputs:
token_cloud_ops_account:
description: 'Cloud Ops account token'
required: true
cloud_ops_workspace_id:
description: 'Cloud Ops workspace id'
required: true
api_url:
description: 'API URL'
required: true
current_token:
description: 'Current token'
required: true
secret_name:
description: 'Secret name'
required: true
sync_secret_app_id:
description: 'Create or update secret app id'
required: true
sync_secret_app_private_key:
description: 'Create or update secret app private key'
required: true
runs:
using: 'composite'
steps:
- name: Refresh Access Tokens
id: refresh_tokens
shell: bash
env:
BP_API_URL: ${{ inputs.api_url }}
BP_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }}
BP_TOKEN: ${{ inputs.token_cloud_ops_account }}
run: |
refresh_result=$(pnpm -F 'instagram' exec -- pnpm run --silent refreshTokens --json --refreshToken "${{ inputs.current_token }}")
new_token=$(echo "$refresh_result" | jq -r '.refreshedToken')
if [ -z "$new_token" ]; then
echo "❌ Error: Failed to refresh token" >&2
exit 1
fi
echo "$refresh_result" | jq -r '.message'
echo "✅ Tokens refreshed successfully"
echo "::add-mask::$new_token"
echo "new_token=$new_token" >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/create-or-update-secret
with:
secret_name: ${{ inputs.secret_name }}
secret_value: ${{ steps.refresh_tokens.outputs.new_token }}
sync_secret_app_id: ${{ inputs.sync_secret_app_id }}
sync_secret_app_private_key: ${{ inputs.sync_secret_app_private_key }}
+45
View File
@@ -0,0 +1,45 @@
name: Setup
description: install dependencies and build
inputs:
extra_filters:
description: 'Turbo additional filters to select packages to build'
required: false
runs:
using: 'composite'
steps:
- uses: pnpm/action-setup@v4 # uses the version from package.json
- uses: actions/setup-node@v6
with:
node-version-file: '.tool-versions'
package-manager-cache: false
- name: Install Node Gyp Build
shell: ${{ runner.os == 'Windows' && 'pwsh' || 'bash' }}
run: |
npm install -g node-gyp-build
- name: Install dependencies
shell: ${{ runner.os == 'Windows' && 'pwsh' || 'bash' }}
run: pnpm i --frozen-lockfile
- name: Cache Turbo
uses: actions/cache@v5
with:
path: |
.turbo
node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ github.workflow_ref }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Build
shell: ${{ runner.os == 'Windows' && 'pwsh' || 'bash' }}
env:
BP_VERBOSE: 'true'
EXTRA_FILTERS: ${{ inputs.extra_filters }}
run: |
# shellcheck disable=SC2086 # EXTRA_FILTERS contains multiple space-separated filter arguments
pnpm turbo run build $EXTRA_FILTERS
@@ -0,0 +1,26 @@
name: Update Linear Status
inputs:
linearApiKey:
required: true
teamName:
required: true
targetLabel:
required: true
runs:
using: 'composite'
steps:
- uses: actions/checkout@v4
- name: Install dependencies
shell: bash
run: pnpm install tsx -w --save-dev
- name: Update issues
shell: bash
run: npx tsx ./.github/scripts/update-linear.ts
env:
LINEAR_API_KEY: ${{inputs.linearApiKey}}
TEAM_NAME: ${{inputs.teamName}}
TARGET_LABEL: ${{inputs.targetLabel}}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: integration name is not provided" >&2
exit 1
fi
integration=$1
integration_path="integrations/$integration"
if ! integration_def=$(pnpm bp read --work-dir "$integration_path" --json); then
echo "Error: Failed to read integration definition for \"$integration\". Check the integration for TypeScript errors." >&2
exit 1
fi
name=$(echo "$integration_def" | jq -r ".name")
version=$(echo "$integration_def" | jq -r ".version")
exists=$(pnpm bp integrations ls --name "$name" --version-number "$version" --json | jq '[ .[] | select(.public) ] | length') # 0 if not exists
echo "$exists"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: integration name is not provided" >&2
exit 1
fi
integration=$1
integration_path="integrations/$integration"
if ! integration_def=$(pnpm bp read --work-dir "$integration_path" --json); then
echo "Error: Failed to read integration definition for \"$integration\". Check the integration for TypeScript errors." >&2
exit 1
fi
category=$(echo "$integration_def" | jq -r '.attributes.category // empty')
valid_categories=(
"AI Models"
"Business Operations"
"CRM & Sales"
"Communication & Channels"
"Customer Support"
"Developer Tools"
"E-commerce & Payments"
"File Management"
"Marketing & Email"
"Other"
"Project Management"
)
if [ -z "$category" ]; then
echo "Integration \"$integration\" is missing the \"category\" attribute in its definition file" >&2
echo "Valid categories are: $(printf '"%s", ' "${valid_categories[@]}" | sed 's/, $//')" >&2
exit 1
fi
for valid_category in "${valid_categories[@]}"; do
if [ "$category" = "$valid_category" ]; then
echo "Integration \"$integration\" has a valid category: \"$category\""
exit 0
fi
done
echo "Integration \"$integration\" has an invalid category: \"$category\"" >&2
echo "Valid categories are: $(printf '"%s", ' "${valid_categories[@]}" | sed 's/, $//')" >&2
exit 1
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: integration name is not provided" >&2
exit 1
fi
integration=$1
integration_path="integrations/$integration"
has_sandbox=false
sandbox_script="$integration_path/sandboxIdentifierExtract.vrl"
if [ -f "$sandbox_script" ]; then
has_sandbox=true
fi
echo "$has_sandbox"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: interface name is not provided" >&2
exit 1
fi
interface=$1
interface_path="interfaces/$interface"
interface_def=$(pnpm bp read --work-dir "$interface_path" --json)
name=$(echo "$interface_def" | jq -r ".name")
version=$(echo "$interface_def" | jq -r ".version")
command="pnpm bp interfaces get \"$name@$version\" --json >/dev/null 2>&1"
exists=$(eval "$command" && echo 1 || echo 0)
echo "$exists"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: plugin name is not provided" >&2
exit 1
fi
plugin=$1
plugin_path="plugins/$plugin"
plugin_def=$(pnpm bp read --work-dir "$plugin_path" --json)
name=$(echo "$plugin_def" | jq -r ".name")
version=$(echo "$plugin_def" | jq -r ".version")
command="pnpm bp plugins get \"$name@$version\" --json >/dev/null 2>&1"
exists=$(eval "$command" && echo 1 || echo 0)
echo "$exists"
+93
View File
@@ -0,0 +1,93 @@
import { LinearClient, Team, WorkflowState, IssueLabel } from '@linear/sdk'
const LINEAR_API_KEY = process.env.LINEAR_API_KEY
const SOURCE_STATE_NAME = 'Staging'
const TARGET_STATE_NAME = 'Production (Done)'
const TEAM_NAME = process.env.TEAM_NAME
const TARGET_LABEL = process.env.TARGET_LABEL
if (!LINEAR_API_KEY) {
throw new Error('No LINEAR_API_KEY environment variable')
}
if (!TEAM_NAME) {
throw new Error('No TEAM_NAME environment variable')
}
if (!TARGET_LABEL) {
throw new Error('No TARGET_LABEL environment variable')
}
void updateLinearIssues()
async function getTeam(): Promise<Team> {
const client = new LinearClient({ apiKey: LINEAR_API_KEY })
const teams = await client.teams()
const targetTeam = teams.nodes.find((t) => t.name === TEAM_NAME)
if (!targetTeam) throw new Error(`Could not find team with name "${TEAM_NAME}"`)
return targetTeam
}
async function getStates(targetTeam: Team): Promise<{ sourceState: WorkflowState; targetState: WorkflowState }> {
const states = await targetTeam.states()
const sourceState = states.nodes.find((s) => s.name === SOURCE_STATE_NAME)
const targetState = states.nodes.find((s) => s.name === TARGET_STATE_NAME)
if (!sourceState) throw new Error(`Could not find workflow state ${SOURCE_STATE_NAME}`)
if (!targetState) throw new Error(`Could not find workflow state ${TARGET_STATE_NAME}`)
console.info(`Found source state: ${sourceState.name} (${sourceState.id})`)
console.info(`Found target state: ${targetState.name} (${targetState.id})`)
return { sourceState, targetState }
}
async function getTargetLabels(targetTeam: Team): Promise<IssueLabel[]> {
let labels = await targetTeam.labels()
const labelsArray = [...labels.nodes]
while (labels.pageInfo.hasNextPage) {
labels = await labels.fetchNext()
labelsArray.push(...labels.nodes)
}
const targetLabels = labelsArray.filter(
(label) => label.name === TARGET_LABEL || label.name.startsWith(TARGET_LABEL + '/')
)
if (targetLabels.length === 0) {
throw new Error(`Could not find any labels matching ${TARGET_LABEL}`)
}
console.info(`Found ${targetLabels.length} matching label(s):`)
targetLabels.forEach((label) => {
console.info(` - ${label.name} (${label.id})`)
})
return targetLabels
}
async function updateLinearIssues() {
const targetTeam = await getTeam()
const { sourceState, targetState } = await getStates(targetTeam)
const targetLabels = await getTargetLabels(targetTeam)
const targetLabelIds = targetLabels.map((label) => label.id)
const issues = await targetTeam.issues({
filter: {
labels: { some: { id: { in: targetLabelIds } } },
state: { id: { eq: sourceState.id } },
},
})
console.info(`Found ${issues.nodes.length} issue(s) in state "${SOURCE_STATE_NAME}"`)
await Promise.all(
issues.nodes.map(async (issue) => {
console.info(`Updating issue ${issue.identifier} to ${TARGET_STATE_NAME}`)
await issue.update({ stateId: targetState.id })
})
)
}
+19
View File
@@ -0,0 +1,19 @@
name: Check All
on:
pull_request:
branches:
- master
jobs:
check-all:
runs-on: ubuntu-latest
permissions:
checks: read
statuses: read
steps:
- name: Check All
uses: upsidr/merge-gatekeeper@v1
with:
timeout: 900 # 15 minutes
token: ${{ secrets.GITHUB_TOKEN }}
self: check-all
@@ -0,0 +1,46 @@
name: Check Integration Categories
on: pull_request
permissions:
id-token: write
contents: read
jobs:
check-integration-categories:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: 'integrations/**/*.{ts,md,svg,vrl}'
separator: '\n'
- name: Check integration categories
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
run: |
modified_integrations=$(echo -e "$CHANGED_FILES" | awk -F'/' '{print $2}' | sort -u)
should_fail=0
for integration in $modified_integrations; do
if [ ! -d "integrations/$integration" ]; then
continue
fi
echo "Checking $integration"
if ! ./.github/scripts/integration-has-valid-category.sh "$integration"; then
should_fail=1
fi
done
if [ "$should_fail" -ne 0 ]; then
echo "Please add a valid category attribute to your integration definition before pushing your changes."
exit 1
fi
@@ -0,0 +1,64 @@
name: Check Integration Versions
on: pull_request
permissions:
id-token: write
contents: read
jobs:
check-integration-versions:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Login to Botpress
env:
TOKEN: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
WORKSPACE_ID: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
run: pnpm bp login -y --token "$TOKEN" --workspace-id "$WORKSPACE_ID"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: 'integrations/**/*.{ts,md,svg,vrl}'
separator: '\n'
- name: Check integration versions
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
run: |
modified_integrations=$(echo -e "$CHANGED_FILES" | awk -F'/' '{print $2}' | sort -u)
should_fail=0
SKIP_INTEGRATIONS=("chat")
for integration in $modified_integrations; do
skip=0
for skip_integration in "${SKIP_INTEGRATIONS[@]}"; do
if [ "$integration" = "$skip_integration" ]; then
echo "Skipping $integration"
skip=1
break
fi
done
if [ $skip -eq 1 ]; then
continue
fi
echo "Checking $integration"
exists=$(./.github/scripts/integration-exists.sh "$integration")
if [ "$exists" -ne 0 ]; then
echo "Integration $integration is already deployed publicly with the same version. Please update the version of your integration."
should_fail=1
fi
done
if [ "$should_fail" -ne 0 ]; then
echo "Please update the version of your integration before pushing your changes."
exit 1
fi
@@ -0,0 +1,49 @@
name: Check Plugins Versions
on: pull_request
permissions:
id-token: write
contents: read
jobs:
check-plugins-versions:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Login to Botpress
env:
TOKEN: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
WORKSPACE_ID: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
run: pnpm bp login -y --token "$TOKEN" --workspace-id "$WORKSPACE_ID"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: 'plugins/**/*.{ts,md,svg}'
separator: '\n'
- name: Check plugin versions
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
run: |
modified_plugins=$(echo -e "$CHANGED_FILES" | awk -F'/' '{print $2}' | sort -u)
should_fail=0
for plugin in $modified_plugins; do
echo "Checking $plugin"
exists=$(./.github/scripts/plugin-exists.sh "$plugin")
if [ "$exists" -ne 0 ]; then
echo "Plugin $plugin is already deployed publicly with the same version. Please update the version of your plugin."
should_fail=1
fi
done
if [ "$should_fail" -ne 0 ]; then
echo "Please update the version of your plugin before pushing your changes."
exit 1
fi
+18
View File
@@ -0,0 +1,18 @@
name: Check Shell Scripts
on:
pull_request:
paths:
- '**/*.sh'
- .github/workflows
- .github/actions
jobs:
check-shells:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check shell scripts
uses: botpress/gh-actions/check-shells@v3
+52
View File
@@ -0,0 +1,52 @@
name: Deploy Bots
on: workflow_dispatch
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup
env:
BUGBUSTER_GITHUB_TOKEN: ${{ secrets.BUGBUSTER_GITHUB_TOKEN }}
BUGBUSTER_GITHUB_WEBHOOK_SECRET: ${{ secrets.BUGBUSTER_GITHUB_WEBHOOK_SECRET }}
BUGBUSTER_LINEAR_API_KEY: ${{ secrets.BUGBUSTER_LINEAR_API_KEY }}
BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET: ${{ secrets.BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET }}
BUGBUSTER_TELEGRAM_BOT_TOKEN: ${{ secrets.BUGBUSTER_TELEGRAM_BOT_TOKEN }}
BUGBUSTER_SLACK_REFRESH_TOKEN: ${{ secrets.BUGBUSTER_SLACK_REFRESH_TOKEN }}
BUGBUSTER_SLACK_CLIENT_ID: ${{ secrets.BUGBUSTER_SLACK_CLIENT_ID }}
BUGBUSTER_SLACK_CLIENT_SECRET: ${{ secrets.BUGBUSTER_SLACK_CLIENT_SECRET }}
BUGBUSTER_SLACK_SIGNING_SECRET: ${{ secrets.BUGBUSTER_SLACK_SIGNING_SECRET }}
CLOG_SLACK_CLIENT_ID: ${{ secrets.CLOG_SLACK_CLIENT_ID }}
CLOG_SLACK_CLIENT_SECRET: ${{ secrets.CLOG_SLACK_CLIENT_SECRET }}
CLOG_SLACK_SIGNING_SECRET: ${{ secrets.CLOG_SLACK_SIGNING_SECRET }}
CLOG_SLACK_REFRESH_TOKEN: ${{ secrets.CLOG_SLACK_REFRESH_TOKEN }}
uses: ./.github/actions/setup
- name: Deploy Bots
env:
WORKSPACE_ID: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
TOKEN: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
api_url="https://api.botpress.cloud"
# login
echo "### Logging in to $api_url ###"
pnpm bp login -y --api-url "$api_url" --workspaceId "$WORKSPACE_ID" --token "$TOKEN"
# deploy
bots="pnpm list -F bugbuster -F clog --json"
bot_paths=$(eval "$bots" | jq -r 'map(.path) | .[]')
for bot_path in $bot_paths; do
bot_name=$(basename "$bot_path")
echo -e "\nDeploying bot: ### $bot_name ###\n"
bot_id=$(pnpm bp bots new --name "$bot_name" --json --if-not-exists | jq -r ".id")
pnpm retry -n 2 -- pnpm -F "$bot_name" -c exec -- "pnpm bp deploy -v -y --botId $bot_id"
done
@@ -0,0 +1,75 @@
name: Deploy Chat Production
on:
workflow_dispatch:
inputs:
branchOrCommit:
description: 'Branch/Commit/Tag to deploy'
required: false
type: string
default: 'master'
skipDeploy:
description: 'Skip deployment to ECS'
required: false
type: boolean
default: false
builder:
description: 'Image Builder'
required: false
type: choice
options:
- docker
- depot
default: 'depot'
permissions:
id-token: write
contents: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
deploy-chat-production:
runs-on: ubuntu-latest
steps:
- uses: botpress/gh-actions/full-service-deploy@v3.3
with:
service: chat
repository: chat-integration
dockerfile: integrations/chat/Dockerfile
context: .
builder: ${{ inputs.builder || 'depot' }}
depot-project: ${{ secrets.DEPOT_PROJECT_ID }}
role-ecs-update: botpress_infra_update
skip-ecs-update: ${{ inputs.skipDeploy }}
create-tag: true
ref: ${{ inputs.branchOrCommit || github.sha }}
environment: production
deploy-chat-lambda:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ inputs.branchOrCommit || github.sha }}
- name: Setup
uses: ./.github/actions/setup
- name: Deploy to Lambda
run: |
pnpm bp login -y --api-url "https://api.botpress.cloud" --workspaceId "${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}" --token "${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}"
pnpm -F "{integrations/chat}" -c exec -- bp deploy -v -y --noBuild --visibility public --allowDeprecated
ping-success:
needs: [deploy-chat-production, deploy-chat-lambda]
runs-on: ubuntu-latest
steps:
- run: curl -m 10 --retry 5 "${{ secrets.CHAT_DEPLOY_PRODUCTION_PING_URL }}"
ping-failure:
needs: [deploy-chat-production, deploy-chat-lambda]
if: ${{ failure() }}
runs-on: ubuntu-latest
steps:
- run: curl -m 10 --retry 5 "${{ secrets.CHAT_DEPLOY_PRODUCTION_PING_URL }}/fail"
+79
View File
@@ -0,0 +1,79 @@
name: Deploy Chat Staging
on:
push:
branches:
- master
paths:
- 'integrations/chat/**'
workflow_dispatch:
inputs:
branchOrCommit:
description: 'Branch/Commit/Tag to deploy'
required: false
type: string
default: 'master'
skipDeploy:
description: 'Skip deployment to ECS'
required: false
type: boolean
default: false
builder:
description: 'Image Builder'
required: false
type: choice
options:
- docker
- depot
default: 'depot'
permissions:
id-token: write
contents: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
deploy-chat-staging:
runs-on: ubuntu-latest
steps:
- uses: botpress/gh-actions/full-service-deploy@v3.3
with:
service: chat
repository: chat-integration
dockerfile: integrations/chat/Dockerfile
context: .
builder: ${{ inputs.builder || 'depot' }}
depot-project: ${{ secrets.DEPOT_PROJECT_ID }}
role-ecs-update: botpress_infra_update
skip-ecs-update: ${{ inputs.skipDeploy }}
ref: ${{ inputs.branchOrCommit || github.sha }}
environment: staging
deploy-chat-lambda:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ inputs.branchOrCommit || github.sha }}
- name: Setup
uses: ./.github/actions/setup
- name: Deploy to Lambda
run: |
pnpm bp login -y --api-url "https://api.botpress.dev" --workspaceId "${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}" --token "${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}"
pnpm -F "{integrations/chat}" -c exec -- bp deploy -v -y --noBuild --visibility public --allowDeprecated
ping-success:
needs: [deploy-chat-staging, deploy-chat-lambda]
runs-on: ubuntu-latest
steps:
- run: curl -m 10 --retry 5 "${{ secrets.CHAT_DEPLOY_STAGING_PING_URL }}"
ping-failure:
needs: [deploy-chat-staging, deploy-chat-lambda]
if: ${{ failure() }}
runs-on: ubuntu-latest
steps:
- run: curl -m 10 --retry 5 "${{ secrets.CHAT_DEPLOY_STAGING_PING_URL }}/fail"
@@ -0,0 +1,55 @@
name: Deploy Integrations Production
on:
workflow_dispatch:
inputs:
force:
description: 'Force re-deploying integrations'
type: boolean
required: false
default: false
permissions:
id-token: write
contents: read
jobs:
deploy-production:
runs-on: depot-ubuntu-22.04-8
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Deploy Interfaces
uses: ./.github/actions/deploy-interfaces
with:
environment: 'production'
force: ${{ github.event.inputs.force == 'true' }}
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
- name: Deploy Integrations
uses: ./.github/actions/deploy-integrations
with:
environment: 'production'
extra_filter: "-F '!docusign' -F '!chat' -F '!grafana'"
force: ${{ github.event.inputs.force == 'true' }}
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
shopify_admin_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_ADMIN_AUTOMATION_TOKEN }}
shopify_storefront_automation_token: ${{ secrets.PRODUCTION_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }}
- name: Deploy Plugins
uses: ./.github/actions/deploy-plugins
with:
extra_filter: "-F '!analytics' -F '!logger' -F '!personality' -F '!synchronizer' -F '!knowledge'"
environment: 'production'
force: ${{ github.event.inputs.force == 'true' }}
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
- name: Update Linear Status
uses: ./.github/actions/update-linear-status
continue-on-error: true
with:
linearApiKey: ${{secrets.LINEAR_API_KEY}}
teamName: 'SHELL (Integration)'
targetLabel: 'area/integrations'
@@ -0,0 +1,56 @@
name: Deploy Integrations Staging
on:
push:
branches:
- master
pull_request:
paths: 'integrations/**'
workflow_dispatch:
inputs:
force:
description: 'Force re-deploying integrations'
type: boolean
required: false
default: false
permissions:
id-token: write
contents: read
jobs:
deploy-staging:
runs-on: depot-ubuntu-22.04-8
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Deploy Interfaces
uses: ./.github/actions/deploy-interfaces
if: ${{ github.event_name != 'pull_request' }}
with:
environment: 'staging'
force: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' }}
token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}
- name: Deploy Integrations
uses: ./.github/actions/deploy-integrations
with:
environment: 'staging'
extra_filter: "-F '!chat'"
force: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' }}
dry_run: ${{ github.event_name == 'pull_request' }}
token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}
shopify_admin_automation_token: ${{ secrets.STAGING_SHOPIFY_ADMIN_AUTOMATION_TOKEN }}
shopify_storefront_automation_token: ${{ secrets.STAGING_SHOPIFY_STOREFRONT_AUTOMATION_TOKEN }}
- name: Deploy Plugins
uses: ./.github/actions/deploy-plugins
if: ${{ github.event_name != 'pull_request' }}
with:
environment: 'staging'
force: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' }}
token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}
+89
View File
@@ -0,0 +1,89 @@
name: Check Chat Integration Docker
on:
pull_request:
branches:
- master
paths:
- 'integrations/chat/**'
- 'packages/sdk/**'
permissions:
id-token: write
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
build-and-test:
runs-on: depot-ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Build Docker image
uses: depot/build-push-action@v1
with:
project: ${{ secrets.DEPOT_PROJECT_ID }}
build-args: |
MINIFY=true
file: ./integrations/chat/Dockerfile
context: .
push: false
load: true
tags: chat-integration:test
- name: Start Docker container
run: |
docker run -d \
--name chat-test \
-p 8081:8081 \
-e SECRET_SIGNAL_URL=https://chat.botpress.dev \
chat-integration:test
- name: Wait for container to be ready
run: |
echo "Waiting for container to start..."
for i in {1..30}; do
if docker ps --filter "name=chat-test" --filter "status=running" | grep -q chat-test; then
echo "Container is running"
sleep 5
exit 0
fi
if docker ps -a --filter "name=chat-test" --filter "status=exited" | grep -q chat-test; then
echo "Container exited prematurely!"
docker logs chat-test
exit 1
fi
echo "Waiting for container... ($i/30)"
sleep 2
done
echo "Container did not start within expected time"
docker ps -a
exit 1
- name: Check health endpoint
run: |
echo "Checking /health endpoint..."
for i in {1..15}; do
if curl -f http://localhost:8081/health; then
echo "Health check passed!"
exit 0
fi
echo "Attempt $i/15 failed, retrying..."
sleep 2
done
echo "Health check failed after 15 attempts"
exit 1
- name: Show container logs on failure
if: failure()
run: docker logs chat-test
- name: Cleanup container
if: always()
run: docker rm -f chat-test || true
@@ -0,0 +1,78 @@
name: Production version matches master
on:
schedule:
- cron: '0 8 * * 1-5'
workflow_dispatch: # optional manual trigger
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- name: Login to Botpress
env:
TOKEN: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
WORKSPACE_ID: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
run: pnpm bp login -y --token "$TOKEN" --workspace-id "$WORKSPACE_ID"
- name: Check integration versions
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WORKFLOW_SEAGULL_WEBHOOK_URL }}
run: |
SKIP_INTEGRATIONS=("chat" "docusign" "zendesk-messaging-hitl")
integrations=$(find integrations -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n1 basename | sort -u)
should_fail=0
outdated_integrations=""
skipped_integrations=""
for integration in $integrations; do
# Check if integration should be skipped
skip=0
for skip_integration in "${SKIP_INTEGRATIONS[@]}"; do
if [ "$integration" = "$skip_integration" ]; then
echo "Skipping $integration"
skip=1
skipped_integrations="$skipped_integrations> $integration\n"
break
fi
done
if [ $skip -eq 1 ]; then
continue
fi
echo "Checking $integration"
exists=$(.github/scripts/integration-exists.sh "$integration")
if [ "$exists" -eq 0 ]; then
echo "Integration $integration is not up to date. Please deploy the latest version of your integration."
should_fail=1
outdated_integrations="$outdated_integrations> $integration\n"
fi
done
if [ $should_fail -eq 1 ]; then
message="\n\nThe following integrations are out of date:\n$outdated_integrations"
message="$message\nThe following integrations were skipped:\n$skipped_integrations"
message="$message\nPlease run the deployment for the out-of-date integrations in ${SERVER_URL}/${REPO}"
echo -e "\n\nSending curl request to Slack webhook"
echo "Response:"
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{
"key": "prod-master-version-verification",
"text": "'"$message"'"
}'
echo -e "\nCurl request sent"
echo -e "$message"
exit 1
fi
+65
View File
@@ -0,0 +1,65 @@
name: Publish Packages
on:
workflow_dispatch: {}
push:
branches:
- master
permissions:
id-token: write
contents: read
jobs:
publish-packages:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/* -F llmz -F @bpinternal/zui'
- name: update npm
shell: bash
run: npm install -g npm@^11.5.1 # Required for OIDC login
- name: Publish Zui
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/zui'
- name: Publish Client
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/client'
- name: Publish SDK
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/sdk'
- name: Publish CLI
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/cli'
- name: Publish Chat Client
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/chat-client'
- name: Publish Cognitive Client
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/cognitive'
- name: Publish Vai
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/vai'
- name: Publish Zai
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/zai'
- name: Publish LLMz
uses: botpress/gh-actions/publish-if-not-exists@master
with:
path: './packages/llmz'
+29
View File
@@ -0,0 +1,29 @@
name: Purge Cache
on:
workflow_dispatch: {}
jobs:
purge-cache:
runs-on: ubuntu-latest
steps:
- name: Delete Cache Entries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache keys for branch '$BRANCH'"
cacheKeysForBranch=$(gh actions-cache list --limit 100 -R "$REPO" -B "$BRANCH" | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting cache entries..."
for cacheKey in $cacheKeysForBranch
do
echo "Deleting entry: $cacheKey"
gh actions-cache delete "$cacheKey" -R "$REPO" -B "$BRANCH" --confirm
done
echo "Done"
@@ -0,0 +1,43 @@
name: Refresh Instagram Access Tokens Production
on:
schedule:
# Run at 10:00 AM EST (15:00 UTC) on the first Tuesday of every month
- cron: '0 15 1-7 * 2'
workflow_dispatch:
jobs:
refresh-tokens:
runs-on: depot-ubuntu-22.04-8
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpresshub/instagram'
- name: Refresh Instagram Access Tokens
uses: ./.github/actions/refresh-instagram-tokens
with:
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}
api_url: https://api.botpress.cloud
current_token: ${{ secrets.PRODUCTION_INSTAGRAM_ACCESS_TOKEN }}
secret_name: PRODUCTION_INSTAGRAM_ACCESS_TOKEN
sync_secret_app_id: ${{ vars.SYNC_SECRET_APP_ID }}
sync_secret_app_private_key: ${{ secrets.SYNC_SECRET_APP_PRIVATE_KEY }}
ping-success:
runs-on: depot-ubuntu-22.04-8
needs: [refresh-tokens]
steps:
- env:
PING_URL: ${{ secrets.INSTAGRAM_REFRESH_TOKEN_PING_URL }}
run: curl -m 10 --retry 5 "$PING_URL"
ping-failure:
runs-on: depot-ubuntu-22.04-8
if: ${{ failure() }}
needs: [refresh-tokens]
steps:
- env:
PING_URL: ${{ secrets.INSTAGRAM_REFRESH_TOKEN_PING_URL }}
run: curl -m 10 --retry 5 "$PING_URL/fail"
@@ -0,0 +1,43 @@
name: Refresh Instagram Access Tokens Staging
on:
schedule:
# Run at 10:00 AM EST (15:00 UTC) on the first Tuesday of every month
- cron: '0 15 1-7 * 2'
workflow_dispatch:
jobs:
refresh-tokens:
runs-on: depot-ubuntu-22.04-8
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpresshub/instagram'
- name: Refresh Instagram Access Tokens
uses: ./.github/actions/refresh-instagram-tokens
with:
token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}
api_url: https://api.botpress.dev
current_token: ${{ secrets.STAGING_INSTAGRAM_ACCESS_TOKEN }}
secret_name: STAGING_INSTAGRAM_ACCESS_TOKEN
sync_secret_app_id: ${{ vars.SYNC_SECRET_APP_ID }}
sync_secret_app_private_key: ${{ secrets.SYNC_SECRET_APP_PRIVATE_KEY }}
ping-success:
runs-on: depot-ubuntu-22.04-8
needs: [refresh-tokens]
steps:
- env:
PING_URL: ${{ secrets.STAGING_INSTAGRAM_REFRESH_TOKEN_PING_URL }}
run: curl -m 10 --retry 5 "$PING_URL"
ping-failure:
runs-on: depot-ubuntu-22.04-8
if: ${{ failure() }}
needs: [refresh-tokens]
steps:
- env:
PING_URL: ${{ secrets.STAGING_INSTAGRAM_REFRESH_TOKEN_PING_URL }}
run: curl -m 10 --retry 5 "$PING_URL/fail"
+32
View File
@@ -0,0 +1,32 @@
name: Run Chat Tests
on: pull_request
permissions:
id-token: write
contents: read
jobs:
run-chat-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpresshub/chat -F @bp-bots/echo -F @botpress/chat'
- name: Install tilt
run: curl -k -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash
- name: Run Tilt
env:
BP_TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
tilt ci -f ./echo.Tiltfile -- \
--skip-install \
--skip-build \
--bp-domain 'botpress.dev' \
--bp-workspace-id '95de33eb-1551-4af9-9088-e5dcb02efd09' \
--bp-token "$BP_TOKEN"
+26
View File
@@ -0,0 +1,26 @@
name: Run Checks
on: pull_request
permissions:
id-token: write
contents: read
jobs:
run-checks:
runs-on: depot-ubuntu-22.04-8
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
- run: pnpm run check:dep
- run: pnpm run check:sherif
- run: pnpm run check:oxlint
- run: pnpm run check:format
- run: pnpm run check:eslint
- run: pnpm run check:bplint
- run: pnpm run check:type
- run: pnpm run test
@@ -0,0 +1,29 @@
name: Run CLI Tests Windows
on:
pull_request: {}
workflow_dispatch: {}
permissions:
id-token: write
contents: read
jobs:
run-cli-tests-windows:
runs-on: windows-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/cli'
- env:
WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
# shellcheck disable=all
pnpm run -F cli test:e2e -v --api-url https://api.botpress.dev --workspace-id $env:WORKSPACE_ID --workspace-handle clitests --token $env:TOKEN --sdk-path "$(pwd)/packages/sdk" --client-path "$(pwd)/packages/client"
shell: pwsh
+33
View File
@@ -0,0 +1,33 @@
name: Run CLI Tests
on:
pull_request: {}
workflow_dispatch: {}
permissions:
id-token: write
contents: read
jobs:
run-cli-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/cli'
- env:
WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
pnpm run -F cli test:e2e -v \
--api-url https://api.botpress.dev \
--workspace-id "$WORKSPACE_ID" \
--workspace-handle clitests \
--token "$TOKEN" \
--sdk-path "$(pwd)/packages/sdk" \
--client-path "$(pwd)/packages/client"
+23
View File
@@ -0,0 +1,23 @@
name: Run Client Tests
on: pull_request
permissions:
id-token: write
contents: read
jobs:
run-client-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/client'
- run: |
# pnpm -F client exec puppeteer browsers install chrome
pnpm -F client run test:e2e
+49
View File
@@ -0,0 +1,49 @@
name: Run Cognitive Tests
on:
pull_request:
paths:
- 'packages/cognitive/**'
- '.github/workflows/run-cognitive-tests.yml'
permissions:
id-token: write
contents: read
jobs:
run-cognitive-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/cognitive'
- name: Login
env:
BP_API_URL: 'https://api.botpress.dev'
BP_WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
BP_TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
pnpm bp login
- name: Run Cognitive Tests
id: 'run-tests'
env:
CLOUD_PAT: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
bot_id=$(pnpm bp bots new --json | jq -r ".id")
echo "bot_id=$bot_id" >> "$GITHUB_OUTPUT"
export CLOUD_API_ENDPOINT='https://api.botpress.dev'
export CLOUD_BOT_ID="$bot_id"
pnpm -F cognitive run test:e2e
- name: Cleanup
if: ${{ always() }}
env:
BOT_ID: ${{ steps.run-tests.outputs.bot_id }}
run: |
pnpm bp bots rm "$BOT_ID"
+49
View File
@@ -0,0 +1,49 @@
name: Run LLMz Tests
on:
pull_request:
paths:
- 'packages/llmz/**'
- '.github/workflows/run-llmz-tests.yml'
permissions:
id-token: write
contents: read
jobs:
run-llmz-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F llmz'
- name: Login
env:
BP_API_URL: 'https://api.botpress.dev'
BP_WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
BP_TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
pnpm bp login
- name: Run LLMz Tests
id: 'run-tests'
env:
CLOUD_PAT: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
bot_id=$(pnpm bp bots new --json | jq -r ".id")
echo "bot_id=$bot_id" >> "$GITHUB_OUTPUT"
export CLOUD_API_ENDPOINT='https://api.botpress.dev'
export CLOUD_BOT_ID="$bot_id"
pnpm -F llmz run test:e2e
- name: Cleanup
if: ${{ always() }}
env:
BOT_ID: ${{ steps.run-tests.outputs.bot_id }}
run: |
pnpm bp bots rm "$BOT_ID"
+49
View File
@@ -0,0 +1,49 @@
name: Run Vai Tests
on:
pull_request:
paths:
- 'packages/vai/**'
- '.github/workflows/run-vai-tests.yml'
permissions:
id-token: write
contents: read
jobs:
run-vai-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/vai'
- name: Login
env:
BP_API_URL: 'https://api.botpress.dev'
BP_WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
BP_TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
pnpm bp login
- name: Run Vai Tests
id: 'run-tests'
env:
CLOUD_PAT: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
bot_id=$(pnpm bp bots new --json | jq -r ".id")
echo "bot_id=$bot_id" >> "$GITHUB_OUTPUT"
export CLOUD_API_ENDPOINT='https://api.botpress.dev'
export CLOUD_BOT_ID="$bot_id"
pnpm -F vai run test:e2e
- name: Cleanup
if: ${{ always() }}
env:
BOT_ID: ${{ steps.run-tests.outputs.bot_id }}
run: |
pnpm bp bots rm "$BOT_ID"
+49
View File
@@ -0,0 +1,49 @@
name: Run Zai Tests
on:
pull_request:
paths:
- 'packages/zai/**'
- '.github/workflows/run-zai-tests.yml'
permissions:
id-token: write
contents: read
jobs:
run-zai-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: '--max_old_space_size=8192'
steps:
- uses: actions/checkout@v2
- name: Setup
uses: ./.github/actions/setup
with:
extra_filters: '-F @botpress/zai'
- name: Login
env:
BP_API_URL: 'https://api.botpress.dev'
BP_WORKSPACE_ID: ${{ secrets.STAGING_E2E_TESTS_WORKSPACE_ID }}
BP_TOKEN: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
pnpm bp login
- name: Run Zai Tests
id: 'run-tests'
env:
CLOUD_PAT: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}
run: |
bot_id=$(pnpm bp bots new --json | jq -r ".id")
echo "bot_id=$bot_id" >> "$GITHUB_OUTPUT"
export CLOUD_API_ENDPOINT='https://api.botpress.dev'
export CLOUD_BOT_ID="$bot_id"
pnpm -F zai run test:e2e
- name: Cleanup
if: ${{ always() }}
env:
BOT_ID: ${{ steps.run-tests.outputs.bot_id }}
run: |
pnpm bp bots rm "$BOT_ID"
+24
View File
@@ -0,0 +1,24 @@
node_modules
bp_modules
dist
gen
local/data
.DS_Store
*.tsbuildinfo
__snapshots__
.ignore.me.*
.env*
.botpress
.botpresshome
.botpresshome.*
.shopify
.turbo
.genenv/
.genenv.*
tilt_config.json
/.idea
hubspot.config.yml
AGENTS.md
CLAUDE.md
lefthook.yml
.claude
+45
View File
@@ -0,0 +1,45 @@
{
"strictness": 2,
"commentTypes": ["info", "logic", "syntax"],
"triggerOnUpdates": false,
"shouldUpdateDescription": false,
"updateExistingSummaryComment": true,
"summarySection": {
"included": true,
"collapsible": true,
"defaultOpen": false
},
"issuesTableSection": {
"included": true,
"collapsible": true,
"defaultOpen": false
},
"confidenceScoreSection": {
"included": true,
"collapsible": true,
"defaultOpen": false
},
"sequenceDiagramSection": {
"included": true,
"collapsible": true,
"defaultOpen": false
},
"rules": [
{
"id": "error-handling",
"rule": "All async functions must use try-catch blocks."
},
{
"id": "promise-handling",
"rule": "All promises must be awaited."
},
{
"id": "catch-handling",
"rule": "All catch clauses must not use `any` as the error type (implicit or explicit). Instead, type the caught value as `unknown` and narrow it before use.\nThe following snippet is an example solution for this problem:\n\n```Typescript\ntry {\n ...\n} catch (thrown: unknown) {\n const error = thrown instanceof Error ? thrown : new Error(String(thrown))\n}\n```"
},
{
"id": "configure-integration-identifier-type",
"rule": "When choosing the identifier of an integration, it should be something unique and if there is an extract script, it should match the value a global webhook extracts. For instance, if the global webhook extracts a `hubId`, then the identifier should be the `hubId` and not the `webhookId`."
}
]
}
+1
View File
@@ -0,0 +1 @@
pnpx lint-staged
+1
View File
@@ -0,0 +1 @@
pnpm check
+2
View File
@@ -0,0 +1,2 @@
link-workspace-packages=true
+140
View File
@@ -0,0 +1,140 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc", "import", "promise", "vitest"],
"env": {
"browser": true,
"es2024": true,
"node": true,
"builtin": true
},
"globals": {},
"settings": {},
"ignorePatterns": [
".git/",
"**/*.d.ts",
"**/*.test.ts",
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.md.ts",
"**/cdk.out/",
"**/dist/",
"**/node_modules/",
"**/bp_modules/",
"**/.botpress/",
"**/gen/",
"**/bp_modules/",
"**/.turbo/",
"**/.genenv/",
"packages/llmz/examples/",
"node_modules/",
"**/.adk/",
"**/.ignore.me.*"
],
"rules": {},
"overrides": [
{
"files": ["**/*.{ts,tsx}"],
"jsPlugins": ["eslint-plugin-prettier", "@stylistic/eslint-plugin"],
"rules": {
"eslint/no-console": [
"warn",
{
"allow": [
"warn",
"dir",
"time",
"timeEnd",
"timeLog",
"trace",
"assert",
"clear",
"count",
"countReset",
"group",
"groupEnd",
"table",
"debug",
"info",
"dirxml",
"error",
"groupCollapsed",
"Console",
"profile",
"profileEnd",
"timeStamp",
"context"
]
}
],
"eslint/no-cond-assign": "error",
"eslint/no-const-assign": "error",
"eslint/no-debugger": "error",
"eslint/no-sparse-arrays": "error",
"eslint/no-unreachable": "error",
"eslint/max-lines-per-function": "off",
"eslint/default-case": "off",
"eslint/default-case-last": "error",
"eslint/max-depth": "error",
"eslint/no-eval": "error",
"eslint/no-return-assign": "error",
"eslint/no-var": "error",
"eslint/no-duplicate-imports": "error",
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/prefer-namespace-keyword": "error",
"eslint/curly": ["error", "multi-line"],
"eslint/eqeqeq": ["error", "smart"],
"typescript/no-unused-vars": "off",
"eslint/no-unused-vars": [
"error",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"unicorn/no-empty-file": "error",
"unicorn/no-lonely-if": "off",
"prettier/prettier": "error",
"arrow-body-style": "off",
"complexity": ["off"],
"prefer-const": "warn",
"@stylistic/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "none",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@stylistic/quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"@stylistic/semi": ["error", "never"],
"@stylistic/type-annotation-spacing": "error",
"@stylistic/brace-style": "off",
"@stylistic/eol-last": "error",
"@stylistic/linebreak-style": ["error", "unix"],
"@stylistic/no-trailing-spaces": "error",
"no-shadow": "off"
}
}
],
"categories": {
"correctness": "off",
"pedantic": "off",
"perf": "off",
"suspicious": "off"
}
}
+30
View File
@@ -0,0 +1,30 @@
# based on https://gist.github.com/thinkricardo/74f37d82b686de371b0853a5d66d559c
# ignore all files
*
# include all folders
!**/
# include files to format
!*.ts
!*.tsx
!*.json
!*.yaml
!*.yml
!*.md
!*.mjs
# exclusions
# **/*.d.ts
pnpm-lock.yaml
gen
dist
.botpress
.botpresshome
.botpresshome.*
bp_modules
.genenv/
.genenv.*
.turbo
*.md.ts
+9
View File
@@ -0,0 +1,9 @@
{
"bracketSpacing": true,
"printWidth": 120,
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"endOfLine": "lf"
}
+3
View File
@@ -0,0 +1,3 @@
pnpm 10.29.3
nodejs 22.17.0
tilt 0.35.0
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "oxc.oxc-vscode", "vitest.explorer"]
}
+52
View File
@@ -0,0 +1,52 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug TS File",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
"args": ["${file}"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"],
"envFile": "${workspaceFolder}/.env"
},
{
"type": "node",
"request": "launch",
"name": "Debug Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${fileBasename}"],
"cwd": "${fileDirname}",
"smartStep": true,
"console": "integratedTerminal"
},
{
"name": "Debug CLI",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"args": ["${workspaceRoot}/packages/cli", "${input:cli-command}"],
"cwd": "${workspaceRoot}",
"sourceMaps": true,
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "node_modules/**"],
"envFile": "${workspaceFolder}/.env"
}
],
"inputs": [
{
"id": "cli-command",
"type": "pickString",
"description": "Select a command to run",
"options": ["login", "logout", "init", "generate", "bundle", "build", "serve", "deploy"]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.tabSize": 2,
"git.branchProtection": ["master"],
"vitest.disableWorkspaceWarning": true,
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
+42
View File
@@ -0,0 +1,42 @@
# Global
* @botpress/swordfish
# Packages
## API / Client / CLI / SDK
/packages/chat-api @botpress/kraken
/packages/chat-client @botpress/kraken
/packages/client @botpress/kraken
/packages/cli @botpress/kraken
/packages/sdk @botpress/kraken
/packages/zui @botpress/swordfish
## Common / Utils
/packages/common @botpress/shell
/packages/sdk-addons @botpress/shell
## AI
/packages/cognitive @botpress/dragon
/packages/llmz @slvnperron @botpress/orca @botpress/swordfish
/packages/vai @slvnperron @botpress/orca @botpress/swordfish
/packages/zai @slvnperron @botpress/orca @botpress/swordfish
# Bots
/bots @botpress/shell
# Plugins
/plugins @botpress/shell
# Integrations
/integrations @botpress/shell
## LLM
/integrations/anthropic @botpress/dragon
/integrations/cerebras @botpress/dragon
/integrations/fireworks-ai @botpress/dragon
/integrations/google-ai @botpress/dragon
/integrations/groq @botpress/dragon
/integrations/openai @botpress/dragon
## Chat
/integrations/chat @botpress/kraken
+7
View File
@@ -0,0 +1,7 @@
Copyright 2023 Botpress Technologies, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`botpress/botpress`
- 原始仓库:https://github.com/botpress/botpress
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+40
View File
@@ -0,0 +1,40 @@
# Security Policy
## Reporting a vulnerability
Do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately through [GitHub private vulnerability reporting](https://github.com/botpress/botpress/security/advisories/new). If you can't use GitHub, email [security@botpress.com](mailto:security@botpress.com).
Please include as much of the following as you can:
- The type of issue (e.g. sandbox escape, injection, authentication bypass, credential exposure)
- The affected package(s) and version(s), or the affected integration
- Step-by-step instructions to reproduce, with a proof of concept if possible
- The impact as you understand it, including how an attacker might exploit it
### What to expect
- We will acknowledge your report within a reasonable time.
- We will keep you informed as we triage, confirm, and fix the issue.
- We coordinate disclosure with you. We aim to fix confirmed vulnerabilities within 90 days of the initial report. Once a fix is released, we publish a GitHub security advisory and credit you for the discovery unless you prefer to stay anonymous. If we need more time, we will communicate this and work out a mutually agreed extension before any disclosure.
## Scope
This repository contains the Botpress SDK, CLI, client libraries, and the source of official integrations and plugins. Reports are in scope if the vulnerability is in code that lives here. Only the latest published version of each package receives security fixes.
### Out of scope
- The Botpress Cloud platform and its infrastructure (app.botpress.cloud, APIs, webchat hosting). Report those to [security@botpress.com](mailto:security@botpress.com) instead.
- Bots and agents built by Botpress users
- Vulnerabilities in third-party services that integrations connect to (report those to the respective vendor)
- Vulnerabilities in dependencies with no demonstrated exploit path through this codebase (report those upstream)
- Denial of service through volume, brute force, or resource exhaustion
- Social engineering, phishing, and physical attacks
- Prompt injection that only affects the output quality of a user's own bot. Prompt injection that crosses a security boundary (unauthorized tool execution, data exfiltration, sandbox escape) is in scope.
Botpress v12 lives in a separate repository ([botpress/v12](https://github.com/botpress/v12)) and is not covered by this policy.
## Safe harbor
We consider security research conducted in line with this policy to be authorized. We will not pursue legal action for good-faith research that stays in scope, avoids privacy violations and service disruption, and gives us reasonable time to fix issues before public disclosure.
+22
View File
@@ -0,0 +1,22 @@
local_resource(
name='install',
cmd="pnpm install"
)
local_resource(
name='build',
cmd="pnpm build",
deps=['install'],
)
local_resource(
name='check',
cmd="pnpm check",
deps=['install', 'build'],
)
local_resource(
name='test',
cmd="pnpm test",
deps=['install', 'build'],
)
+149
View File
@@ -0,0 +1,149 @@
import * as sdk from '@botpress/sdk'
import * as genenv from './.genenv'
import github from './bp_modules/github'
import linear from './bp_modules/linear'
import slack from './bp_modules/slack'
import telegram from './bp_modules/telegram'
// TODO: use default options
const toJSONSchemaOptions: Partial<sdk.z.transforms.JSONSchemaGenerationOptions> = {
discriminatedUnionStrategy: 'anyOf',
discriminator: false,
}
export default new sdk.BotDefinition({
states: {
watchedTeams: {
type: 'bot',
schema: sdk.z.object({
teamKeys: sdk.z
.array(sdk.z.string())
.title('Team Keys')
.describe('The keys of the teams for which BugBuster should lint issues'),
}),
},
lastLintedId: {
type: 'workflow',
schema: sdk.z.object({
id: sdk.z.string().optional().title('ID').describe('The ID of the last successfully linted issue'),
}),
},
lintResults: {
type: 'workflow',
schema: sdk.z.object({
issues: sdk.z.array(
sdk.z.discriminatedUnion('result', [
sdk.z.object({
identifier: sdk.z.string().title('Identifier').describe('The issue identifier'),
result: sdk.z.literal('failed').title('Result').describe('The lint result'),
messages: sdk.z.array(sdk.z.string()).title('Messages').describe('The lint error messages'),
}),
sdk.z.object({
identifier: sdk.z.string().title('Identifier').describe('The issue identifier'),
result: sdk.z.enum(['succeeded', 'ignored']).title('Result').describe('The lint result'),
}),
])
),
}),
},
notificationChannels: {
type: 'bot',
schema: sdk.z.object({
channels: sdk.z
.array(
sdk.z.object({
conversationId: sdk.z.string().title('Conversation ID').describe('The conversation ID'),
name: sdk.z.string().title('Name').describe('The channel name'),
teams: sdk.z
.array(sdk.z.string())
.title('Teams')
.describe('The teams for which notifications will be sent to the channel'),
})
)
.title('Channel')
.describe('The Slack channel where notifications will be sent'),
}),
},
},
workflows: {
lintAll: {
input: {
schema: sdk.z.object({
verbose: sdk.z
.boolean()
.optional()
.title('Verbose')
.describe('Post the detailed lint results (per issue) to the Slack channel'),
comment: sdk.z
.boolean()
.optional()
.title('Comment')
.describe('Whether to comment the lint results on the Linear issues themselves (defaults to true)'),
}),
},
output: { schema: sdk.z.object({}) },
},
},
events: {
timeToLintAll: {
schema: sdk.z.object({}),
},
timeToCheckIssuesState: {
schema: sdk.z.object({}),
},
},
recurringEvents: {
timeToLintAll: {
payload: sdk.z.object({}),
type: 'timeToLintAll',
schedule: {
cron: '0 13 * * 1', // runs every week on Monday at 8AM EST
},
},
timeToCheckIssuesState: {
payload: sdk.z.object({}),
type: 'timeToCheckIssuesState',
schedule: {
cron: '0 * * * *', // runs every hour on the hour
},
},
},
__advanced: {
toJSONSchemaOptions,
},
})
.addIntegration(github, {
enabled: true,
configurationType: 'manualPAT',
configuration: {
personalAccessToken: genenv.BUGBUSTER_GITHUB_TOKEN,
githubWebhookSecret: genenv.BUGBUSTER_GITHUB_WEBHOOK_SECRET,
},
})
.addIntegration(telegram, {
enabled: true,
configurationType: null,
configuration: {
botToken: genenv.BUGBUSTER_TELEGRAM_BOT_TOKEN,
typingIndicatorEmoji: true,
},
})
.addIntegration(linear, {
enabled: true,
configurationType: 'apiKey',
configuration: {
apiKey: genenv.BUGBUSTER_LINEAR_API_KEY,
webhookSigningSecret: genenv.BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET,
},
})
.addIntegration(slack, {
enabled: true,
configurationType: null,
configuration: {
typingIndicatorEmoji: false,
replyBehaviour: {
location: 'channel',
onlyOnBotMention: false,
},
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@bp-bots/bugbuster",
"scripts": {
"postinstall": "genenv -o ./.genenv/index.ts -e BUGBUSTER_GITHUB_TOKEN -e BUGBUSTER_GITHUB_WEBHOOK_SECRET -e BUGBUSTER_LINEAR_API_KEY -e BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET -e BUGBUSTER_TELEGRAM_BOT_TOKEN",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/github": "workspace:*",
"@botpresshub/linear": "workspace:*",
"@botpresshub/slack": "workspace:*",
"@botpresshub/telegram": "workspace:*",
"@bpinternal/genenv": "0.0.1"
},
"bpDependencies": {
"github": "../../integrations/github",
"linear": "../../integrations/linear",
"slack": "../../integrations/slack",
"telegram": "../../integrations/telegram"
}
}
+20
View File
@@ -0,0 +1,20 @@
# Bugbuster
A simple bot built on top of the Botpress [SDK](https://botpress.com/docs/integrations/sdk/overview) that lints Linear issues and synchronizes them with GitHub issues.
<img src="./bugbuster.png" />
## Linear
Once connected to your Linear workspace, the bot comments on newly created or updated issues when linting errors are detected, and automatically resolves those comments once the linting errors are fixed.
The following linting rules are applied (among others):
- An issue must have a title, description, priority, and estimate
- An issue must be linked to a project or have a goal tag
- If an issue is in the _Blocked_ state, it must have either a blocking reason tag or a blocking issue
- If an issue is in the _In Progress_ state, it must have an assignee
## GitHub
Once connected to your GitHub repository, the bot detects new issues and automatically creates a corresponding Linear issue in the Triage state, including all relevant information.
+32
View File
@@ -0,0 +1,32 @@
import { CommandProcessor } from './services/command-processor'
import { IssueProcessor } from './services/issue-processor'
import { IssueStateChecker } from './services/issue-state-checker'
import { RecentlyLintedManager } from './services/recently-linted-manager'
import { StateService } from './services/state-service'
import { TeamsManager } from './services/teams-manager'
import * as types from './types'
import * as utils from './utils'
export const bootstrap = (props: types.CommonHandlerProps) => {
const { client, logger, ctx } = props
const botpress = utils.botpress.BotpressApi.create(props)
const linear = utils.linear.LinearApi.create(client)
const stateService = new StateService(linear)
const teamsManager = new TeamsManager(linear, client, ctx.botId)
const recentlyLintedManager = new RecentlyLintedManager(linear)
const issueProcessor = new IssueProcessor(logger, linear, stateService, teamsManager, ctx.botId)
const issueStateChecker = new IssueStateChecker(linear, stateService, logger, ctx.botId)
const commandProcessor = new CommandProcessor(client, teamsManager, ctx.botId)
return {
botpress,
linear,
stateService,
teamsManager,
recentlyLintedManager,
issueProcessor,
issueStateChecker,
commandProcessor,
}
}
@@ -0,0 +1,42 @@
import * as boot from '../bootstrap'
import * as bp from '.botpress'
const TEAM_NAME_FOR_NEW_ISSUES = 'Engineering'
export const handleGithubIssueOpened: bp.EventHandlers['github:issueOpened'] = async (props): Promise<void> => {
const githubIssue = props.event.payload
props.logger.info('Received GitHub issue', githubIssue)
const { botpress, linear } = boot.bootstrap(props)
const _handleError =
(context: string) =>
(thrown: unknown): Promise<never> =>
botpress.handleError({ context, conversationId: undefined }, thrown)
const linearResponse = await props.client
.callAction({
type: 'linear:createIssue',
input: {
teamName: TEAM_NAME_FOR_NEW_ISSUES,
description: githubIssue.issue.body,
title: githubIssue.issue.name,
},
})
.catch(_handleError('trying to create a Linear issue from the GitHub issue'))
const comment = [
'This issue was created from GitHub by BugBuster Bot.',
'',
`GitHub Issue: [${githubIssue.issue.name}](${githubIssue.issue.url})`,
].join('\n')
await linear
.createComment({
body: comment,
issueId: linearResponse.output.issue.id,
botId: props.ctx.botId,
})
.catch(_handleError('trying to create a comment on the Linear issue created from GitHub'))
}
+7
View File
@@ -0,0 +1,7 @@
export * from './github-issue-opened'
export * from './linear-issue-updated'
export * from './linear-issue-created'
export * from './message-created'
export * from './lint-all'
export * from './time-to-lint-all'
export * from './time-to-check-issues-state'
@@ -0,0 +1,22 @@
import * as boot from '../bootstrap'
import * as bp from '.botpress'
export const handleLinearIssueCreated: bp.EventHandlers['linear:issueCreated'] = async (props) => {
const { event } = props
const { number: issueNumber, teamKey } = event.payload
const { botpress, issueProcessor } = boot.bootstrap(props)
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
props.logger.info('Linear issue created event received', `${teamKey}-${issueNumber}`)
const issue = await issueProcessor
.findIssue(issueNumber, teamKey)
.catch(_handleError('trying to find the created Linear issue'))
if (!issue) {
return
}
await issueProcessor.lintIssue(issue).catch(_handleError('trying to lint the created Linear issue'))
}
@@ -0,0 +1,25 @@
import * as boot from '../bootstrap'
import * as bp from '.botpress'
export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = async (props) => {
const { event, logger } = props
const { number: issueNumber, teamKey } = event.payload
const { botpress, issueProcessor, recentlyLintedManager } = boot.bootstrap(props)
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
logger.info('Linear issue updated event received', `${teamKey}-${issueNumber}`)
const issue = await issueProcessor
.findIssue(issueNumber, teamKey)
.catch(_handleError('trying to find the updated Linear issue'))
if (!issue) {
return
}
const isRecentlyLinted = await recentlyLintedManager
.isRecentlyLinted(issue)
.catch(_handleError('trying to get recently linted issues'))
await issueProcessor.lintIssue(issue, isRecentlyLinted).catch(_handleError('trying to lint the updated Linear issue'))
}
+152
View File
@@ -0,0 +1,152 @@
import * as types from 'src/types'
import * as boot from '../bootstrap'
import * as bp from '.botpress'
const LINEAR_ISSUE_BASE_URL = 'https://linear.app/botpress/issue/'
export const handleLintAll: bp.WorkflowHandlers['lintAll'] = async (props) => {
const { client, workflow, ctx, conversation } = props
const verbose = workflow.input.verbose ?? false
const comment = workflow.input.comment ?? true
const { botpress, issueProcessor } = boot.bootstrap(props)
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
const {
state: {
payload: { id: lastLintedId },
},
} = await client
.getOrSetState({
id: workflow.id,
name: 'lastLintedId',
type: 'workflow',
payload: {},
})
.catch(_handleError('trying to get last linted issue ID'))
const {
state: {
payload: { issues: lintResults },
},
} = await client
.getOrSetState({
id: workflow.id,
name: 'lintResults',
type: 'workflow',
payload: { issues: [] },
})
.catch(_handleError('trying to get previous lint results'))
let hasNextPage = false
let endCursor: string | undefined = lastLintedId
do {
const pagedIssues = await issueProcessor
.listRelevantIssues(endCursor)
.catch(_handleError('trying to list all issues'))
const pageResults: types.LintResult[] = []
for (const issue of pagedIssues.issues) {
const lintResult = await issueProcessor
.lintIssue(issue, undefined, { comment })
.catch(_handleError(`trying to lint issue ${issue.identifier}`))
lintResults.push(lintResult)
pageResults.push(lintResult)
await workflow.acknowledgeStartOfProcessing().catch(_handleError('trying to acknowledge start of processing'))
endCursor = issue.id
await Promise.all([
client
.setState({
id: workflow.id,
name: 'lastLintedId',
type: 'workflow',
payload: { id: endCursor },
})
.catch(_handleError('trying to update last linted issue ID')),
client
.setState({
id: workflow.id,
name: 'lintResults',
type: 'workflow',
payload: { issues: lintResults },
})
.catch(_handleError('trying to update lint results')),
])
}
hasNextPage = pagedIssues.pagination?.hasNextPage ?? false
if (verbose && conversation?.id) {
const failedCount = lintResults.filter((result) => result.result === 'failed').length
const newlyFailed = pageResults.filter((result) => result.result === 'failed')
const progressLine = `Linting... linted ${lintResults.length} issue(s) so far (${failedCount} with errors).`
const failedList = newlyFailed.map((result) => `- ${_issueLink(result.identifier)}`).join('\n')
const message = newlyFailed.length > 0 ? `${progressLine}\n${failedList}` : progressLine
await botpress.respondText(conversation.id, message).catch(() => {})
}
} while (hasNextPage)
if (conversation?.id) {
const message = _buildResultMessage(lintResults)
await botpress.respondText(conversation.id, message).catch(() => {})
await workflow.setCompleted()
return
}
const {
state: {
payload: { channels },
},
} = await client.getOrSetState({
id: ctx.botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: [] },
})
for (const channel of channels) {
const relevantIssues = lintResults.filter((result) =>
channel.teams.some((team) => result.identifier.includes(team))
)
if (relevantIssues.length > 0) {
await botpress.respondText(channel.conversationId, _buildResultMessage(relevantIssues)).catch(() => {})
}
}
await workflow.setCompleted()
}
export const handleLintAllTimeout: bp.WorkflowHandlers['lintAll'] = async (props) => {
const { conversation } = props
const { botpress } = boot.bootstrap(props)
if (conversation?.id) {
await botpress.respondText(conversation.id, "Error: the 'lintAll' operation timed out")
}
}
const _issueLink = (identifier: string) => `[${identifier}](${LINEAR_ISSUE_BASE_URL + identifier})`
const _buildResultMessage = (results: types.LintResult[]) => {
const failedIssuesLinks = results
.filter((result) => result.result === 'failed')
.slice(0, 10) // Limit to 10 issues to avoid spamming the message
.map((result) => _issueLink(result.identifier))
let messageDetail = 'No issue contained lint errors.'
if (failedIssuesLinks.length === 1) {
messageDetail = `This issue contained lint errors: ${failedIssuesLinks[0]}.`
} else if (failedIssuesLinks.length > 1) {
messageDetail = `These issues contained lint errors: ${failedIssuesLinks.join(', ')}.`
}
return `Linting complete. ${messageDetail}`
}
@@ -0,0 +1,74 @@
import { CommandDefinition } from 'src/types'
import * as boot from '../bootstrap'
import * as bp from '.botpress'
const MESSAGING_INTEGRATIONS = ['telegram', 'slack']
export const handleMessageCreated: bp.MessageHandlers['*'] = async (props) => {
const { conversation, message } = props
if (!MESSAGING_INTEGRATIONS.includes(conversation.integration)) {
props.logger.info(`Ignoring message from ${conversation.integration}`)
return
}
if (
conversation.integration === 'slack' &&
(conversation.channel === 'channel' || conversation.channel === 'thread')
) {
return
}
const { botpress, commandProcessor } = boot.bootstrap(props)
const commandListMessage = _buildListCommandsMessage(commandProcessor.commandDefinitions)
if (message.type !== 'text') {
await botpress.respondText(conversation.id, commandListMessage)
return
}
if (!message.payload.text) {
await botpress.respondText(conversation.id, commandListMessage)
return
}
const [command, ...args] = message.payload.text.trim().split(' ')
if (!command) {
await botpress.respondText(conversation.id, commandListMessage)
return
}
const commandDefinition = commandProcessor.commandDefinitions.find((commandImpl) => commandImpl.name === command)
if (!commandDefinition) {
await botpress.respondText(conversation.id, commandListMessage)
return
}
if (commandDefinition.requiredArgs && args.length < commandDefinition.requiredArgs?.length) {
await botpress.respondText(
conversation.id,
`Error: a minimum of ${commandDefinition.requiredArgs.length} argument(s) is required.`
)
return
}
const _handleError = (context: string, thrown: unknown) =>
botpress.handleError({ context, conversationId: conversation.id }, thrown)
try {
const result = await commandDefinition.implementation(args, conversation.id)
await botpress.respondText(conversation.id, `${result.success ? '' : 'Error: '}${result.message}`)
} catch (thrown) {
await _handleError(`trying to run ${commandDefinition.name}`, thrown)
}
}
const _buildListCommandsMessage = (definitions: CommandDefinition[]) => {
const commands = definitions.map(_buildCommandMessage).join('\n')
return `Unknown command. Here's a list of possible commands:\n${commands}`
}
const _buildCommandMessage = (definition: CommandDefinition) => {
const requiredArgs = definition.requiredArgs?.map((arg) => `&lt;${arg}&gt;`).join(' ')
const optionalArgs = definition.optionalArgs?.map((arg) => `[${arg}]`).join(' ')
return `${definition.name} ${requiredArgs ?? ''} ${optionalArgs ?? ''}`
}
@@ -0,0 +1,39 @@
import * as types from 'src/types'
import * as boot from '../bootstrap'
import * as bp from '.botpress'
const statesToProcess: types.StateAttributes[] = [
{
commonStateName: 'STAGING',
maxTimeSinceLastUpdate: '-P1W',
warningComment: 'BugBuster bot detected that this issue has been in staging for over a week',
buildWarningReason: (issueIdentifier) => `Issue ${issueIdentifier} has been in staging for over a week`,
},
{
commonStateName: 'BLOCKED',
maxTimeSinceLastUpdate: '-P1M',
warningComment: 'BugBuster bot detected that this issue has been blocked for over a month',
buildWarningReason: (issueIdentifier) => `Issue ${issueIdentifier} has been blocked for over a month`,
},
]
export const handleTimeToCheckIssuesState: bp.EventHandlers['timeToCheckIssuesState'] = async (props) => {
const { logger } = props
const { botpress, teamsManager, issueStateChecker } = boot.bootstrap(props)
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
logger.info("Validating issues' states...")
const teams = await teamsManager.listWatchedTeams().catch(_handleError('trying to list teams'))
for (const state of statesToProcess) {
await issueStateChecker
.processIssues({
stateAttributes: state,
teams,
})
.catch(_handleError("trying to check issues' states"))
}
logger.info("Finished validating issues' states...")
}
@@ -0,0 +1,20 @@
import * as boot from '../bootstrap'
import * as bp from '.botpress'
export const handleTimeToLintAll: bp.EventHandlers['timeToLintAll'] = async (props) => {
const { client, logger } = props
logger.info("'timeToLintAll' event received.")
const { botpress } = boot.bootstrap(props)
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
await client
.getOrCreateWorkflow({
name: 'lintAll',
input: {},
discriminateByStatusGroup: 'active',
status: 'pending',
})
.catch(_handleError("trying to start the 'lintAll' workflow"))
}
+17
View File
@@ -0,0 +1,17 @@
import * as handlers from './handlers'
import * as bp from '.botpress'
export const bot = new bp.Bot({ actions: {} })
bot.on.event('github:issueOpened', handlers.handleGithubIssueOpened)
bot.on.event('linear:issueUpdated', handlers.handleLinearIssueUpdated)
bot.on.event('linear:issueCreated', handlers.handleLinearIssueCreated)
bot.on.event('timeToLintAll', handlers.handleTimeToLintAll)
bot.on.event('timeToCheckIssuesState', handlers.handleTimeToCheckIssuesState)
bot.on.message('*', handlers.handleMessageCreated)
bot.on.workflowStart('lintAll', handlers.handleLintAll)
bot.on.workflowContinue('lintAll', handlers.handleLintAll)
bot.on.workflowTimeout('lintAll', handlers.handleLintAllTimeout)
export default bot
@@ -0,0 +1,303 @@
import * as types from '../types'
import { TeamsManager } from './teams-manager'
import { Client } from '.botpress'
const MISSING_ARGS_ERROR = 'More arguments are required with this command.'
export class CommandProcessor {
public constructor(
private _client: Client,
private _teamsManager: TeamsManager,
private _botId: string
) {}
private _listTeams: types.CommandImplementation = async () => {
const teams = await this._teamsManager.listWatchedTeams()
const message = teams.length > 0 ? teams.join(', ') : 'You have no watched teams.'
return { success: true, message }
}
private _addTeam: types.CommandImplementation = async ([team]: string[]) => {
if (!team) {
return { success: false, message: MISSING_ARGS_ERROR }
}
await this._teamsManager.addWatchedTeam(team)
return {
success: true,
message: `Success: the team with the key '${team}' has been added to the watched team list.`,
}
}
private _removeTeam: types.CommandImplementation = async ([team]: string[]) => {
if (!team) {
return { success: false, message: MISSING_ARGS_ERROR }
}
await this._teamsManager.removeWatchedTeam(team)
return {
success: true,
message: `Success: the team with the key '${team}' has been removed from the watched team list.`,
}
}
private _lintAll: types.CommandImplementation = async (args: string[], conversationId: string) => {
let verbose = false
let comment = true
for (const arg of args) {
if (arg === '--verbose') {
verbose = true
} else if (arg === '--no-comments') {
comment = false
} else {
return {
success: false,
message: `Unknown argument '${arg}'. Supported arguments are '--verbose' and '--no-comments'.`,
}
}
}
await this._client.getOrCreateWorkflow({
name: 'lintAll',
input: { verbose, comment },
discriminateByStatusGroup: 'active',
conversationId,
status: 'pending',
})
const details = [
verbose ? 'detailed lint results will be posted here' : null,
comment ? null : 'issues will not be commented on Linear',
].filter((detail) => detail !== null)
const message =
details.length > 0 ? `Launched 'lintAll' workflow: ${details.join(', ')}.` : "Launched 'lintAll' workflow."
return { success: true, message }
}
private _addNotifChannel: types.CommandImplementation = async ([channelToAdd, ...teams]: string[]) => {
if (!channelToAdd || !teams[0]) {
return { success: false, message: MISSING_ARGS_ERROR }
}
const {
state: {
payload: { channels },
},
} = await this._client.getOrSetState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: [] },
})
const watchedTeams = await this._teamsManager.listWatchedTeams()
if (!teams.every((team) => watchedTeams.includes(team))) {
return {
success: false,
message: 'make sure every team you want to add is being watched.',
}
}
const existingChannel = channels.find((channel) => channel.name === channelToAdd)
if (!existingChannel) {
const channelId = await this._resolveChannelId(channelToAdd)
if (!channelId) {
return { success: false, message: `Could not find a Slack channel matching '${channelToAdd}'.` }
}
const conversation = await this._client.callAction({
type: 'slack:getOrCreateChannelConversation',
input: { conversation: { channelId } },
})
channels.push({ conversationId: conversation.output.conversationId, name: channelToAdd, teams })
} else {
teams.forEach((team) => {
if (!existingChannel.teams.includes(team)) {
existingChannel.teams.push(team)
}
})
}
await this._client.setState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels },
})
return {
success: true,
message: `Notifications for team(s) ${teams.join(', ')} will be posted in channel ${channelToAdd}.`,
}
}
private async _resolveChannelId(input: string): Promise<string | undefined> {
const { output } = await this._client.callAction({
type: 'slack:findTarget',
input: { query: input, channel: 'channel' },
})
const exact = output.targets.find((t) => t.displayName === input)
const match = exact ?? output.targets[0]
return match?.tags.id
}
private _removeNotifChannel: types.CommandImplementation = async ([channelToRemove]: string[]) => {
if (!channelToRemove) {
return { success: false, message: MISSING_ARGS_ERROR }
}
const {
state: {
payload: { channels },
},
} = await this._client.getOrSetState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: [] },
})
if (!channels.find((channel) => channel.name === channelToRemove)) {
return {
success: false,
message: `channel '${channelToRemove}' is not part of the notification channels.`,
}
}
await this._client.setState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: channels.filter((channel) => channel.name !== channelToRemove) },
})
return {
success: true,
message: `Notification channel ${channelToRemove} has been removed.`,
}
}
private _removeNotifChannelTeam: types.CommandImplementation = async ([channelToRemove, teamToRemove]: string[]) => {
if (!channelToRemove || !teamToRemove) {
return { success: false, message: MISSING_ARGS_ERROR }
}
const {
state: {
payload: { channels },
},
} = await this._client.getOrSetState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: [] },
})
const channel = channels.find((channel) => channel.name === channelToRemove)
if (!channel) {
return {
success: false,
message: `channel '${channelToRemove}' is not part of the notification channels.`,
}
}
if (!channel.teams.find((team) => team === teamToRemove)) {
return {
success: false,
message: `channel ${channel.name} does not receive notifications from team '${teamToRemove}'.`,
}
}
channel.teams = channel.teams.filter((team) => team !== teamToRemove)
await this._client.setState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels },
})
return {
success: true,
message: `Team ${teamToRemove} has been removed from channel ${channelToRemove}.`,
}
}
private _listNotifChannels: types.CommandImplementation = async () => {
const {
state: {
payload: { channels },
},
} = await this._client.getOrSetState({
id: this._botId,
name: 'notificationChannels',
type: 'bot',
payload: { channels: [] },
})
let message = 'There is no set Slack notification channel.'
if (channels.length > 0) {
message = this._buildNotifChannelsMessage(channels)
}
return {
success: true,
message,
}
}
private _buildNotifChannelsMessage(channels: { name: string; teams: string[] }[]) {
return `The Slack notification channels are:\n${channels.map(this._getMessageForChannel).join('\n')}`
}
private _getMessageForChannel(channel: { name: string; teams: string[] }) {
const { name, teams } = channel
return `- channel ${name} for team(s) ${teams.join(', ')}`
}
public commandDefinitions: types.CommandDefinition[] = [
{
name: '#listTeams',
implementation: this._listTeams,
},
{
name: '#addTeam',
implementation: this._addTeam,
requiredArgs: ['teamName'],
},
{
name: '#removeTeam',
implementation: this._removeTeam,
requiredArgs: ['teamName'],
},
{
name: '#lintAll',
implementation: this._lintAll,
optionalArgs: ['--verbose', '--no-comments'],
},
{
name: '#addNotifChannel',
implementation: this._addNotifChannel,
requiredArgs: ['channelName', 'teamName1'],
optionalArgs: ['teamName2 ...'],
},
{
name: '#removeNotifChannel',
implementation: this._removeNotifChannel,
requiredArgs: ['channelName'],
},
{
name: '#removeNotifChannelTeam',
implementation: this._removeNotifChannelTeam,
requiredArgs: ['channelName', 'teamName'],
},
{
name: '#listNotifChannels',
implementation: this._listNotifChannels,
},
]
}
@@ -0,0 +1,109 @@
import * as sdk from '@botpress/sdk'
import * as types from '../../types'
import * as lin from '../../utils/linear-utils'
import { StateService } from '../state-service'
import * as tm from '../teams-manager'
import { lintIssue } from './lint-issue'
const IGNORED_STATES: types.CommonStateName[] = ['TRIAGE', 'BACKLOG', 'DONE', 'CANCELED', 'STALE', 'DUPLICATE']
const LINTIGNORE_LABEL_NAME = 'lintignore'
export class IssueProcessor {
public constructor(
private _logger: sdk.BotLogger,
private _linear: lin.LinearApi,
private _stateService: StateService,
private _teamsManager: tm.TeamsManager,
private _botId: string
) {}
/**
* @returns The corresponding issue, or `undefined` if the issue is not found or not valid.
*/
public async findIssue(issueNumber: number, teamKey: string | undefined): Promise<lin.Issue | undefined> {
if (!issueNumber || !teamKey) {
this._logger.error('Missing issueNumber or teamKey in event payload')
return
}
const watchedTeams = await this._teamsManager.listWatchedTeams()
if (!(await this._linear.isTeam(teamKey)) || !watchedTeams.includes(teamKey)) {
this._logger.info(`Ignoring issue of team "${teamKey}"`)
return
}
const issue = await this._linear.findIssue({ teamKey, issueNumber })
if (!issue) {
this._logger.warn(`Issue with number ${issueNumber} not found in team ${teamKey}`)
return
}
return issue
}
public async listRelevantIssues(endCursor?: string): Promise<{ issues: lin.Issue[]; pagination?: lin.Pagination }> {
const watchedTeams = await this._teamsManager.listWatchedTeams()
if (watchedTeams.length === 0) {
throw new Error('You have no watched teams.')
}
const stateIdsToOmit = await this._stateService.mapToStateIds(IGNORED_STATES)
return await this._linear.listIssues(
{
teamKeys: watchedTeams,
stateIdsToOmit,
},
endCursor
)
}
public async lintIssue(
issue: lin.Issue,
isRecentlyLinted?: boolean,
options?: { comment?: boolean }
): Promise<types.LintResult> {
const shouldComment = options?.comment ?? true
const state = await this._stateService.getIssueCommonStateName(issue)
if (!state) {
this._logger.warn(
`Issue ${issue.identifier} has an unknown state ${issue.state.name}. Ignoring linting for this issue.`
)
return { identifier: issue.identifier, result: 'ignored' }
}
if (IGNORED_STATES.includes(state) || issue.labels.nodes.some((label) => label.name === LINTIGNORE_LABEL_NAME)) {
return { identifier: issue.identifier, result: 'ignored' }
}
const errors = lintIssue(issue, state)
if (errors.length === 0) {
this._logger.info(`Issue ${issue.identifier} passed all lint checks.`)
await this._linear.resolveComments(issue)
return { identifier: issue.identifier, result: 'succeeded' }
}
const warningMessage = `Issue ${issue.identifier} has ${errors.length} lint errors.`
if (isRecentlyLinted) {
this._logger.warn(`${warningMessage} Not commenting the issue because it has been linted recently.`)
return { identifier: issue.identifier, result: 'succeeded' }
}
this._logger.warn(warningMessage)
if (shouldComment) {
await this._linear.createComment({
issueId: issue.id,
botId: this._botId,
body: [
`BugBuster Bot found the following problems with ${issue.identifier}:`,
'',
...errors.map((error) => `- ${error.message}`),
].join('\n'),
})
}
return { identifier: issue.identifier, messages: errors.map((error) => error.message), result: 'failed' }
}
}
@@ -0,0 +1,194 @@
import { test, assert } from 'vitest'
import { isIssueTitleFormatValid } from './issue-title-format-validator'
test('assert that a title containing only words is valid', async () => {
const title = 'any string'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with square brackets not on the first or second word is valid', async () => {
const title = 'any string [abc]'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one opening square bracket on the first word is valid', async () => {
const title = '[abc'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one closing square bracket on the first word is valid', async () => {
const title = 'abc]'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with and one opening square bracket on the second word is valid', async () => {
const title = 'abc[def'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one closing square bracket on the second word is valid', async () => {
const title = 'abc def]'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with parenthesiss not on the first or second word is valid', async () => {
const title = 'any string (abc)'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one opening parenthesis on the first word is valid', async () => {
const title = '(abc'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one closing parenthesis on the first word is valid', async () => {
const title = 'abc)'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one opening parenthesis on the second word is valid', async () => {
const title = 'abc(def'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one closing parenthesis on the second word is valid', async () => {
const title = 'abc def)'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with one closing parenthesis on the second word is valid', async () => {
const title = 'abc def)'
const actual = isIssueTitleFormatValid(title)
assert.isTrue(actual)
})
test('assert that a title with square brackets on the first word is invalid', async () => {
const title = '[abc] def'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with square brackets on the second word is invalid', async () => {
const title = 'abc [def]'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with square brackets on the second word with no space between the first and second word is invalid', async () => {
const title = 'abc[def]'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with square brackets on the second word with multiple spaces between the first and second word is invalid', async () => {
const title = 'abc [def]'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with square brackets on the second word with multiple spaces between the first and second word is invalid', async () => {
const title = 'abc [def]'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with square brackets on the first and second words is invalid', async () => {
const title = '[abc def]'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the first word is invalid', async () => {
const title = '(abc) def'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the second word is invalid', async () => {
const title = 'abc (def)'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the second word with no space between the first and second word is invalid', async () => {
const title = 'abc(def)'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the second word with multiple spaces between the first and second word is invalid', async () => {
const title = 'abc (def)'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the second word with multiple spaces between the first and second word is invalid', async () => {
const title = 'abc (def)'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
test('assert that a title with parenthesis on the first and second words is invalid', async () => {
const title = '(abc def)'
const actual = isIssueTitleFormatValid(title)
assert.isFalse(actual)
})
@@ -0,0 +1,5 @@
const PATTERN = /^\w{0,} {0,}((\[.{1,}\])|(\(.{1,}\)))/
export function isIssueTitleFormatValid(title: string) {
return !title.match(PATTERN)
}
@@ -0,0 +1,73 @@
import * as types from '../../types'
import * as lin from '../../utils/linear-utils'
import { isIssueTitleFormatValid } from './issue-title-format-validator'
export type IssueLint = {
message: string
}
export const lintIssue = (issue: lin.Issue, state: types.CommonStateName): IssueLint[] => {
const lints: string[] = []
if (!_hasLabelOfCategory(issue, 'type')) {
lints.push(`Issue ${issue.identifier} is missing a type label.`)
}
const hasBlockedLabel = _hasLabelOfCategory(issue, 'blocked')
const hasBlockedRelation = issue.inverseRelations.nodes.some((relation) => relation.type === 'blocks')
if (state === 'BLOCKED' && !issue.assignee && !hasBlockedRelation) {
lints.push(`Issue ${issue.identifier} is blocked but has no assignee.`)
}
if (state === 'BLOCKED' && !hasBlockedLabel && !hasBlockedRelation) {
lints.push(`Issue ${issue.identifier} is blocked but missing a "blocked" label or a blocking issue.`)
}
if (state === 'BACKLOG' && issue.assignee) {
lints.push(`Issue ${issue.identifier} has an assignee but is still in the backlog.`)
}
const hasArea = issue.labels.nodes.some((label) => label.name.startsWith('area/'))
if (!hasArea) {
lints.push(`Issue ${issue.identifier} is missing an "area/" label.`)
}
if (!issue.priority) {
lints.push(`Issue ${issue.identifier} is missing a priority.`)
}
if (issue.estimate === null && state !== 'BLOCKED') {
// blocked issues can be unestimated
lints.push(`Issue ${issue.identifier} is missing an estimate.`)
}
const issueIsEpic = _hasLabel({ issue, label: 'epic', category: 'type' })
if (issue.estimate && issue.estimate > 8 && !issueIsEpic) {
lints.push(
`Issue ${issue.identifier} has an estimate greater than 8 (${issue.estimate}). Consider breaking it down or making it an epic.`
)
}
if (!isIssueTitleFormatValid(issue.title)) {
lints.push(
`Issue ${issue.identifier} has unconventional commit syntax in the title. Issue title should not attempt to follow a formal syntax.`
)
}
const issueProject = issue.project
if (issueProject && issueProject.completedAt) {
lints.push(
`Issue ${issue.identifier} is associated with a completed project (${issueProject.name}). Consider removing the project association.`
)
}
return lints.map((message) => ({ message }))
}
const _hasLabelOfCategory = (issue: lin.Issue, category: string) => {
return issue.labels.nodes.some((label) => label.parent?.name === category)
}
const _hasLabel = (props: { issue: lin.Issue; label: string; category?: string }) => {
const { issue, label, category } = props
return issue.labels.nodes.some((l) => l.name === label && (category ? l.parent?.name === category : true))
}
@@ -0,0 +1,44 @@
import * as sdk from '@botpress/sdk'
import * as types from '../types'
import * as lin from '../utils/linear-utils'
import * as sts from './state-service'
export class IssueStateChecker {
public constructor(
private _linear: lin.LinearApi,
private _stateService: sts.StateService,
private _logger: sdk.BotLogger,
private _botId: string
) {}
public async processIssues(props: { stateAttributes: types.StateAttributes; teams: string[] }) {
const { stateAttributes, teams } = props
const stateIdsToInclude = await this._stateService.mapToStateIds([stateAttributes.commonStateName])
let hasNextPage = false
let endCursor: string | undefined = undefined
do {
const { issues, pagination } = await this._linear.listIssues(
{
teamKeys: teams,
stateIdsToInclude,
updatedBefore: stateAttributes.maxTimeSinceLastUpdate,
},
endCursor
)
for (const issue of issues) {
await this._linear.createComment({
issueId: issue.id,
botId: this._botId,
body: stateAttributes.warningComment,
})
this._logger.warn(stateAttributes.buildWarningReason(issue.identifier))
}
hasNextPage = pagination?.hasNextPage ?? false
endCursor = pagination?.endCursor
} while (hasNextPage)
}
}
@@ -0,0 +1,21 @@
import * as lin from '../utils/linear-utils'
const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes
export class RecentlyLintedManager {
public constructor(private _linear: lin.LinearApi) {}
public async isRecentlyLinted(issue: lin.Issue): Promise<boolean> {
const me = await this._linear.getViewerId()
const timestamps = issue.comments.nodes
.filter((comment) => comment.user?.id === me)
.map((comment) => new Date(comment.createdAt).getTime())
const now = new Date().getTime()
for (const timestamp of timestamps) {
if (now - timestamp < RECENT_THRESHOLD) {
return true
}
}
return false
}
}
@@ -0,0 +1,68 @@
import * as types from '../types'
import * as lin from '../utils/linear-utils'
type StateResolver = {
byName?: Record<string, types.CommonStateName>
default: types.CommonStateName
}
const COMMON_STATE_BY_TYPE: Record<types.LinearStateType, StateResolver> = {
triage: { default: 'TRIAGE' },
backlog: { default: 'BACKLOG' },
unstarted: { default: 'TODO' },
started: {
byName: { staging: 'STAGING', blocked: 'BLOCKED' },
default: 'IN_PROGRESS',
},
completed: { default: 'DONE' },
canceled: {
byName: { stale: 'STALE' },
default: 'CANCELED',
},
duplicate: {
default: 'DUPLICATE',
},
}
type StateEntry = { state: types.LinearState; key?: types.CommonStateName }
export class StateService {
private _stateEntries?: StateEntry[] = undefined
public constructor(private _linear: lin.LinearApi) {}
private async _getClassifiedStates(): Promise<StateEntry[]> {
if (!this._stateEntries) {
const states = await this._linear.getStates()
this._stateEntries = states.map((state) => ({ state, key: this._findCommonState(state) }))
}
return this._stateEntries
}
public async mapToStateIds(keys: types.CommonStateName[]): Promise<string[]> {
const states = await this._getClassifiedStates()
return keys.flatMap((key) => {
const relevantStates = states.filter((state) => state.key === key)
const ids = relevantStates.map((state) => state.state.id)
return ids
})
}
public async getIssueCommonStateName(issue: lin.Issue): Promise<types.CommonStateName | undefined> {
const states = await this._getClassifiedStates()
const state = states.find((s) => s.state.id === issue.state.id)
if (!state) {
throw new Error(`State with ID "${issue.state.id}" not found.`)
}
return state.key
}
private _findCommonState = (state: types.LinearState): types.CommonStateName | undefined => {
const resolver = COMMON_STATE_BY_TYPE[state.type]
if (!resolver) {
return
}
const name = state.name.toLowerCase()
return resolver.byName?.[name] ?? resolver.default
}
}
@@ -0,0 +1,59 @@
import * as lin from '../utils/linear-utils'
import * as bp from '.botpress'
export class TeamsManager {
public constructor(
private _linear: lin.LinearApi,
private _client: bp.Client,
private _botId: string
) {}
public async addWatchedTeam(key: string): Promise<void> {
const teamKeys = await this._getWatchedTeams()
if (teamKeys.includes(key)) {
throw new Error(`The team with the key '${key}' is already being watched.`)
}
if (!(await this._linear.isTeam(key))) {
throw new Error(`The team with the key '${key}' does not exist.`)
}
await this._setWatchedTeams([...teamKeys, key])
}
public async removeWatchedTeam(key: string): Promise<void> {
const teamKeys = await this._getWatchedTeams()
if (!teamKeys.includes(key)) {
throw new Error(`The team with the key '${key}' is not currently being watched.`)
}
await this._setWatchedTeams(teamKeys.filter((team) => team !== key))
}
public async listWatchedTeams(): Promise<string[]> {
const teamKeys = await this._getWatchedTeams()
return teamKeys
}
private _getWatchedTeams = async () => {
return (
await this._client.getOrSetState({
id: this._botId,
name: 'watchedTeams',
type: 'bot',
payload: {
teamKeys: [],
},
})
).state.payload.teamKeys
}
private _setWatchedTeams = async (teamKeys: string[]) => {
await this._client.setState({
id: this._botId,
name: 'watchedTeams',
type: 'bot',
payload: {
teamKeys,
},
})
}
}
+59
View File
@@ -0,0 +1,59 @@
import * as bp from '.botpress'
export type CommonHandlerProps = bp.WorkflowHandlerProps['lintAll'] | bp.EventHandlerProps | bp.MessageHandlerProps
export type LintResult =
| {
identifier: string
result: 'failed'
messages: string[]
}
| {
identifier: string
result: 'succeeded' | 'ignored'
}
export type LinearStateType = 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled' | 'duplicate'
export type CommonStateName =
| 'IN_PROGRESS'
| 'STAGING'
| 'DONE'
| 'BACKLOG'
| 'TODO'
| 'TRIAGE'
| 'CANCELED'
| 'BLOCKED'
| 'STALE'
| 'DUPLICATE'
export type StateAttributes = {
commonStateName: CommonStateName
maxTimeSinceLastUpdate: ISO8601Duration
warningComment: string
buildWarningReason: (issueIdentifier: string) => string
}
export type LinearTeam = {
id: string
key: string
name: string
description?: string | undefined
icon?: string | undefined
}
export type LinearState = {
id: string
name: string
type: LinearStateType
}
export type ISO8601Duration = string
type CommandResult = { success: boolean; message: string }
export type CommandImplementation = (args: string[], conversationId: string) => CommandResult | Promise<CommandResult>
export type CommandDefinition = {
name: string
requiredArgs?: string[]
optionalArgs?: string[]
implementation: CommandImplementation
}
@@ -0,0 +1,49 @@
import * as sdk from '@botpress/sdk'
import * as types from '../types'
import * as bp from '.botpress'
type BotMessage = Pick<bp.ClientInputs['createMessage'], 'type' | 'payload'>
type ErrorHandlerProps = {
context: string
conversationId?: string
}
export class BotpressApi {
private constructor(
private _client: bp.Client,
private _botId: string,
private _logger: sdk.BotLogger
) {}
public static create(props: types.CommonHandlerProps): BotpressApi {
return new BotpressApi(props.client, props.ctx.botId, props.logger)
}
public async respond(conversationId: string, msg: BotMessage): Promise<void> {
await this._client.createMessage({
type: msg.type,
payload: msg.payload,
conversationId,
userId: this._botId,
tags: {},
})
}
public async respondText(conversationId: string, msg: string): Promise<void> {
return this.respond(conversationId, {
type: 'text',
payload: { text: msg },
})
}
public handleError = async (props: ErrorHandlerProps, thrown: unknown): Promise<never> => {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
const message = `An error occured while ${props.context}: ${error.message}`
this._logger.error(message)
if (props.conversationId) {
await this.respondText(props.conversationId, message).catch(() => {}) // if this fails, there's nothing we can do
}
throw new sdk.RuntimeError(error.message)
}
}
+2
View File
@@ -0,0 +1,2 @@
export * as linear from './linear-utils'
export * as botpress from './botpress-utils'
@@ -0,0 +1,193 @@
import * as types from '../../types'
import * as graphql from './graphql-queries'
import { Client } from '.botpress'
const ISSUES_PER_PAGE = 50
const STATES_PER_PAGE = 200
export class LinearApi {
private _teams?: types.LinearTeam[] = undefined
private _states?: types.LinearState[] = undefined
private _viewerId?: string = undefined
private constructor(private _bpClient: Client) {}
public static create(bpClient: Client): LinearApi {
return new LinearApi(bpClient)
}
public async getViewerId(): Promise<string> {
if (this._viewerId) {
return this._viewerId
}
const { output: me } = await this._bpClient.callAction({
type: 'linear:getUser',
input: {},
})
if (!me) {
throw new Error('Viewer not found. Please ensure you are authenticated.')
}
this._viewerId = me.linearId
return this._viewerId
}
public async isTeam(teamKey: string) {
return (await this.getTeams()).some((team) => team.key === teamKey)
}
public async findIssue(filter: { teamKey: string; issueNumber: number }): Promise<graphql.Issue | undefined> {
const { teamKey, issueNumber } = filter
const { issues } = await this.listIssues({
teamKeys: [teamKey],
issueNumber,
})
const [issue] = issues
if (!issue) {
return undefined
}
return issue
}
public async listIssues(
filter: {
teamKeys: string[]
issueNumber?: number
stateIdsToOmit?: string[]
stateIdsToInclude?: string[]
updatedBefore?: types.ISO8601Duration
},
nextPage?: string
): Promise<{ issues: graphql.Issue[]; pagination?: graphql.Pagination }> {
const { teamKeys, issueNumber, stateIdsToOmit, stateIdsToInclude, updatedBefore } = filter
const teams = await this.getTeams()
const teamsExist = teamKeys.every((key) => teams.some((team) => team.key === key))
if (!teamsExist) {
return { issues: [] }
}
const queryInput: graphql.GRAPHQL_QUERIES['listIssues'][graphql.QUERY_INPUT] = {
filter: {
team: { key: { in: teamKeys } },
...(issueNumber && { number: { eq: issueNumber } }),
state: {
id: {
...(stateIdsToOmit && { nin: stateIdsToOmit }),
...(stateIdsToInclude && { in: stateIdsToInclude }),
},
},
...(updatedBefore && { updatedAt: { lt: updatedBefore } }),
},
...(nextPage && { after: nextPage }),
first: ISSUES_PER_PAGE,
orderBy: 'createdAt',
}
const data = await this._executeGraphqlQuery('listIssues', queryInput)
return { issues: data.issues.nodes, pagination: data.issues.pageInfo }
}
public async resolveComments(issue: graphql.Issue): Promise<void> {
const comments = issue.comments.nodes
const me = await this.getViewerId()
const promises: ReturnType<typeof this._bpClient.callAction<'linear:resolveComment'>>[] = []
for (const comment of comments) {
if (comment.user?.id === me && !comment.parentId && !comment.resolvedAt) {
promises.push(this._bpClient.callAction({ type: 'linear:resolveComment', input: { id: comment.id } }))
}
}
await Promise.all(promises)
}
public async createComment(props: { body: string; issueId: string; botId: string }): Promise<void> {
const { body, issueId, botId } = props
const conversation = await this._bpClient.callAction({
type: 'linear:getOrCreateIssueConversation',
input: {
conversation: { id: issueId },
},
})
await this._bpClient.createMessage({
type: 'text',
conversationId: conversation.output.conversationId,
payload: { text: body },
tags: {},
userId: botId,
})
}
public async findTeamStates(teamKey: string): Promise<graphql.TeamStates | undefined> {
const queryInput: graphql.GRAPHQL_QUERIES['findTeamStates'][graphql.QUERY_INPUT] = {
filter: { key: { eq: teamKey } },
}
const data = await this._executeGraphqlQuery('findTeamStates', queryInput)
const [team] = data.organization.teams.nodes
if (!team) {
return undefined
}
return team
}
public async getTeams(): Promise<types.LinearTeam[]> {
if (!this._teams) {
this._teams = await this._listAllTeams()
}
return this._teams
}
public async getStates(): Promise<types.LinearState[]> {
if (!this._states) {
this._states = await this._listAllStates()
}
return this._states
}
private _listAllTeams = async (): Promise<types.LinearTeam[]> => {
const response = await this._bpClient.callAction({ type: 'linear:listTeams', input: {} })
return response.output.teams
}
private _listAllStates = async (): Promise<types.LinearState[]> => {
// We fetch states via GraphQL rather than the linear:listStates action because the action's
// output does not include the state `type`, which we need to normalize states across teams.
let states: types.LinearState[] = []
let after: string | undefined = undefined
do {
const queryInput: graphql.GRAPHQL_QUERIES['listStates'][graphql.QUERY_INPUT] = {
first: STATES_PER_PAGE,
...(after && { after }),
}
const data = await this._executeGraphqlQuery('listStates', queryInput)
states = states.concat(data.workflowStates.nodes)
after = data.workflowStates.pageInfo.hasNextPage ? data.workflowStates.pageInfo.endCursor : undefined
} while (after)
return states
}
private async _executeGraphqlQuery<K extends keyof graphql.GRAPHQL_QUERIES>(
queryName: K,
variables: graphql.GRAPHQL_QUERIES[K][graphql.QUERY_INPUT]
): Promise<graphql.GRAPHQL_QUERIES[K][graphql.QUERY_RESPONSE]> {
const params = Object.entries(variables).map(([name, value]) => ({
name,
value,
}))
const result = await this._bpClient.callAction({
type: 'linear:sendRawGraphqlQuery',
input: {
query: graphql.GRAPHQL_QUERIES[queryName].query,
parameters: params,
},
})
return result.output.result as graphql.GRAPHQL_QUERIES[K][graphql.QUERY_RESPONSE]
}
}
@@ -0,0 +1,219 @@
import * as types from 'src/types'
const QUERY_INPUT = Symbol('graphqlInputType')
const QUERY_RESPONSE = Symbol('graphqlResponseType')
type GraphQLQuery<TInput, TResponse> = {
query: string
[QUERY_INPUT]: TInput
[QUERY_RESPONSE]: TResponse
}
export type Issue = {
id: string
identifier: string
title: string
estimate: number | null
priority: number
assignee: {
id: string
} | null
state: {
id: string
name: string
}
labels: {
nodes: {
name: string
parent: {
name: string
} | null
}[]
}
inverseRelations: {
nodes: {
type: string
}[]
}
project: {
id: string
name: string
completedAt: string | null
} | null
comments: {
nodes: {
id: string
resolvedAt: string | null
createdAt: string
user: {
id: string
} | null
parentId: string | null
}[]
}
}
export type TeamStates = {
id: string
states: {
nodes: {
id: string
name: string
}[]
}
}
export type Pagination = {
hasNextPage: boolean
endCursor: string
}
export const GRAPHQL_QUERIES = {
listIssues: {
query: `
query FindIssue($filter: IssueFilter, $first: Int, $after: String, $orderBy: PaginationOrderBy) {
issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
nodes {
id,
identifier,
title,
estimate,
priority,
assignee {
id
},
state {
id
name
},
labels {
nodes {
name
parent {
name
}
}
},
inverseRelations {
nodes {
type
}
},
project {
id,
name,
completedAt
}
comments {
nodes {
id,
user {
id
},
parentId,
resolvedAt,
createdAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}`,
[QUERY_INPUT]: {} as {
filter: {
team?: { key: { in: string[] } }
number?: { eq: number }
state?: {
id: {
nin?: string[]
in?: string[]
}
}
updatedAt?: {
lt: types.ISO8601Duration
}
}
after?: string
first?: number
orderBy?: 'createdAt' | 'updatedAt'
},
[QUERY_RESPONSE]: {} as {
issues: {
nodes: Issue[]
pageInfo: Pagination
}
},
},
listStates: {
query: `
query ListStates($first: Int, $after: String) {
workflowStates(first: $first, after: $after) {
nodes {
id
name
type
}
pageInfo {
hasNextPage
endCursor
}
}
}`,
[QUERY_INPUT]: {} as {
first?: number
after?: string
},
[QUERY_RESPONSE]: {} as {
workflowStates: {
nodes: types.LinearState[]
pageInfo: Pagination
}
},
},
findTeamStates: {
query: `
query GetAllTeams($filter: TeamFilter) {
organization {
teams(filter: $filter) {
nodes {
id
key
states {
nodes {
id
name
}
}
}
}
}
}`,
[QUERY_INPUT]: {} as {
filter: {
key?: { eq: string }
}
},
[QUERY_RESPONSE]: {} as {
organization: {
teams: {
nodes: {
id: string
states: {
nodes: {
id: string
name: 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,2 @@
export * from './client'
export { Issue, Pagination } from './graphql-queries'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+41
View File
@@ -0,0 +1,41 @@
import * as sdk from '@botpress/sdk'
import slack from 'bp_modules/slack'
export default new sdk.BotDefinition({
states: {
metaApiVersions: {
type: 'bot',
schema: sdk.z.object({
currentGraphApiVersion: sdk.z
.string()
.optional()
.describe("The current Meta's Graph API version")
.title('Current Graph API Version'),
}),
},
},
events: {
timeToCheckApi: {
schema: sdk.z.object({}),
},
},
recurringEvents: {
timeToCheckApi: {
type: 'timeToCheckApi',
schedule: { cron: '0 * * * *' },
payload: sdk.z.object({}),
},
},
}).addIntegration(slack, {
enabled: true,
configurationType: null,
configuration: {
typingIndicatorEmoji: false,
botName: 'Clog',
botAvatarUrl: 'https://files.bpcontent.cloud/2025/06/16/20/20250616204038-BRUW6C2R.svg',
replyBehaviour: {
location: 'channel',
onlyOnBotMention: false,
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@bp-bots/clog",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"cheerio": "^1.1.2"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/slack": "workspace:*"
},
"bpDependencies": {
"slack": "../../integrations/slack"
}
}
+90
View File
@@ -0,0 +1,90 @@
import * as cheerio from 'cheerio'
import * as bp from '.botpress'
const SLACK_CHANNEL_TO_PING = 'alert-squid'
const bot = new bp.Bot({
actions: {},
})
const _resolveSlackChannelId = async (client: bp.Client, channelName: string): Promise<string | undefined> => {
const { output } = await client.callAction({
type: 'slack:findTarget',
input: { query: channelName, channel: 'channel' },
})
const exact = output.targets.find((t) => t.displayName === channelName)
return (exact ?? output.targets[0])?.tags.id
}
const _handleApiChange = async (
message: string,
newGraphApiVersion: string | undefined,
props: bp.EventHandlerProps
): Promise<void> => {
const { client, logger } = props
logger.info(message)
const channelId = await _resolveSlackChannelId(client, SLACK_CHANNEL_TO_PING)
if (!channelId) {
logger.error(`Could not find Slack channel '${SLACK_CHANNEL_TO_PING}'`)
return
}
const response = await client.callAction({
type: 'slack:getOrCreateChannelConversation',
input: { conversation: { channelId } },
})
await client.createMessage({
type: 'text',
conversationId: response.output.conversationId,
tags: {},
userId: props.ctx.botId,
payload: {
text: message,
},
})
await client.setState({
name: 'metaApiVersions',
type: 'bot',
id: props.ctx.botId,
payload: { currentGraphApiVersion: newGraphApiVersion },
})
}
// Checks if starts with v, has a number of at least one digit, a dot and a single digit (e.g. v00.0)
const versionRegexp: RegExp = /v\d+.\d/i
const isVersionString = (s: string): boolean => versionRegexp.test(s)
bot.on.event('timeToCheckApi', async (props) => {
const { client, ctx, logger } = props
const { state } = await client.getOrSetState({
name: 'metaApiVersions',
type: 'bot',
id: ctx.botId,
payload: { currentGraphApiVersion: undefined },
})
const currentGraphApiVersion = state.payload.currentGraphApiVersion
const response = await fetch('https://developers.facebook.com/docs/graph-api/changelog/')
const html = await response.text()
const selector = cheerio.load(html)
const newGraphApiVersion = selector('code').first().text().trim()
if (!isVersionString(newGraphApiVersion)) {
await _handleApiChange(
`I failed reading the Meta's API version, I received\n${newGraphApiVersion}`,
undefined,
props
)
} else if (currentGraphApiVersion === undefined) {
await _handleApiChange(
`I'll notify you when Meta's Graph API version will change.\nThe current version is ${newGraphApiVersion}`,
newGraphApiVersion,
props
)
} else if (currentGraphApiVersion !== newGraphApiVersion) {
await _handleApiChange(`Meta's Graph API version changed to: ${newGraphApiVersion}`, newGraphApiVersion, props)
} else {
logger.info(`Meta's Graph API version is ${newGraphApiVersion}`)
}
})
export default bot
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import * as sdk from '@botpress/sdk'
import * as genenv from './.genenv'
import todoist from './bp_modules/todoist'
export default new sdk.BotDefinition({
configuration: {
schema: sdk.z.object({}),
},
states: {},
events: {},
recurringEvents: {},
user: {},
conversation: {},
})
.addIntegration(todoist, {
alias: 'todoist-src',
enabled: true,
configurationType: 'apiToken',
configuration: { apiToken: genenv.TODOIST_SRC_API_TOKEN },
})
.addIntegration(todoist, {
alias: 'todoist-dst',
enabled: true,
configurationType: 'apiToken',
configuration: { apiToken: genenv.TODOIST_DST_API_TOKEN },
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@bp-bots/doppel-doer",
"scripts": {
"postinstall": "genenv -o ./.genenv/index.ts -e TODOIST_SRC_API_TOKEN -e TODOIST_DST_API_TOKEN",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/todoist": "workspace:*",
"@bpinternal/genenv": "0.0.1"
},
"bpDependencies": {
"todoist": "../../integrations/todoist"
}
}

Some files were not shown because too many files have changed in this diff Show More