From a6ca373f48cf6c21081de842a00ec83512c5f04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 25 Jul 2025 19:04:52 -0400 Subject: [PATCH] feat(bots/bugbuster): improve capabilities of bugbuster (#14084) --- .github/workflows/deploy-bots.yml | 5 +- bots/bugbuster/bot.definition.ts | 44 +++-- bots/bugbuster/package.json | 11 +- bots/bugbuster/src/bot.ts | 2 - .../src/handlers/github-issue-opened.ts | 34 ++++ bots/bugbuster/src/handlers/index.ts | 5 +- bots/bugbuster/src/handlers/issue-opened.ts | 21 --- .../src/handlers/linear-issue-updated.ts | 53 ++++++ .../bugbuster/src/handlers/message-created.ts | 14 ++ bots/bugbuster/src/handlers/sync-issues.ts | 48 ----- bots/bugbuster/src/index.ts | 61 +------ bots/bugbuster/src/linear-lint-issue.ts | 80 +++++++++ bots/bugbuster/src/list-issues.ts | 17 -- bots/bugbuster/src/listeners.ts | 46 ----- bots/bugbuster/src/utils/botpress-utils.ts | 80 +++++++++ bots/bugbuster/src/utils/index.ts | 4 + bots/bugbuster/src/utils/linear-utils.ts | 167 ++++++++++++++++++ bots/bugbuster/src/utils/promise-utils.ts | 8 + bots/bugbuster/src/utils/string-utils.ts | 3 + .../linear/src/events/issueUpdated.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 21 ++- 22 files changed, 510 insertions(+), 218 deletions(-) delete mode 100644 bots/bugbuster/src/bot.ts create mode 100644 bots/bugbuster/src/handlers/github-issue-opened.ts delete mode 100644 bots/bugbuster/src/handlers/issue-opened.ts create mode 100644 bots/bugbuster/src/handlers/linear-issue-updated.ts create mode 100644 bots/bugbuster/src/handlers/message-created.ts delete mode 100644 bots/bugbuster/src/handlers/sync-issues.ts create mode 100644 bots/bugbuster/src/linear-lint-issue.ts delete mode 100644 bots/bugbuster/src/list-issues.ts delete mode 100644 bots/bugbuster/src/listeners.ts create mode 100644 bots/bugbuster/src/utils/botpress-utils.ts create mode 100644 bots/bugbuster/src/utils/index.ts create mode 100644 bots/bugbuster/src/utils/linear-utils.ts create mode 100644 bots/bugbuster/src/utils/promise-utils.ts create mode 100644 bots/bugbuster/src/utils/string-utils.ts diff --git a/.github/workflows/deploy-bots.yml b/.github/workflows/deploy-bots.yml index b7e150fc403..38937332b91 100644 --- a/.github/workflows/deploy-bots.yml +++ b/.github/workflows/deploy-bots.yml @@ -15,8 +15,9 @@ jobs: env: BUGBUSTER_GITHUB_TOKEN: ${{ secrets.BUGBUSTER_GITHUB_TOKEN }} BUGBUSTER_GITHUB_WEBHOOK_SECRET: ${{ secrets.BUGBUSTER_GITHUB_WEBHOOK_SECRET }} - BUGBUSTER_SLACK_BOT_TOKEN: ${{ secrets.BUGBUSTER_SLACK_BOT_TOKEN }} - BUGBUSTER_SLACK_SIGNING_SECRET: ${{ secrets.BUGBUSTER_SLACK_SIGNING_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 }} uses: ./.github/actions/setup - name: Deploy Bots run: | diff --git a/bots/bugbuster/bot.definition.ts b/bots/bugbuster/bot.definition.ts index cd0508f86b0..b7ae561a33f 100644 --- a/bots/bugbuster/bot.definition.ts +++ b/bots/bugbuster/bot.definition.ts @@ -1,29 +1,26 @@ import * as sdk from '@botpress/sdk' import * as genenv from './.genenv' import github from './bp_modules/github' -import slack from './bp_modules/slack' +import linear from './bp_modules/linear' +import telegram from './bp_modules/telegram' export default new sdk.BotDefinition({ states: { - listeners: { + recentlyLinted: { type: 'bot', schema: sdk.z.object({ - conversationIds: sdk.z.array(sdk.z.string()).title('Conversation IDs').describe('List of conversation IDs'), + issues: sdk.z + .array( + sdk.z.object({ + id: sdk.z.string(), + lintedAt: sdk.z.string().datetime(), + }) + ) + .title('Recently Linted Issues') + .describe('List of recently linted issues'), }), }, }, - events: { - syncIssuesRequest: { - schema: sdk.z.object({}).title('Sync Issues Request').describe('Request to sync issues'), - }, - }, - recurringEvents: { - fetchIssues: { - type: 'syncIssuesRequest', - payload: {}, - schedule: { cron: '0 0/6 * * *' }, // every 6 hours - }, - }, }) .addIntegration(github, { enabled: true, @@ -33,4 +30,19 @@ export default new sdk.BotDefinition({ githubWebhookSecret: genenv.BUGBUSTER_GITHUB_WEBHOOK_SECRET, }, }) - .addIntegration(slack) + // TODO: replace Telegram with Slack when available + .addIntegration(telegram, { + enabled: true, + configurationType: null, + configuration: { + botToken: genenv.BUGBUSTER_TELEGRAM_BOT_TOKEN, + }, + }) + .addIntegration(linear, { + enabled: true, + configurationType: 'apiKey', + configuration: { + apiKey: genenv.BUGBUSTER_LINEAR_API_KEY, + webhookSigningSecret: genenv.BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET, + }, + }) diff --git a/bots/bugbuster/package.json b/bots/bugbuster/package.json index 87014e0369e..53d61b2e088 100644 --- a/bots/bugbuster/package.json +++ b/bots/bugbuster/package.json @@ -1,7 +1,7 @@ { "name": "@bp-bots/bugbuster", "scripts": { - "postinstall": "genenv -o ./.genenv/index.ts -e BUGBUSTER_GITHUB_TOKEN -e BUGBUSTER_GITHUB_WEBHOOK_SECRET -e BUGBUSTER_SLACK_BOT_TOKEN -e BUGBUSTER_SLACK_SIGNING_SECRET", + "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" @@ -9,17 +9,20 @@ "private": true, "dependencies": { "@botpress/client": "workspace:*", - "@botpress/sdk": "workspace:*" + "@botpress/sdk": "workspace:*", + "@linear/sdk": "^50.0.0" }, "devDependencies": { "@botpress/cli": "workspace:*", "@botpress/common": "workspace:*", "@botpresshub/github": "workspace:*", - "@botpresshub/slack": "workspace:*", + "@botpresshub/linear": "workspace:*", + "@botpresshub/telegram": "workspace:*", "@bpinternal/genenv": "0.0.1" }, "bpDependencies": { "github": "../../integrations/github", - "slack": "../../integrations/slack" + "linear": "../../integrations/linear", + "telegram": "../../integrations/telegram" } } diff --git a/bots/bugbuster/src/bot.ts b/bots/bugbuster/src/bot.ts deleted file mode 100644 index 90fa0f2d9dd..00000000000 --- a/bots/bugbuster/src/bot.ts +++ /dev/null @@ -1,2 +0,0 @@ -import * as bp from '.botpress' -export const bot = new bp.Bot({ actions: {} }) diff --git a/bots/bugbuster/src/handlers/github-issue-opened.ts b/bots/bugbuster/src/handlers/github-issue-opened.ts new file mode 100644 index 00000000000..d2348b02cbd --- /dev/null +++ b/bots/bugbuster/src/handlers/github-issue-opened.ts @@ -0,0 +1,34 @@ +import * as utils from '../utils' +import * as bp from '.botpress' + +export const handleGithubIssueOpened: bp.EventHandlers['github:issueOpened'] = async (props): Promise => { + const githubIssue = props.event.payload + + props.logger.info('Received GitHub issue', githubIssue) + + const linear = await utils.linear.LinearApi.create() + + const githubLabel = await linear.findLabel({ name: 'github', parentName: 'origin' }) + if (!githubLabel) { + props.logger.error('Label origin/github not found in engineering team') + } + + const linearResponse = await linear.client.createIssue({ + teamId: linear.teams.ENG.id, + stateId: linear.states.ENG.TRIAGE.id, + title: githubIssue.issue.name, + description: githubIssue.issue.body, + labelIds: githubLabel ? [githubLabel.id] : [], + }) + + const comment = [ + 'This issue was created from GitHub by BugBuster Bot.', + '', + `GitHub Issue: [${githubIssue.issue.name}](${githubIssue.issue.url})`, + ].join('\n') + + await linear.client.createComment({ + issueId: linearResponse.issueId, + body: comment, + }) +} diff --git a/bots/bugbuster/src/handlers/index.ts b/bots/bugbuster/src/handlers/index.ts index c43806dcf8c..f82c01dd40b 100644 --- a/bots/bugbuster/src/handlers/index.ts +++ b/bots/bugbuster/src/handlers/index.ts @@ -1,2 +1,3 @@ -export * from './issue-opened' -export * from './sync-issues' +export * from './github-issue-opened' +export * from './linear-issue-updated' +export * from './message-created' diff --git a/bots/bugbuster/src/handlers/issue-opened.ts b/bots/bugbuster/src/handlers/issue-opened.ts deleted file mode 100644 index f2f8f4541de..00000000000 --- a/bots/bugbuster/src/handlers/issue-opened.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as listener from '../listeners' -import * as bp from '.botpress' - -export const handleNewIssue: bp.EventHandlers['github:issueOpened'] = async (props): Promise => { - const githubIssue = props.event.payload - - console.info('Received GitHub issue', githubIssue) - - const message = [ - 'The following issue was just created in GitHub:', - githubIssue.issue.name, - githubIssue.issue.body, - ].join('\n') - - await listener.notifyListeners(props, { - type: 'text', - payload: { - text: message, - }, - }) -} diff --git a/bots/bugbuster/src/handlers/linear-issue-updated.ts b/bots/bugbuster/src/handlers/linear-issue-updated.ts new file mode 100644 index 00000000000..393a4f3a3df --- /dev/null +++ b/bots/bugbuster/src/handlers/linear-issue-updated.ts @@ -0,0 +1,53 @@ +import * as linlint from '../linear-lint-issue' +import * as utils from '../utils' +import * as bp from '.botpress' + +export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = async (props) => { + const { number: issueNumber, teamKey } = props.event.payload + if (!issueNumber || !teamKey) { + props.logger.error('Missing issueNumber or teamKey in event payload') + return + } + + props.logger.info('Linear issue updated event received', `${teamKey}-${issueNumber}`) + + const linear = await utils.linear.LinearApi.create() + + if (!linear.isTeam(teamKey) || teamKey !== 'SQD') { + props.logger.error(`Ignoring issue of team "${teamKey}"`) + return + } + + const issue = await linear.findIssue({ teamKey, issueNumber }) + if (!issue) { + props.logger.error(`Issue with number ${issueNumber} not found in team ${teamKey}`) + return + } + + const botpress = await utils.botpress.BotpressApi.create(props) + const recentlyLinted = await botpress.getRecentlyLinted() + + if (recentlyLinted.some(({ id: issueId }) => issue.id === issueId)) { + props.logger.info(`Issue ${issue.identifier} has already been linted recently, skipping...`) + return + } + + const errors = await linlint.lintIssue(linear, issue) + if (errors.length === 0) { + props.logger.info(`Issue ${issue.identifier} passed all lint checks.`) + return + } + + props.logger.warn(`Issue ${issue.identifier} has ${errors.length} lint errors:`) + + await linear.client.createComment({ + issueId: issue.id, + body: [ + `BugBuster Bot found the following problems with ${issue.identifier}:`, + '', + ...errors.map((error) => `- ${error.message}`), + ].join('\n'), + }) + + await botpress.setRecentlyLinted([...recentlyLinted, { id: issue.id, lintedAt: new Date().toISOString() }]) +} diff --git a/bots/bugbuster/src/handlers/message-created.ts b/bots/bugbuster/src/handlers/message-created.ts new file mode 100644 index 00000000000..31115cf2d71 --- /dev/null +++ b/bots/bugbuster/src/handlers/message-created.ts @@ -0,0 +1,14 @@ +import * as utils from '../utils' +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 + } + const botpress = await utils.botpress.BotpressApi.create(props) + await botpress.respondText(message.conversationId, "Hey, I'm BugBuster.") +} diff --git a/bots/bugbuster/src/handlers/sync-issues.ts b/bots/bugbuster/src/handlers/sync-issues.ts deleted file mode 100644 index e03615cdb8b..00000000000 --- a/bots/bugbuster/src/handlers/sync-issues.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { listIssues } from '../list-issues' -import * as listeners from '../listeners' -import * as bp from '.botpress' - -/** - * checks if all issues in GitHub are assigned to someone - * if not, sends a DM to listeners of the bot - */ -export const handleSyncIssuesRequest: bp.EventHandlers['syncIssuesRequest'] = async (props) => { - try { - const githubIssues = await listIssues(props) - - const unassignedIssues = githubIssues - .map((issue) => - issue.tags['id'] - ? { - displayName: issue.displayName, - id: issue.tags['id'], - assigneeId: issue.tags['assigneeId'], - } - : null - ) - .filter((i: T | null): i is T => i !== null) - .filter((issue) => !issue.assigneeId) - - if (!unassignedIssues.length) { - console.info('All issues are assigned in GitHub') - return - } - - const message = [ - 'The following issues are not assigned in GitHub:', - ...unassignedIssues.map((issue) => `\t${issue.displayName}`), - ].join('\n') - console.info(message) - - await listeners.notifyListeners(props, { - type: 'text', - payload: { - text: message, - }, - }) - } catch (thrown) { - // If recurring event fails to many times, bridge stops sending it... We don't want that - const err = thrown instanceof Error ? thrown : new Error(`${thrown}`) - console.error(err.message) - } -} diff --git a/bots/bugbuster/src/index.ts b/bots/bugbuster/src/index.ts index 962da8f39b2..657a8617a77 100644 --- a/bots/bugbuster/src/index.ts +++ b/bots/bugbuster/src/index.ts @@ -1,61 +1,10 @@ -import { bot } from './bot' -import { handleNewIssue, handleSyncIssuesRequest } from './handlers' -import { listIssues } from './list-issues' -import * as listeners from './listeners' +import * as handlers from './handlers' import * as bp from '.botpress' -bot.on.event('github:issueOpened', handleNewIssue) -bot.on.event('syncIssuesRequest', handleSyncIssuesRequest) +export const bot = new bp.Bot({ actions: {} }) -const respond = async (props: bp.MessageHandlerProps, text: string) => { - const { client, ctx, message } = props - await client.createMessage({ - type: 'text', - payload: { - text, - }, - conversationId: message.conversationId, - userId: ctx.botId, - tags: {}, - }) -} - -bot.on.message('*', async (props) => { - const { conversation, message, client, ctx } = props - if (conversation.integration !== 'slack') { - console.info(`Ignoring message from ${conversation.integration}`) - return - } - - if (message.type === 'text' && message.payload.text === '#start_listening') { - const state = await listeners.readListeners(props) - if (!state.conversationIds.includes(message.conversationId)) { - state.conversationIds.push(message.conversationId) - await listeners.writeListeners(props, state) - return await respond(props, 'You will now receive notifications.') - } else { - return await respond(props, 'Already listening.') - } - } else if (message.type === 'text' && message.payload.text === '#stop_listening') { - const state = await listeners.readListeners(props) - state.conversationIds = state.conversationIds.filter((id) => id !== message.conversationId) - await listeners.writeListeners(props, state) - return await respond(props, 'Stopped listening.') - } else if (message.type === 'text' && message.payload.text === '#list') { - const githubIssues = await listIssues(props) - const message = ['Here are the issues in GitHub:', ...githubIssues.map((i) => `\t${i.displayName}`)].join('\n') - return await respond(props, message) - } - - await client.createMessage({ - type: 'text', - payload: { - text: "Hi, I'm BugBuster. I can't help you.", - }, - conversationId: message.conversationId, - userId: ctx.botId, - tags: {}, - }) -}) +bot.on.event('github:issueOpened', handlers.handleGithubIssueOpened) +bot.on.event('linear:issueUpdated', handlers.handleLinearIssueUpdated) +bot.on.message('*', handlers.handleMessageCreated) export default bot diff --git a/bots/bugbuster/src/linear-lint-issue.ts b/bots/bugbuster/src/linear-lint-issue.ts new file mode 100644 index 00000000000..26a402ee1fc --- /dev/null +++ b/bots/bugbuster/src/linear-lint-issue.ts @@ -0,0 +1,80 @@ +import * as lin from '@linear/sdk' +import * as utils from './utils' + +export type IssueLint = { + message: string +} + +const IGNORED_STATUSES: utils.linear.StateKey[] = ['TRIAGE', 'PRODUCTION_DONE', 'CANCELED', 'STALE'] + +export const lintIssue = async (client: utils.linear.LinearApi, issue: lin.Issue): Promise => { + const status = client.issueStatus(issue) + if (IGNORED_STATUSES.includes(status)) { + return [] + } + + const lints: string[] = [] + const { nodes: labels } = await issue.labels() + + const hasType = await utils.promise.some(labels, async (label) => { + const parent = await label.parent + return parent?.name === 'type' + }) + if (!hasType) { + lints.push(`Issue ${issue.identifier} is missing a type label.`) + } + + const hasBlockedLabel = await utils.promise.some(labels, async (label) => { + const parent = await label.parent + return parent?.name === 'blocked' + }) + + const hasBlockedRelation = await client.isBlockedByOtherIssues(issue) + if (status === 'BLOCKED' && !hasBlockedLabel && !hasBlockedRelation) { + lints.push(`Issue ${issue.identifier} is blocked but missing a "blocked" label or a blocking issue.`) + } + + const hasArea = labels.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) { + lints.push(`Issue ${issue.identifier} is missing an estimate.`) + } + + if (issue.estimate && issue.estimate > 8) { + lints.push( + `Issue ${issue.identifier} has an estimate greater than 8 (${issue.estimate}). Consider breaking it down.` + ) + } + + const hasProject = await issue.project + const hasGoal = await utils.promise.some(labels, async (label) => { + const parent = await label.parent + return parent?.name === 'goal' + }) + if (!hasProject && !hasGoal) { + lints.push(`Issue ${issue.identifier} is missing both a project and a goal label.`) + } + + const hasFormalTitle = issue.title.includes('[') && issue.title.includes(']') + if (hasFormalTitle) { + lints.push( + `Issue ${issue.identifier} has unconventional commit syntax in the title. Issue title should not attempt to follow a formal syntax.` + ) + } + + const issueProject = await 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 })) +} diff --git a/bots/bugbuster/src/list-issues.ts b/bots/bugbuster/src/list-issues.ts deleted file mode 100644 index cbfdc16991a..00000000000 --- a/bots/bugbuster/src/list-issues.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as bp from '.botpress' - -type GithubIssue = bp.integrations.github.actions.findTarget.output.Output['targets'][number] -export const listIssues = async (props: bp.EventHandlerProps | bp.MessageHandlerProps): Promise => { - const { client } = props - const { - output: { targets: githubIssues }, - } = await client.callAction({ - type: 'github:findTarget', - input: { - channel: 'issue', - repo: 'botpress', - query: '', - }, - }) - return githubIssues -} diff --git a/bots/bugbuster/src/listeners.ts b/bots/bugbuster/src/listeners.ts deleted file mode 100644 index 81b0a20c9a3..00000000000 --- a/bots/bugbuster/src/listeners.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as bp from '.botpress' - -type Props = Omit -type Message = Pick -type BotListeners = bp.states.listeners.Listeners['payload'] - -const emptyListeners: BotListeners = { - conversationIds: [], -} - -export const readListeners = async (props: Props): Promise => { - try { - const { - state: { payload: listeners }, - } = await props.client.getState({ - id: props.ctx.botId, - type: 'bot', - name: 'listeners', - }) - return listeners - } catch { - return emptyListeners - } -} - -export const writeListeners = async (props: Props, state: BotListeners) => { - await props.client.setState({ - id: props.ctx.botId, - type: 'bot', - name: 'listeners', - payload: state, - }) -} - -export const notifyListeners = async (props: Props, message: Message) => { - const state = await readListeners(props) - console.info(`Sending message to ${state.conversationIds.length} conversation(s)`) - for (const conversationId of state.conversationIds) { - await props.client.createMessage({ - conversationId, - userId: props.ctx.botId, - tags: {}, - ...message, - }) - } -} diff --git a/bots/bugbuster/src/utils/botpress-utils.ts b/bots/bugbuster/src/utils/botpress-utils.ts new file mode 100644 index 00000000000..6f5766bad12 --- /dev/null +++ b/bots/bugbuster/src/utils/botpress-utils.ts @@ -0,0 +1,80 @@ +import * as bp from '.botpress' + +export type BotProps = bp.EventHandlerProps | bp.MessageHandlerProps +export type BotMessage = Pick +export type GithubIssue = bp.integrations.github.actions.findTarget.output.Output['targets'][number] +export type IssueLintEntry = bp.states.recentlyLinted.RecentlyLinted['payload']['issues'][number] + +const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes + +export class BotpressApi { + private constructor(private _props: BotProps) {} + + public static async create(props: BotProps): Promise { + return new BotpressApi(props) + } + + public async respond(conversationId: string, msg: BotMessage): Promise { + const { client, ctx } = this._props + await client.createMessage({ + type: msg.type, + payload: msg.payload, + conversationId, + userId: ctx.botId, + tags: {}, + }) + } + + public async respondText(conversationId: string, msg: string): Promise { + return this.respond(conversationId, { + type: 'text', + payload: { text: msg }, + }) + } + + public listGithubIssues = async (): Promise => { + const { + output: { targets: githubIssues }, + } = await this._props.client.callAction({ + type: 'github:findTarget', + input: { + channel: 'issue', + repo: 'botpress', + query: '', + }, + }) + return githubIssues + } + + public async getRecentlyLinted(): Promise { + const { client } = this._props + const { + state: { + payload: { issues }, + }, + } = await client.getOrSetState({ + id: this._props.ctx.botId, + type: 'bot', + name: 'recentlyLinted', + payload: { issues: [] }, + }) + return issues.filter(this._isRecentlyLinted) + } + + public async setRecentlyLinted(issues: bp.states.recentlyLinted.RecentlyLinted['payload']['issues']): Promise { + await this._props.client.setState({ + id: this._props.ctx.botId, + type: 'bot', + name: 'recentlyLinted', + payload: { + issues: issues.filter(this._isRecentlyLinted), + }, + }) + } + + private _isRecentlyLinted = (issue: IssueLintEntry): boolean => { + const lintedAt = new Date(issue.lintedAt).getTime() + const now = new Date().getTime() + return now - lintedAt < RECENT_THRESHOLD + } +} diff --git a/bots/bugbuster/src/utils/index.ts b/bots/bugbuster/src/utils/index.ts new file mode 100644 index 00000000000..413cf5fe9ad --- /dev/null +++ b/bots/bugbuster/src/utils/index.ts @@ -0,0 +1,4 @@ +export * as string from './string-utils' +export * as promise from './promise-utils' +export * as linear from './linear-utils' +export * as botpress from './botpress-utils' diff --git a/bots/bugbuster/src/utils/linear-utils.ts b/bots/bugbuster/src/utils/linear-utils.ts new file mode 100644 index 00000000000..fe23bcfb992 --- /dev/null +++ b/bots/bugbuster/src/utils/linear-utils.ts @@ -0,0 +1,167 @@ +import * as lin from '@linear/sdk' +import * as genenv from '../../.genenv' +import * as utils from '.' + +const TEAM_KEYS = ['SQD', 'FT', 'BE', 'ENG'] as const +export type TeamKey = (typeof TEAM_KEYS)[number] + +const STATE_KEYS = [ + 'IN_PROGRESS', + 'MERGED_STAGING', + 'PRODUCTION_DONE', + 'BACKLOG', + 'TODO', + 'TRIAGE', + 'CANCELED', + 'BLOCKED', + 'STALE', +] as const +export type StateKey = (typeof STATE_KEYS)[number] + +export class LinearApi { + private constructor( + private _client: lin.LinearClient, + private _viewer: lin.User, + private _teams: lin.Team[], + private _states: lin.WorkflowState[] + ) {} + + public static async create(): Promise { + const client = new lin.LinearClient({ apiKey: genenv.BUGBUSTER_LINEAR_API_KEY }) + const me = await client.viewer + if (!me) { + throw new Error('Viewer not found. Please ensure you are authenticated.') + } + + const states = await this._listAllStates(client) + const teams = await this._listAllTeams(client) + + return new LinearApi(client, me, teams, states) + } + + public get client(): lin.LinearClient { + return this._client + } + + public get me(): lin.User { + return this._viewer + } + + public isTeam(teamKey: string): teamKey is TeamKey { + return TEAM_KEYS.includes(teamKey as TeamKey) + } + + public isState(stateKey: string): stateKey is StateKey { + return STATE_KEYS.includes(stateKey as StateKey) + } + + public async findIssue(filter: { teamKey: TeamKey; issueNumber: number }): Promise { + const { teamKey, issueNumber } = filter + const teamExists = this._teams.some((team) => team.key === teamKey) + if (!teamExists) { + return undefined + } + + const { nodes: issues } = await this._client.issues({ + filter: { + team: { key: { eq: teamKey } }, + number: { eq: issueNumber }, + }, + }) + + const [issue] = issues + if (!issue) { + return undefined + } + return issue + } + + public async findLabel(filter: { name: string; parentName?: string }): Promise { + const { name, parentName } = filter + const { nodes: labels } = await this._client.issueLabels({ + filter: { + name: { eq: name }, + parent: parentName ? { name: { eq: parentName } } : undefined, + }, + }) + + const [label] = labels + return label || undefined + } + + public issueStatus(issue: lin.Issue): StateKey { + const state = this._states.find((s) => s.id === issue.stateId) + if (!state) { + throw new Error(`State with ID "${issue.stateId}" not found.`) + } + return utils.string.toScreamingSnakeCase(state.name) as StateKey + } + + public async isBlockedByOtherIssues(issueA: lin.Issue): Promise { + const { nodes: issues } = await this._client.issues({ + filter: { + hasBlockedByRelations: { eq: true }, + id: { eq: issueA.id }, + }, + }) + return issues.length > 0 + } + + public get teams(): Record { + return new Proxy({} as Record, { + get: (_, key: TeamKey) => { + const team = this._teams.find((t) => t.key === key) + if (!team) { + throw new Error(`Team with key "${key}" not found.`) + } + return team + }, + }) + } + + public get states(): Record> { + return new Proxy({} as Record>, { + get: (_, teamKey: TeamKey) => { + const teamId = this.teams[teamKey].id + if (!teamId) { + throw new Error(`Team with key "${teamKey}" not found.`) + } + + return new Proxy({} as Record, { + get: (_, stateKey: StateKey) => { + const state = this._states.find( + (s) => utils.string.toScreamingSnakeCase(s.name) === stateKey && s.teamId === teamId + ) + + if (!state) { + throw new Error(`State with key "${stateKey}" not found.`) + } + return state + }, + }) + }, + }) + } + + private static _listAllTeams = async (client: lin.LinearClient): Promise => { + let teams: lin.Team[] = [] + let cursor: string | undefined = undefined + do { + const response = await client.teams({ after: cursor, first: 100 }) + teams = teams.concat(response.nodes) + cursor = response.pageInfo.endCursor + } while (cursor) + return teams + } + + private static _listAllStates = async (client: lin.LinearClient): Promise => { + let states: lin.WorkflowState[] = [] + let cursor: string | undefined = undefined + do { + const response = await client.workflowStates({ after: cursor, first: 100 }) + states = states.concat(response.nodes) + cursor = response.pageInfo.endCursor + } while (cursor) + return states + } +} diff --git a/bots/bugbuster/src/utils/promise-utils.ts b/bots/bugbuster/src/utils/promise-utils.ts new file mode 100644 index 00000000000..7a6cd70e3f6 --- /dev/null +++ b/bots/bugbuster/src/utils/promise-utils.ts @@ -0,0 +1,8 @@ +export const some = async (xs: T[], fn: (x: T) => Promise): Promise => { + for (const x of xs) { + if (await fn(x)) { + return true + } + } + return false +} diff --git a/bots/bugbuster/src/utils/string-utils.ts b/bots/bugbuster/src/utils/string-utils.ts new file mode 100644 index 00000000000..aa77e19bdbd --- /dev/null +++ b/bots/bugbuster/src/utils/string-utils.ts @@ -0,0 +1,3 @@ +const _splitSpecialChars = (text: string) => text.split(/[^a-zA-Z0-9]/).filter((t) => !!t) +export const toSnakeCase = (text: string): string => _splitSpecialChars(text).join('_').toLowerCase() +export const toScreamingSnakeCase = (text: string): string => _splitSpecialChars(text).join('_').toUpperCase() diff --git a/integrations/linear/src/events/issueUpdated.ts b/integrations/linear/src/events/issueUpdated.ts index 809e117738b..983f2ee569a 100644 --- a/integrations/linear/src/events/issueUpdated.ts +++ b/integrations/linear/src/events/issueUpdated.ts @@ -15,7 +15,7 @@ export const fireIssueUpdated = async ({ linearEvent, client, ctx }: IssueProps) title: linearEvent.data.title, priority: linearEvent.data.priority, status: linearEvent.data.state.name, - description: linearEvent.data.description, + description: linearEvent.data.description ?? '', number: linearEvent.data.number, updatedAt: linearEvent.data.updatedAt, createdAt: linearEvent.data.createdAt, diff --git a/package.json b/package.json index be5b8730e04..c5c962e2e8b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "vitest --run", "check:bplint": "turbo check:bplint", "check:dep": "depsynky check --ignore-dev", - "check:sherif": "sherif -i zod -i axios -i query-string -i googleapis", + "check:sherif": "sherif -i zod -i axios -i query-string -i googleapis -i @linear/sdk", "check:format": "prettier --check .", "check:eslint": "eslint ./ --max-warnings=0", "check:oxlint": "oxlint -c .oxlintrc.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2aa0997e2d..96ea0280e95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk + '@linear/sdk': + specifier: ^50.0.0 + version: 50.0.0 devDependencies: '@botpress/cli': specifier: workspace:* @@ -121,9 +124,12 @@ importers: '@botpresshub/github': specifier: workspace:* version: link:../../integrations/github - '@botpresshub/slack': + '@botpresshub/linear': + specifier: workspace:* + version: link:../../integrations/linear + '@botpresshub/telegram': specifier: workspace:* - version: link:../../integrations/slack + version: link:../../integrations/telegram '@bpinternal/genenv': specifier: 0.0.1 version: 0.0.1 @@ -6072,6 +6078,17 @@ packages: - encoding dev: false + /@linear/sdk@50.0.0: + resolution: {integrity: sha512-cqOTIkayqCDTBITCieuZ3acMiJmWZB0bR9FhfrfMs2B5PotntDgcfNCBW+21LcMwcBrq0A84kwK2AQzdu+Atsg==} + engines: {node: '>=12.x', yarn: 1.x} + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@15.8.0) + graphql: 15.8.0 + isomorphic-unfetch: 3.1.0 + transitivePeerDependencies: + - encoding + dev: false + /@lukeed/csprng@1.1.0: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'}