Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bots/bugbuster/bot.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export default new sdk.BotDefinition({
timeToLintAll: {
schema: sdk.z.object({}),
},
timeToCheckIssuesState: {
schema: sdk.z.object({}),
},
},
recurringEvents: {
timeToLintAll: {
Expand All @@ -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, {
Expand Down
3 changes: 3 additions & 0 deletions bots/bugbuster/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,12 +13,14 @@ 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,
linear,
teamsManager,
recentlyLintedManager,
issueProcessor,
issueStateChecker,
}
}
1 change: 1 addition & 0 deletions bots/bugbuster/src/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
39 changes: 39 additions & 0 deletions bots/bugbuster/src/handlers/time-to-check-issues-state.ts
Original file line number Diff line number Diff line change
@@ -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...")
}
1 change: 1 addition & 0 deletions bots/bugbuster/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions bots/bugbuster/src/services/issue-processor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<types.LintResult> {
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.`)
Expand Down
11 changes: 6 additions & 5 deletions bots/bugbuster/src/services/issue-processor/lint-issue.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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, status: lin.StateKey): IssueLint[] => {
export const lintIssue = (issue: lin.Issue, state: types.StateKey): IssueLint[] => {
const lints: string[] = []

if (!_hasLabelOfCategory(issue, 'type')) {
Expand All @@ -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.`)
}

Expand All @@ -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.`)
}
Expand Down
38 changes: 38 additions & 0 deletions bots/bugbuster/src/services/issue-state-checker.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions bots/bugbuster/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 32 additions & 33 deletions bots/bugbuster/src/utils/linear-utils/client.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -74,32 +63,31 @@ 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))
if (!teamsExist) {
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,
Expand All @@ -123,7 +111,7 @@ export class LinearApi {
return label || undefined
}

public async issueStatus(issue: graphql.Issue): Promise<StateKey> {
public async issueState(issue: graphql.Issue): Promise<types.StateKey> {
const states = await this.getStates()
const state = states.find((s) => s.state.id === issue.state.id)
if (!state) {
Expand Down Expand Up @@ -177,23 +165,23 @@ export class LinearApi {
return this._states
}

public async getStateRecords(): Promise<Record<TeamKey, Record<StateKey, lin.WorkflowState>>> {
public async getStateRecords(): Promise<Record<TeamKey, Record<types.StateKey, lin.WorkflowState>>> {
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<TeamKey, Record<StateKey, lin.WorkflowState>>, {
return new Proxy({} as Record<TeamKey, Record<types.StateKey, lin.WorkflowState>>, {
get: (_, teamKey: TeamKey) => {
const teamId = teams[teamKey].id
if (!teamId) {
throw new Error(`Team with key "${teamKey}" not found.`)
}

return new Proxy({} as Record<StateKey, lin.WorkflowState>, {
get: (_, stateKey: StateKey) => {
return new Proxy({} as Record<types.StateKey, lin.WorkflowState>, {
get: (_, stateKey: types.StateKey) => {
const state = safeStates.find((s) => s.key === stateKey && s.state.teamId === teamId)

if (!state) {
Expand All @@ -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<lin.Team[]> => {
let teams: lin.Team[] = []
let cursor: string | undefined = undefined
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion bots/bugbuster/src/utils/linear-utils/graphql-queries.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as types from 'src/types'

const QUERY_INPUT = Symbol('graphqlInputType')
const QUERY_RESPONSE = Symbol('graphqlResponseType')

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading