chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 |
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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) => `<${arg}>`).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"))
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user