diff --git a/bots/bugbuster/bot.definition.ts b/bots/bugbuster/bot.definition.ts index 6aedb184a1b..364f7e99e49 100644 --- a/bots/bugbuster/bot.definition.ts +++ b/bots/bugbuster/bot.definition.ts @@ -61,6 +61,9 @@ export default new sdk.BotDefinition({ timeToLintAll: { schema: sdk.z.object({}), }, + timeToCheckIssuesState: { + schema: sdk.z.object({}), + }, }, recurringEvents: { timeToLintAll: { @@ -70,6 +73,13 @@ export default new sdk.BotDefinition({ cron: '0 8 * * 1', }, }, + timeToCheckIssuesState: { + payload: sdk.z.object({}), + type: 'timeToCheckIssuesState', + schedule: { + cron: '0 * * * *', + }, + }, }, }) .addIntegration(github, { diff --git a/bots/bugbuster/src/bootstrap.ts b/bots/bugbuster/src/bootstrap.ts index 21533d12808..a3e30a6691b 100644 --- a/bots/bugbuster/src/bootstrap.ts +++ b/bots/bugbuster/src/bootstrap.ts @@ -1,4 +1,5 @@ import { IssueProcessor } from './services/issue-processor' +import { IssueStateChecker } from './services/issue-state-checker' import { RecentlyLintedManager } from './services/recently-linted-manager' import { TeamsManager } from './services/teams-manager' import * as types from './types' @@ -12,6 +13,7 @@ export const bootstrap = (props: types.CommonHandlerProps) => { const teamsManager = new TeamsManager(linear, client, ctx.botId) const recentlyLintedManager = new RecentlyLintedManager(linear) const issueProcessor = new IssueProcessor(logger, linear, teamsManager) + const issueStateChecker = new IssueStateChecker(linear, logger) return { botpress, @@ -19,5 +21,6 @@ export const bootstrap = (props: types.CommonHandlerProps) => { teamsManager, recentlyLintedManager, issueProcessor, + issueStateChecker, } } diff --git a/bots/bugbuster/src/handlers/index.ts b/bots/bugbuster/src/handlers/index.ts index 51ba763f661..0739cf755e8 100644 --- a/bots/bugbuster/src/handlers/index.ts +++ b/bots/bugbuster/src/handlers/index.ts @@ -4,3 +4,4 @@ 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' diff --git a/bots/bugbuster/src/handlers/time-to-check-issues-state.ts b/bots/bugbuster/src/handlers/time-to-check-issues-state.ts new file mode 100644 index 00000000000..03419285f50 --- /dev/null +++ b/bots/bugbuster/src/handlers/time-to-check-issues-state.ts @@ -0,0 +1,39 @@ +import * as types from 'src/types' +import * as boot from '../bootstrap' +import * as bp from '.botpress' + +const statesToProcess: types.StateAttributes[] = [ + { + stateKey: '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`, + }, + { + stateKey: '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...") +} diff --git a/bots/bugbuster/src/index.ts b/bots/bugbuster/src/index.ts index c739a1a0e85..5aaacbf5166 100644 --- a/bots/bugbuster/src/index.ts +++ b/bots/bugbuster/src/index.ts @@ -7,6 +7,7 @@ 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) diff --git a/bots/bugbuster/src/services/issue-processor/index.ts b/bots/bugbuster/src/services/issue-processor/index.ts index 9d2b4120b52..e202bc8079f 100644 --- a/bots/bugbuster/src/services/issue-processor/index.ts +++ b/bots/bugbuster/src/services/issue-processor/index.ts @@ -4,7 +4,7 @@ import * as lin from '../../utils/linear-utils' import * as tm from '../teams-manager' import { lintIssue } from './lint-issue' -const IGNORED_STATUSES: lin.StateKey[] = ['TRIAGE', 'PRODUCTION_DONE', 'CANCELED', 'STALE'] +const IGNORED_STATES: types.StateKey[] = ['TRIAGE', 'PRODUCTION_DONE', 'CANCELED', 'STALE'] const LINTIGNORE_LABEL_NAME = 'lintignore' export class IssueProcessor { @@ -44,19 +44,19 @@ export class IssueProcessor { return await this._linear.listIssues( { teamKeys: watchedTeams, - statusesToOmit: IGNORED_STATUSES, + statesToOmit: IGNORED_STATES, }, endCursor ) } public async lintIssue(issue: lin.Issue, isRecentlyLinted?: boolean): Promise { - const status = await this._linear.issueStatus(issue) - if (IGNORED_STATUSES.includes(status) || issue.labels.nodes.some((label) => label.name === LINTIGNORE_LABEL_NAME)) { + const state = await this._linear.issueState(issue) + 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, status) + const errors = lintIssue(issue, state) if (errors.length === 0) { this._logger.info(`Issue ${issue.identifier} passed all lint checks.`) diff --git a/bots/bugbuster/src/services/issue-processor/lint-issue.ts b/bots/bugbuster/src/services/issue-processor/lint-issue.ts index c2465e7f457..22828a46ba6 100644 --- a/bots/bugbuster/src/services/issue-processor/lint-issue.ts +++ b/bots/bugbuster/src/services/issue-processor/lint-issue.ts @@ -1,3 +1,4 @@ +import * as types from '../../types' import * as lin from '../../utils/linear-utils' import { isIssueTitleFormatValid } from './issue-title-format-validator' @@ -5,7 +6,7 @@ export type IssueLint = { message: string } -export const lintIssue = (issue: lin.Issue, status: lin.StateKey): IssueLint[] => { +export const lintIssue = (issue: lin.Issue, state: types.StateKey): IssueLint[] => { const lints: string[] = [] if (!_hasLabelOfCategory(issue, 'type')) { @@ -15,13 +16,13 @@ export const lintIssue = (issue: lin.Issue, status: lin.StateKey): IssueLint[] = const hasBlockedLabel = _hasLabelOfCategory(issue, 'blocked') const hasBlockedRelation = issue.inverseRelations.nodes.some((relation) => relation.type === 'blocks') - if (status === 'BLOCKED' && !issue.assignee) { + if (state === 'BLOCKED' && !issue.assignee) { lints.push(`Issue ${issue.identifier} is blocked but has no assignee.`) } - if (status === 'BLOCKED' && !hasBlockedLabel && !hasBlockedRelation) { + if (state === 'BLOCKED' && !hasBlockedLabel && !hasBlockedRelation) { lints.push(`Issue ${issue.identifier} is blocked but missing a "blocked" label or a blocking issue.`) } - if (status === 'BACKLOG' && issue.assignee) { + if (state === 'BACKLOG' && issue.assignee) { lints.push(`Issue ${issue.identifier} has an assignee but is still in the backlog.`) } @@ -34,7 +35,7 @@ export const lintIssue = (issue: lin.Issue, status: lin.StateKey): IssueLint[] = lints.push(`Issue ${issue.identifier} is missing a priority.`) } - if (issue.estimate === null && status !== 'BLOCKED') { + if (issue.estimate === null && state !== 'BLOCKED') { // blocked issues can be unestimated lints.push(`Issue ${issue.identifier} is missing an estimate.`) } diff --git a/bots/bugbuster/src/services/issue-state-checker.ts b/bots/bugbuster/src/services/issue-state-checker.ts new file mode 100644 index 00000000000..62fdec3397e --- /dev/null +++ b/bots/bugbuster/src/services/issue-state-checker.ts @@ -0,0 +1,38 @@ +import * as sdk from '@botpress/sdk' +import * as types from '../types' +import * as lin from '../utils/linear-utils' + +export class IssueStateChecker { + public constructor( + private _linear: lin.LinearApi, + private _logger: sdk.BotLogger + ) {} + + public async processIssues(props: { stateAttributes: types.StateAttributes; teams: string[] }) { + const { stateAttributes, teams } = props + + let hasNextPage = false + let endCursor: string | undefined = undefined + do { + const { issues, pagination } = await this._linear.listIssues( + { + teamKeys: teams, + statesToInclude: [stateAttributes.stateKey], + updatedBefore: stateAttributes.maxTimeSinceLastUpdate, + }, + endCursor + ) + + for (const issue of issues) { + await this._linear.client.createComment({ + issueId: issue.id, + body: stateAttributes.warningComment, + }) + this._logger.warn(stateAttributes.buildWarningReason(issue.identifier)) + } + + hasNextPage = pagination?.hasNextPage ?? false + endCursor = pagination?.endCursor + } while (hasNextPage) + } +} diff --git a/bots/bugbuster/src/types.ts b/bots/bugbuster/src/types.ts index 7f1b98e1f25..608e581614e 100644 --- a/bots/bugbuster/src/types.ts +++ b/bots/bugbuster/src/types.ts @@ -12,3 +12,25 @@ export type LintResult = identifier: string result: 'succeeded' | 'ignored' } + +const STATE_KEYS = [ + 'IN_PROGRESS', + 'STAGING', + 'PRODUCTION_DONE', + 'BACKLOG', + 'TODO', + 'TRIAGE', + 'CANCELED', + 'BLOCKED', + 'STALE', +] as const +export type StateKey = (typeof STATE_KEYS)[number] + +export type StateAttributes = { + stateKey: StateKey + maxTimeSinceLastUpdate: ISO8601Duration + warningComment: string + buildWarningReason: (issueIdentifier: string) => string +} + +export type ISO8601Duration = string diff --git a/bots/bugbuster/src/utils/linear-utils/client.ts b/bots/bugbuster/src/utils/linear-utils/client.ts index 76fb5643575..4c17e1ec21f 100644 --- a/bots/bugbuster/src/utils/linear-utils/client.ts +++ b/bots/bugbuster/src/utils/linear-utils/client.ts @@ -1,24 +1,13 @@ import * as lin from '@linear/sdk' import * as utils from '..' import * as genenv from '../../../.genenv' +import * as types from '../../types' import * as graphql from './graphql-queries' 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] -type State = { state: lin.WorkflowState; key: StateKey } +type TeamKey = (typeof TEAM_KEYS)[number] + +type State = { state: lin.WorkflowState; key: types.StateKey } const RESULTS_PER_PAGE = 200 @@ -74,11 +63,13 @@ export class LinearApi { filter: { teamKeys: string[] issueNumber?: number - statusesToOmit?: StateKey[] + statesToOmit?: types.StateKey[] + statesToInclude?: types.StateKey[] + updatedBefore?: types.ISO8601Duration }, nextPage?: string ): Promise<{ issues: graphql.Issue[]; pagination?: graphql.Pagination }> { - const { teamKeys, issueNumber, statusesToOmit } = filter + const { teamKeys, issueNumber, statesToOmit, statesToInclude, updatedBefore } = filter const teams = await this.getTeams() const teamsExist = teamKeys.every((key) => teams.some((team) => team.key === key)) @@ -86,20 +77,17 @@ export class LinearApi { return { issues: [] } } - const states = await this.getStates() - const stateNamesToOmit = statusesToOmit?.map((key) => { - const matchingStates = states.filter((state) => state.key === key) - if (matchingStates[0]) { - return matchingStates[0].state.name - } - return '' - }) - const queryInput: graphql.GRAPHQL_QUERIES['listIssues'][graphql.QUERY_INPUT] = { filter: { team: { key: { in: teamKeys } }, ...(issueNumber && { number: { eq: issueNumber } }), - ...(stateNamesToOmit && { state: { name: { nin: stateNamesToOmit } } }), + state: { + name: { + ...(statesToOmit && { nin: await this._stateKeysToStates(statesToOmit) }), + ...(statesToInclude && { in: await this._stateKeysToStates(statesToInclude) }), + }, + }, + ...(updatedBefore && { updatedAt: { lt: updatedBefore } }), }, ...(nextPage && { after: nextPage }), first: RESULTS_PER_PAGE, @@ -123,7 +111,7 @@ export class LinearApi { return label || undefined } - public async issueStatus(issue: graphql.Issue): Promise { + public async issueState(issue: graphql.Issue): Promise { const states = await this.getStates() const state = states.find((s) => s.state.id === issue.state.id) if (!state) { @@ -177,7 +165,7 @@ export class LinearApi { return this._states } - public async getStateRecords(): Promise>> { + public async getStateRecords(): Promise>> { if (!this._states) { const states = await LinearApi._listAllStates(this._client) this._states = LinearApi._toStateObjects(states) @@ -185,15 +173,15 @@ export class LinearApi { const safeStates = this._states const teams = await this.getTeamRecords() - return new Proxy({} as Record>, { + return new Proxy({} as Record>, { get: (_, teamKey: TeamKey) => { const teamId = teams[teamKey].id if (!teamId) { throw new Error(`Team with key "${teamKey}" not found.`) } - return new Proxy({} as Record, { - get: (_, stateKey: StateKey) => { + return new Proxy({} as Record, { + get: (_, stateKey: types.StateKey) => { const state = safeStates.find((s) => s.key === stateKey && s.state.teamId === teamId) if (!state) { @@ -206,6 +194,17 @@ export class LinearApi { }) } + private async _stateKeysToStates(keys: types.StateKey[]) { + const states = await this.getStates() + return keys?.map((key) => { + const matchingStates = states.filter((state) => state.key === key) + if (matchingStates[0]) { + return matchingStates[0].state.name + } + return '' + }) + } + private static _listAllTeams = async (client: lin.LinearClient): Promise => { let teams: lin.Team[] = [] let cursor: string | undefined = undefined @@ -231,7 +230,7 @@ export class LinearApi { private static _toStateObjects(states: lin.WorkflowState[]): State[] { const stateObjects: State[] = [] for (const state of states) { - const key = utils.string.toScreamingSnakeCase(state.name) as StateKey + const key = utils.string.toScreamingSnakeCase(state.name) as types.StateKey stateObjects.push({ key, state }) } return stateObjects diff --git a/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts b/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts index a2fba0ce1af..ec4967352e3 100644 --- a/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts +++ b/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts @@ -1,3 +1,5 @@ +import * as types from 'src/types' + const QUERY_INPUT = Symbol('graphqlInputType') const QUERY_RESPONSE = Symbol('graphqlResponseType') @@ -110,9 +112,13 @@ export const GRAPHQL_QUERIES = { number?: { eq: number } state?: { name: { - nin: string[] + nin?: string[] + in?: string[] } } + updatedAt?: { + lt: types.ISO8601Duration + } } after?: string first?: number diff --git a/integrations/notion/src/notion-api/notion-oauth-client.ts b/integrations/notion/src/notion-api/notion-oauth-client.ts index 9e69390a177..f7b0a574071 100644 --- a/integrations/notion/src/notion-api/notion-oauth-client.ts +++ b/integrations/notion/src/notion-api/notion-oauth-client.ts @@ -16,7 +16,6 @@ export class NotionOAuthClient { this._notion = new NotionHQClient({}) } - @handleErrors('Failed to get access token. Please reconfigure the integration.') public async getNewAccessToken() { const { authToken } = await this._getAuthState() diff --git a/integrations/notion/src/setup.ts b/integrations/notion/src/setup.ts index 157189f69f3..81ce0f49376 100644 --- a/integrations/notion/src/setup.ts +++ b/integrations/notion/src/setup.ts @@ -3,11 +3,13 @@ import { NotionClient } from './notion-api' import * as bp from '.botpress' export const register: bp.IntegrationProps['register'] = async (props) => { - const notionClient = await NotionClient.create(props) - await notionClient.testAuthentication().catch((thrown) => { + try { + const notionClient = await NotionClient.create(props) + await notionClient.testAuthentication() + } catch (thrown) { const error = thrown instanceof Error ? thrown : new Error(String(thrown)) - throw new RuntimeError(`Failed to test authentication: ${error.message}`) - }) + throw new RuntimeError(`Registering Notion integration failed: ${error.message}`) + } } export const unregister: bp.IntegrationProps['unregister'] = async (props) => { diff --git a/integrations/notion/src/webhook-events/handler-dispatcher.ts b/integrations/notion/src/webhook-events/handler-dispatcher.ts index 1a7340d429b..512d182d628 100644 --- a/integrations/notion/src/webhook-events/handler-dispatcher.ts +++ b/integrations/notion/src/webhook-events/handler-dispatcher.ts @@ -12,17 +12,24 @@ export const handler: bp.IntegrationProps['handler'] = async (props) => { _validatePayloadSignature(props) - if (handlers.isDatabaseDeletedEvent(props)) { - return await handlers.handleDatabaseDeletedEvent(props) - } else if (handlers.isPageCreatedEvent(props)) { - return await handlers.handlePageCreatedEvent(props) - } else if (handlers.isPageDeletedEvent(props)) { - return await handlers.handlePageDeletedEvent(props) - } else if (handlers.isPageMovedEvent(props)) { - return await handlers.handlePageMovedEvent(props) + try { + if (handlers.isDatabaseDeletedEvent(props)) { + return await handlers.handleDatabaseDeletedEvent(props) + } else if (handlers.isPageCreatedEvent(props)) { + return await handlers.handlePageCreatedEvent(props) + } else if (handlers.isPageDeletedEvent(props)) { + return await handlers.handlePageDeletedEvent(props) + } else if (handlers.isPageMovedEvent(props)) { + return await handlers.handlePageMovedEvent(props) + } + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)) + props.logger.forBot().error(`Handling webhook event failed: ${error.message}`) + return { status: 200 } } - throw new sdk.RuntimeError('Unsupported webhook event') + props.logger.forBot().info('Unsupported webhook event received') + return { status: 200 } } const _validatePayloadSignature = (props: bp.HandlerProps) => {