diff --git a/bots/bugbuster/src/bootstrap.ts b/bots/bugbuster/src/bootstrap.ts index 798574925b9..71ed0d55df3 100644 --- a/bots/bugbuster/src/bootstrap.ts +++ b/bots/bugbuster/src/bootstrap.ts @@ -1,24 +1,23 @@ import { IssueProcessor } from './services/issue-processor' +import { RecentlyLintedManager } from './services/recently-linted-manager' import { TeamsManager } from './services/teams-manager' import * as types from './types' import * as utils from './utils' -export const bootstrap = async (props: types.CommonHandlerProps, conversationId?: string) => { +export const bootstrap = (props: types.CommonHandlerProps) => { const { client, logger, ctx } = props const botpress = utils.botpress.BotpressApi.create(props) - const _handleError = (context: string) => (thrown: unknown) => - botpress.handleError({ context, conversationId }, thrown) - - // TODO: make this synchronous so it won't slow down bootstraping or throw - const linear = await utils.linear.LinearApi.create().catch(_handleError('trying to initialize Linear API')) + const linear = utils.linear.LinearApi.create() const teamsManager = new TeamsManager(linear, client, ctx.botId) + const recentlyLintedManager = new RecentlyLintedManager(client, ctx.botId) const issueProcessor = new IssueProcessor(logger, linear, teamsManager) return { botpress, linear, teamsManager, + recentlyLintedManager, issueProcessor, } } diff --git a/bots/bugbuster/src/handlers/github-issue-opened.ts b/bots/bugbuster/src/handlers/github-issue-opened.ts index a51d35bf839..532757b35eb 100644 --- a/bots/bugbuster/src/handlers/github-issue-opened.ts +++ b/bots/bugbuster/src/handlers/github-issue-opened.ts @@ -6,7 +6,7 @@ export const handleGithubIssueOpened: bp.EventHandlers['github:issueOpened'] = a props.logger.info('Received GitHub issue', githubIssue) - const { linear, botpress } = await boot.bootstrap(props) + const { linear, botpress } = boot.bootstrap(props) const _handleError = (context: string) => @@ -21,10 +21,13 @@ export const handleGithubIssueOpened: bp.EventHandlers['github:issueOpened'] = a props.logger.error('Label origin/github not found in engineering team') } + const teams = await linear.getTeamRecords().catch(_handleError('trying to get Linear teams')) + const states = await linear.getStateRecords().catch(_handleError('trying to get Linear states')) + const linearResponse = await linear.client .createIssue({ - teamId: linear.teams.ENG.id, - stateId: linear.states.ENG.TRIAGE.id, + teamId: teams.ENG.id, + stateId: states.ENG.TRIAGE.id, title: githubIssue.issue.name, description: githubIssue.issue.body, labelIds: githubLabel ? [githubLabel.id] : [], diff --git a/bots/bugbuster/src/handlers/linear-issue-created.ts b/bots/bugbuster/src/handlers/linear-issue-created.ts index 1f3124fcd83..3bf928a605e 100644 --- a/bots/bugbuster/src/handlers/linear-issue-created.ts +++ b/bots/bugbuster/src/handlers/linear-issue-created.ts @@ -5,7 +5,7 @@ export const handleLinearIssueCreated: bp.EventHandlers['linear:issueCreated'] = const { event } = props const { number: issueNumber, teamKey } = event.payload - const { botpress, issueProcessor } = await boot.bootstrap(props) + const { botpress, issueProcessor } = boot.bootstrap(props) const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown) diff --git a/bots/bugbuster/src/handlers/linear-issue-updated.ts b/bots/bugbuster/src/handlers/linear-issue-updated.ts index 035e7452431..c80aa559a75 100644 --- a/bots/bugbuster/src/handlers/linear-issue-updated.ts +++ b/bots/bugbuster/src/handlers/linear-issue-updated.ts @@ -5,11 +5,10 @@ export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = const { event, logger } = props const { number: issueNumber, teamKey } = event.payload - const { botpress, issueProcessor } = await boot.bootstrap(props) + const { botpress, issueProcessor, recentlyLintedManager } = boot.bootstrap(props) const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown) - - props.logger.info('Linear issue updated event received', `${teamKey}-${issueNumber}`) + 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')) @@ -18,15 +17,14 @@ export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = return } - const recentlyLinted = await botpress.getRecentlyLinted().catch(_handleError('trying to get recently linted issues')) + const recentlyLinted = await recentlyLintedManager + .getRecentlyLinted() + .catch(_handleError('trying to get recently linted issues')) - if (recentlyLinted.some(({ id: issueId }) => issue.id === issueId)) { - logger.info(`Issue ${issue.identifier} has already been linted recently, skipping...`) - return - } + const isRecentlyLinted = recentlyLinted.some(({ id: issueId }) => issue.id === issueId) - await issueProcessor.lintIssue(issue).catch(_handleError('trying to lint the updated Linear issue')) - await botpress + await issueProcessor.lintIssue(issue, isRecentlyLinted).catch(_handleError('trying to lint the updated Linear issue')) + await recentlyLintedManager .setRecentlyLinted([...recentlyLinted, { id: issue.id, lintedAt: new Date().toISOString() }]) .catch(_handleError('trying to update recently linted issues')) } diff --git a/bots/bugbuster/src/handlers/lint-all.ts b/bots/bugbuster/src/handlers/lint-all.ts index 993b5165f38..b42966186fa 100644 --- a/bots/bugbuster/src/handlers/lint-all.ts +++ b/bots/bugbuster/src/handlers/lint-all.ts @@ -7,7 +7,7 @@ export const handleLintAll: bp.WorkflowHandlers['lintAll'] = async (props) => { const conversationId = conversation?.id - const { botpress, issueProcessor } = await boot.bootstrap(props, conversationId) + const { botpress, issueProcessor } = boot.bootstrap(props) const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context, conversationId }, thrown) @@ -25,22 +25,28 @@ export const handleLintAll: bp.WorkflowHandlers['lintAll'] = async (props) => { }) .catch(_handleError('trying to get last linted issue ID')) - const issues = await issueProcessor - .listRelevantIssues(lastLintedId) // TODO: we should not list all issues at first, bug fetch next page and lint progressively - .catch(_handleError('trying to list all issues')) - - for (const issue of issues) { - await issueProcessor.lintIssue(issue).catch(_handleError(`trying to lint issue ${issue.identifier}`)) - await workflow.acknowledgeStartOfProcessing().catch(_handleError('trying to acknowledge start of processing')) - await client - .setState({ - id: workflow.id, - name: 'lastLintedId', - type: 'workflow', - payload: { id: issue.id }, - }) - .catch(_handleError('trying to update last linted issue ID')) - } + let hasNextPage = false + let endCursor: string | undefined = lastLintedId + do { + const pagedIssues = await issueProcessor + .listRelevantIssues(endCursor) + .catch(_handleError('trying to list all issues')) + + for (const issue of pagedIssues.issues) { + await issueProcessor.lintIssue(issue).catch(_handleError(`trying to lint issue ${issue.identifier}`)) + await workflow.acknowledgeStartOfProcessing().catch(_handleError('trying to acknowledge start of processing')) + await client + .setState({ + id: workflow.id, + name: 'lastLintedId', + type: 'workflow', + payload: { id: issue.id }, + }) + .catch(_handleError('trying to update last linted issue ID')) + } + hasNextPage = pagedIssues.pagination?.hasNextPage ?? false + endCursor = pagedIssues.pagination?.endCursor + } while (hasNextPage) if (conversationId) { await botpress.respondText(conversationId, 'linted all issues').catch(() => {}) diff --git a/bots/bugbuster/src/handlers/message-created.ts b/bots/bugbuster/src/handlers/message-created.ts index c1e92ed7bc5..ca80a3710f8 100644 --- a/bots/bugbuster/src/handlers/message-created.ts +++ b/bots/bugbuster/src/handlers/message-created.ts @@ -17,7 +17,7 @@ export const handleMessageCreated: bp.MessageHandlers['*'] = async (props) => { return } - const { botpress, teamsManager } = await boot.bootstrap(props, conversation.id) + const { botpress, teamsManager } = boot.bootstrap(props) if (message.type !== 'text') { await botpress.respondText(conversation.id, COMMAND_LIST_MESSAGE) diff --git a/bots/bugbuster/src/services/issue-processor/index.ts b/bots/bugbuster/src/services/issue-processor/index.ts index 9f62936426c..8fbe9853f15 100644 --- a/bots/bugbuster/src/services/issue-processor/index.ts +++ b/bots/bugbuster/src/services/issue-processor/index.ts @@ -23,7 +23,7 @@ export class IssueProcessor { } const watchedTeams = await this._teamsManager.listWatchedTeams() - if (!this._linear.isTeam(teamKey) || !watchedTeams.includes(teamKey)) { + if (!(await this._linear.isTeam(teamKey)) || !watchedTeams.includes(teamKey)) { this._logger.info(`Ignoring issue of team "${teamKey}"`) return } @@ -37,31 +37,20 @@ export class IssueProcessor { return issue } - public async listRelevantIssues(endCursor?: string): Promise { + public async listRelevantIssues(endCursor?: string): Promise<{ issues: lin.Issue[]; pagination?: lin.Pagination }> { const watchedTeams = await this._teamsManager.listWatchedTeams() - const issues: lin.Issue[] = [] - let pagination: lin.Pagination | undefined - - do { - const { issues: newIssues, pagination: newPagination } = await this._linear.listIssues( - { - teamKeys: watchedTeams, - statusesToOmit: IGNORED_STATUSES, - }, - endCursor - ) - - issues.push(...newIssues) - pagination = newPagination - endCursor = pagination?.endCursor - } while (pagination?.hasNextPage) - - return issues + return await this._linear.listIssues( + { + teamKeys: watchedTeams, + statusesToOmit: IGNORED_STATUSES, + }, + endCursor + ) } - public async lintIssue(issue: lin.Issue) { - const status = this._linear.issueStatus(issue) + public async lintIssue(issue: lin.Issue, isRecentlyLinted?: boolean) { + const status = await this._linear.issueStatus(issue) if (IGNORED_STATUSES.includes(status) || issue.labels.nodes.some((label) => label.name === LINTIGNORE_LABEL_NAME)) { return } @@ -74,7 +63,13 @@ export class IssueProcessor { return } - this._logger.warn(`Issue ${issue.identifier} has ${errors.length} lint errors:`) + 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 + } + + this._logger.warn(warningMessage) await this._linear.client.createComment({ issueId: issue.id, diff --git a/bots/bugbuster/src/services/recently-linted-manager.ts b/bots/bugbuster/src/services/recently-linted-manager.ts new file mode 100644 index 00000000000..5fe1eb067e7 --- /dev/null +++ b/bots/bugbuster/src/services/recently-linted-manager.ts @@ -0,0 +1,42 @@ +import * as bp from '.botpress' + +const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes +type IssueLintEntry = bp.states.recentlyLinted.RecentlyLinted['payload']['issues'][number] + +export class RecentlyLintedManager { + public constructor( + private _client: bp.Client, + private _botId: string + ) {} + + public async getRecentlyLinted(): Promise { + const { + state: { + payload: { issues }, + }, + } = await this._client.getOrSetState({ + id: this._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._client.setState({ + id: this._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/services/teams-manager.ts b/bots/bugbuster/src/services/teams-manager.ts index 4db529f18dc..1904ee06de7 100644 --- a/bots/bugbuster/src/services/teams-manager.ts +++ b/bots/bugbuster/src/services/teams-manager.ts @@ -13,7 +13,7 @@ export class TeamsManager { if (teamKeys.includes(key)) { throw new Error(`The team with the key '${key}' is already being watched.`) } - if (!this._linear.isTeam(key)) { + if (!(await this._linear.isTeam(key))) { throw new Error(`The team with the key '${key}' does not exist.`) } diff --git a/bots/bugbuster/src/utils/botpress-utils.ts b/bots/bugbuster/src/utils/botpress-utils.ts index 00c4d127a42..9bd03aba939 100644 --- a/bots/bugbuster/src/utils/botpress-utils.ts +++ b/bots/bugbuster/src/utils/botpress-utils.ts @@ -2,18 +2,13 @@ import * as sdk from '@botpress/sdk' import * as types from '../types' import * as bp from '.botpress' -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] +type BotMessage = Pick -const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes - -export type ErrorHandlerProps = { +type ErrorHandlerProps = { context: string conversationId?: string } -// TODO: most of this class is not really meant to be in utils, consider moving it in services like the teams-manager class export class BotpressApi { private constructor( private _client: bp.Client, @@ -42,45 +37,6 @@ export class BotpressApi { }) } - public listGithubIssues = async (): Promise => { - const { - output: { targets: githubIssues }, - } = await this._client.callAction({ - type: 'github:findTarget', - input: { - channel: 'issue', - repo: 'botpress', - query: '', - }, - }) - return githubIssues - } - - public async getRecentlyLinted(): Promise { - const { - state: { - payload: { issues }, - }, - } = await this._client.getOrSetState({ - id: this._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._client.setState({ - id: this._botId, - type: 'bot', - name: 'recentlyLinted', - payload: { - issues: issues.filter(this._isRecentlyLinted), - }, - }) - } - public handleError = async (props: ErrorHandlerProps, thrown: unknown): Promise => { const error = thrown instanceof Error ? thrown : new Error(String(thrown)) const message = `An error occured while ${props.context}: ${error.message}` @@ -90,10 +46,4 @@ export class BotpressApi { } throw new sdk.RuntimeError(error.message) } - - 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/linear-utils/client.ts b/bots/bugbuster/src/utils/linear-utils/client.ts index aa7fc3de4ab..e1494788c8f 100644 --- a/bots/bugbuster/src/utils/linear-utils/client.ts +++ b/bots/bugbuster/src/utils/linear-utils/client.ts @@ -23,36 +23,33 @@ type State = { state: lin.WorkflowState; key: StateKey } const RESULTS_PER_PAGE = 200 export class LinearApi { - private constructor( - private _client: lin.LinearClient, - private _viewer: lin.User, - private _teams: lin.Team[], - private _states: State[] - ) {} - - 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.') - } + private _teams?: lin.Team[] = undefined + private _states?: State[] = undefined + private _viewer?: lin.User = undefined + + private constructor(private _client: lin.LinearClient) {} - const states = await this._listAllStates(client) - const teams = await this._listAllTeams(client) + public static create(): LinearApi { + const client = new lin.LinearClient({ apiKey: genenv.BUGBUSTER_LINEAR_API_KEY }) - return new LinearApi(client, me, teams, this._toStateObjects(states)) + return new LinearApi(client) } public get client(): lin.LinearClient { return this._client } - public get me(): lin.User { + public async getMe(): Promise { + const me = await this._client.viewer + if (!me) { + throw new Error('Viewer not found. Please ensure you are authenticated.') + } + this._viewer = me return this._viewer } - public isTeam(teamKey: string) { - return this._teams.some((team) => team.key === teamKey) + public async isTeam(teamKey: string) { + return (await this.getTeams()).some((team) => team.key === teamKey) } public async findIssue(filter: { teamKey: string; issueNumber: number }): Promise { @@ -80,13 +77,15 @@ export class LinearApi { ): Promise<{ issues: graphql.Issue[]; pagination?: graphql.Pagination }> { const { teamKeys, issueNumber, statusesToOmit } = filter - const teamsExist = teamKeys.every((key) => this._teams.some((team) => team.key === key)) + const teams = await this.getTeams() + const teamsExist = teamKeys.every((key) => teams.some((team) => team.key === key)) if (!teamsExist) { return { issues: [] } } + const states = await this.getStates() const stateNamesToOmit = statusesToOmit?.map((key) => { - const matchingStates = this._states.filter((state) => state.key === key) + const matchingStates = states.filter((state) => state.key === key) if (matchingStates[0]) { return matchingStates[0].state.name } @@ -121,8 +120,9 @@ export class LinearApi { return label || undefined } - public issueStatus(issue: graphql.Issue): StateKey { - const state = this._states.find((s) => s.state.id === issue.state.id) + public async issueStatus(issue: graphql.Issue): Promise { + const states = await this.getStates() + const state = states.find((s) => s.state.id === issue.state.id) if (!state) { throw new Error(`State with ID "${issue.state.id}" not found.`) } @@ -131,20 +131,33 @@ export class LinearApi { public async resolveComments(issue: graphql.Issue): Promise { const comments = issue.comments.nodes + const me = await this.getMe() const promises: Promise[] = [] for (const comment of comments) { - if (comment.user.id === this.me.id && !comment.resolvedAt) { + if (comment.user.id === me.id && !comment.resolvedAt) { promises.push(this._client.commentResolve(comment.id)) } } await Promise.all(promises) } - public get teams(): Record { + public async getTeams(): Promise { + if (!this._teams) { + this._teams = await LinearApi._listAllTeams(this._client) + } + return this._teams + } + + public async getTeamRecords(): Promise> { + if (!this._teams) { + this._teams = await LinearApi._listAllTeams(this._client) + } + const safeTeams = this._teams + return new Proxy({} as Record, { - get: (_, key: TeamKey) => { - const team = this._teams.find((t) => t.key === key) + get: async (_, key: TeamKey): Promise => { + const team = safeTeams.find((t) => t.key === key) if (!team) { throw new Error(`Team with key "${key}" not found.`) } @@ -153,17 +166,32 @@ export class LinearApi { }) } - public get states(): Record> { + public async getStates(): Promise { + if (!this._states) { + const states = await LinearApi._listAllStates(this._client) + this._states = LinearApi._toStateObjects(states) + } + return this._states + } + + public async getStateRecords(): Promise>> { + if (!this._states) { + const states = await LinearApi._listAllStates(this._client) + this._states = LinearApi._toStateObjects(states) + } + const safeStates = this._states + const teams = await this.getTeamRecords() + return new Proxy({} as Record>, { get: (_, teamKey: TeamKey) => { - const teamId = this.teams[teamKey].id + const teamId = 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) => s.key === stateKey && s.state.teamId === teamId) + const state = safeStates.find((s) => s.key === stateKey && s.state.teamId === teamId) if (!state) { throw new Error(`State with key "${stateKey}" not found.`)