diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2dbb7..abaa851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,40 @@ jobs: - name: Run yarn lint run: yarn lint + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v3.0.2 + + - name: Use Node.js ${{ env.node-NODE_VERSION }} + uses: actions/setup-node@v3.4.1 + with: + node-version: ${{ env.node-NODE_VERSION }} + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn config get cacheFolder)" + + - name: Check yarn cache + uses: actions/cache@v3.0.7 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Run tests + run: yarn run test + build-container: name: Build container - needs: lint + needs: test runs-on: ubuntu-latest steps: - name: Checkout the repository diff --git a/package.json b/package.json index 5b01bac..fc94d7c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "start:bots": "nest start bots --watch", "format": "prettier --write \"services/**/*.ts\" \"libs/**/*.ts\"", "lint": "eslint \"{src,services,libs,test}/**/*.ts\" --fix", - "prebuild": "rimraf dist" + "prebuild": "rimraf dist", + "test": "ts-mocha --recursive --timeout 10000 tests/**/*.spec.ts", + "test-watch": "ts-mocha --recursive --timeout 10000 --watch tests/**/*.spec.ts" }, "dependencies": { "@dev-thought/nestjs-github-webhooks": "^1.0.0", @@ -42,6 +44,7 @@ "@sentry/node": "^6.19.7", "apollo-server-express": "^3.6.7", "aws-sdk": "^2.1211.0", + "codeowners-utils": "^1.0.2", "convict": "^6.2.3", "discord.js": "^14.3.0", "find-up": "^4.0.0", @@ -66,10 +69,13 @@ "eslint": "8.14.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", + "mocha": "^10.0.0", "prettier": "^2.6.2", + "sinon": "^14.0.0", "source-map-support": "^0.5.21", "ts-jest": "27.1.4", "ts-loader": "^9.3.0", + "ts-mocha": "^10.0.0", "ts-node": "^10.7.0", "tsconfig-paths": "^3.14.1", "typescript": "^4.6.4" @@ -102,4 +108,4 @@ "@lib/sentry": "/libs/sentry/src" } } -} \ No newline at end of file +} diff --git a/services/bots/src/cla-sign/cla-sign.service.ts b/services/bots/src/cla-sign/cla-sign.service.ts index ba5e4e0..d03bfbd 100644 --- a/services/bots/src/cla-sign/cla-sign.service.ts +++ b/services/bots/src/cla-sign/cla-sign.service.ts @@ -2,18 +2,18 @@ import { ServiceError } from '@lib/common'; import { ClaIssueLabel } from '@lib/common/github'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Octokit } from '@octokit/rest'; import { DynamoDB } from 'aws-sdk'; +import { GithubClient } from '../github-webhook/github-webhook.model'; @Injectable() export class ClaSignService { - private githubApiClient: Octokit; + private githubApiClient: GithubClient; private ddbClient: DynamoDB; private signersTableName: string; private pendingSignersTableName: string; constructor(configService: ConfigService) { - this.githubApiClient = new Octokit({ auth: configService.get('github.token') }); + this.githubApiClient = new GithubClient({ auth: configService.get('github.token') }); this.ddbClient = new DynamoDB({ region: configService.get('dynamodb.cla.region') }); this.signersTableName = configService.get('dynamodb.cla.signersTable'); diff --git a/services/bots/src/github-webhook/github-webhook.const.ts b/services/bots/src/github-webhook/github-webhook.const.ts index 49820f6..2a5294e 100644 --- a/services/bots/src/github-webhook/github-webhook.const.ts +++ b/services/bots/src/github-webhook/github-webhook.const.ts @@ -2,7 +2,106 @@ import { RestEndpointMethodTypes } from '@octokit/rest'; import { EventPayloadMap } from '@octokit/webhooks-types'; import { BaseWebhookHandler } from './handlers/base'; +export const HOME_ASSISTANT_ORG = 'home-assistant'; + export const WEBHOOK_HANDLERS: BaseWebhookHandler[] = []; export type PullRequestEventData = EventPayloadMap['pull_request']; -export type ListCommitResponse = RestEndpointMethodTypes['pulls']['listCommits']['response']; +export type IssuesEventData = EventPayloadMap['issues']; +export type ListPullRequestFiles = + RestEndpointMethodTypes['pulls']['listFiles']['response']['data']; +export type GetPullRequestParams = RestEndpointMethodTypes['pulls']['get']['parameters']; +export type GetPullRequestResponse = RestEndpointMethodTypes['pulls']['get']['response']['data']; +export type GetIssueParams = RestEndpointMethodTypes['issues']['get']['parameters']; +export type GetIssueResponse = RestEndpointMethodTypes['issues']['get']['response']['data']; + +export enum Repository { + BRANDS = 'brands', + CORE = 'core', + DEVELOPERS_HOME_ASSISTANT = 'developers.home-assistant', + HOME_ASSISTANT_IO = 'home-assistant.io', +} + +export const entityComponents = new Set([ + 'air_quality', + 'alarm_control_panel', + 'automation', + 'binary_sensor', + 'calendar', + 'camera', + 'climate', + 'cover', + 'device_tracker', + 'fan', + 'geo_location', + 'image_processing', + 'light', + 'lock', + 'mailbox', + 'media_player', + 'notify', + 'remote', + 'scene', + 'sensor', + 'switch', + 'tts', + 'vacuum', + 'water_heater', + 'weather', +]); + +export const coreComponents = new Set([ + ...entityComponents, + 'alexa', + 'api', + 'auth', + 'cloud', + 'config', + 'configurator', + 'conversation', + 'counter', + 'default_config', + 'demo', + 'discovery', + 'ffmpeg', + 'frontend', + 'google_assistant', + 'group', + 'hassio', + 'homeassistant', + 'history', + 'http', + 'input_boolean', + 'input_datetime', + 'input_number', + 'input_select', + 'input_text', + 'introduction', + 'ios', + 'logbook', + 'logger', + 'lovelace', + 'map', + 'mobile_app', + 'mqtt', + 'onboarding', + 'panel_custom', + 'panel_iframe', + 'persistent_notification', + 'person', + 'recorder', + 'script', + 'scene', + 'shell_command', + 'shopping_list', + 'stream', + 'sun', + 'system_health', + 'system_log', + 'timer', + 'updater', + 'webhook', + 'weblink', + 'websocket_api', + 'zone', +]); diff --git a/services/bots/src/github-webhook/github-webhook.controller.ts b/services/bots/src/github-webhook/github-webhook.controller.ts index 05db890..afee7b2 100644 --- a/services/bots/src/github-webhook/github-webhook.controller.ts +++ b/services/bots/src/github-webhook/github-webhook.controller.ts @@ -2,12 +2,11 @@ import { Body, Controller, Post, UseGuards, Headers } from '@nestjs/common'; import { GithubWebhookService } from './github-webhook.service'; import { GithubGuard, GithubWebhookEvents } from '@dev-thought/nestjs-github-webhooks'; -import { WebhookContext } from './github-webhook.model'; @Controller('/github-webhook') @UseGuards(GithubGuard) export class GithubWebhookController { - constructor(private readonly GithubWebhookService: GithubWebhookService) {} + constructor(private readonly githubWebhookService: GithubWebhookService) {} @Post() @GithubWebhookEvents([]) @@ -15,8 +14,6 @@ export class GithubWebhookController { @Headers() headers: Record, @Body() payload: Record, ): Promise { - await this.GithubWebhookService.handleWebhook( - new WebhookContext({ eventType: `${headers['x-github-event']}.${payload.action}`, payload }), - ); + await this.githubWebhookService.handleWebhook(headers, payload); } } diff --git a/services/bots/src/github-webhook/github-webhook.model.ts b/services/bots/src/github-webhook/github-webhook.model.ts index 7b7918e..61bb06e 100644 --- a/services/bots/src/github-webhook/github-webhook.model.ts +++ b/services/bots/src/github-webhook/github-webhook.model.ts @@ -1,46 +1,94 @@ -interface WebhookContextParams { - payload: Record; +import { Octokit } from '@octokit/rest'; +import { + GetIssueParams, + GetIssueResponse, + GetPullRequestParams, + GetPullRequestResponse, + ListPullRequestFiles, +} from './github-webhook.const'; + +export class GithubClient extends Octokit {} + +interface WebhookContextParams { + github: GithubClient; + payload: E; eventType: string; } -export class WebhookContext { +export class WebhookContext { + public github: GithubClient; public eventType: string; - public payload: Record; - public scheduledComments: { context: string; comment: string }[] = []; + public payload: E; + public scheduledComments: { handler: string; comment: string }[] = []; public scheduledlabels: string[] = []; - constructor(params: WebhookContextParams) { + public _prFilesCache?: ListPullRequestFiles; + private _issueRequestCache: { [key: string]: GetIssueResponse } = {}; + private _pullRequestCache: { [key: string]: GetPullRequestResponse } = {}; + + constructor(params: WebhookContextParams) { + this.github = params.github; this.eventType = params.eventType; this.payload = params.payload; } - public repo(data?: T) { + public get senderIsBot(): boolean { + return ( + (this.payload as any).sender.type === 'Bot' || + (this.payload as any).sender.login === 'homeassistant' + ); + } + + public repo(data?: T): { owner: string; repo: string } & T { return { - owner: this.payload.repository.owner.login, - repo: this.payload.repository.name, + owner: (this.payload as any).repository.owner.login, + repo: (this.payload as any).repository.name, ...data, }; } public issue(data?: T) { return { - issue_number: (this.payload.issue || this.payload.pull_request || this.payload).number, + issue_number: ((this.payload as any).issue?.number || + (this.payload as any).pull_request?.number || + (this.payload as any)?.number) as number, ...this.repo(data), }; } public pullRequest(data?: T) { return { - pull_number: (this.payload.issue || this.payload.pull_request || this.payload).number, + pull_number: ( + (this.payload as any).issue || + (this.payload as any).pull_request || + this.payload + ).number as number, ...this.repo(data), }; } - public scheduleIssueComment(context: string, comment: string): void { - this.scheduledComments.push({ context, comment }); + public scheduleIssueComment(handler: string, comment: string): void { + this.scheduledComments.push({ handler, comment }); } public scheduleIssueLabel(label: string): void { this.scheduledlabels.push(label); } + + public async fetchIssueWithCache(params: GetIssueParams): Promise { + const key = `${params.owner}/${params.repo}/${params.pull_number}`; + if (!(key in this._issueRequestCache)) { + this._issueRequestCache[key] = (await this.github.issues.get(params)).data; + } + return this._issueRequestCache[key]; + } + public async fetchPullRequestWithCache( + params: GetPullRequestParams, + ): Promise { + const key = `${params.owner}/${params.repo}/${params.pull_number}`; + if (!(key in this._pullRequestCache)) { + this._pullRequestCache[key] = (await this.github.pulls.get(params)).data; + } + return this._pullRequestCache[key]; + } } diff --git a/services/bots/src/github-webhook/github-webhook.module.ts b/services/bots/src/github-webhook/github-webhook.module.ts index 4edfb8a..c5fa2d6 100644 --- a/services/bots/src/github-webhook/github-webhook.module.ts +++ b/services/bots/src/github-webhook/github-webhook.module.ts @@ -5,10 +5,39 @@ import { GithubWebhooksModule } from '@dev-thought/nestjs-github-webhooks'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AppConfig } from '../config'; import { GithubWebhookController } from './github-webhook.controller'; +import { BranchLabels } from './handlers/branch_labels'; +import { CodeOwnersMention } from './handlers/code_owners_mention'; +import { DependencyBump } from './handlers/dependency_bump'; +import { DocsMissing } from './handlers/docs_missing'; +import { DocsParenting } from './handlers/docs_parenting'; +import { DocsTargetBranch } from './handlers/docs_target_branch'; +import { Hacktoberfest } from './handlers/hacktoberfest'; +import { IssueLinks } from './handlers/issue_links'; +import { LabelBot } from './handlers/label_bot/handler'; +import { LabelCleaner } from './handlers/label_cleaner'; +import { ReviewEnforcer } from './handlers/review_enforcer'; +import { SetDocumentationSection } from './handlers/set_documentation_section'; +import { SetIntegration } from './handlers/set_integration'; import { ValidateCla } from './handlers/validate-cla'; @Module({ - providers: [GithubWebhookService, ValidateCla], + providers: [ + BranchLabels, + CodeOwnersMention, + DependencyBump, + DocsMissing, + DocsParenting, + DocsTargetBranch, + GithubWebhookService, + Hacktoberfest, + IssueLinks, + LabelBot, + LabelCleaner, + ReviewEnforcer, + SetDocumentationSection, + SetIntegration, + ValidateCla, + ], imports: [ GithubWebhooksModule.forRootAsync({ imports: [ConfigModule], diff --git a/services/bots/src/github-webhook/github-webhook.service.ts b/services/bots/src/github-webhook/github-webhook.service.ts index be7a165..babd49f 100644 --- a/services/bots/src/github-webhook/github-webhook.service.ts +++ b/services/bots/src/github-webhook/github-webhook.service.ts @@ -2,19 +2,23 @@ import { ServiceError } from '@lib/common'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Octokit } from '@octokit/rest'; import { WEBHOOK_HANDLERS } from './github-webhook.const'; -import { WebhookContext } from './github-webhook.model'; +import { GithubClient, WebhookContext } from './github-webhook.model'; @Injectable() export class GithubWebhookService { - private githubApiClient: Octokit; + private githubClient: GithubClient; constructor(configService: ConfigService) { - this.githubApiClient = new Octokit({ auth: configService.get('github.token') }); + this.githubClient = new GithubClient({ auth: configService.get('github.token') }); } - async handleWebhook(context: WebhookContext): Promise { + async handleWebhook(headers: Record, payload: Record): Promise { + const context = new WebhookContext({ + github: this.githubClient, + eventType: `${headers['x-github-event']}.${payload.action}`, + payload, + }); try { await Promise.all(WEBHOOK_HANDLERS.map((handler) => handler.handle(context))); } catch (err) { @@ -22,7 +26,7 @@ export class GithubWebhookService { } if (context.scheduledlabels.length) { - await this.githubApiClient.issues.addLabels( + await this.githubClient.issues.addLabels( context.issue({ labels: context.scheduledlabels, }), @@ -30,14 +34,14 @@ export class GithubWebhookService { } if (context.scheduledComments.length) { - await this.githubApiClient.issues.createComment( + await this.githubClient.issues.createComment( context.issue({ body: context.scheduledComments .map( (entry) => `${entry.comment}${ context.scheduledComments.length >= 2 - ? `\n(message by ${entry.context})` + ? `\n(message by ${entry.handler})` : '' }`, ) diff --git a/services/bots/src/github-webhook/handlers/base.ts b/services/bots/src/github-webhook/handlers/base.ts index a621c09..ef75041 100644 --- a/services/bots/src/github-webhook/handlers/base.ts +++ b/services/bots/src/github-webhook/handlers/base.ts @@ -1,17 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Octokit } from '@octokit/rest'; import { WEBHOOK_HANDLERS } from '../github-webhook.const'; import { WebhookContext } from '../github-webhook.model'; @Injectable() export class BaseWebhookHandler { - protected githubApiClient: Octokit; - - constructor(configService: ConfigService) { - this.githubApiClient = new Octokit({ auth: configService.get('github.token') }); + constructor() { WEBHOOK_HANDLERS.push(this); } - async handle(context: WebhookContext) {} + async handle(context: WebhookContext) {} } diff --git a/services/bots/src/github-webhook/handlers/branch_labels.ts b/services/bots/src/github-webhook/handlers/branch_labels.ts new file mode 100644 index 0000000..22423fc --- /dev/null +++ b/services/bots/src/github-webhook/handlers/branch_labels.ts @@ -0,0 +1,34 @@ +import { PullRequestEditedEvent, PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { BaseWebhookHandler } from './base'; + +const BRANCH_LABELS: { [key: string]: Set } = { + [Repository.HOME_ASSISTANT_IO]: new Set(['current', 'rc', 'next']), +}; + +export class BranchLabels extends BaseWebhookHandler { + async handle(context: WebhookContext) { + const reposiotyName = context.repo().repo; + if ( + !['pull_request.opened', 'pull_request.edited'].includes(context.eventType) || + !BRANCH_LABELS[reposiotyName]?.size + ) { + return; + } + + const targetBranch = context.payload.pull_request.base.ref; + const currentLabels = context.payload.pull_request.labels.map((label) => label.name); + + if (BRANCH_LABELS[reposiotyName].has(targetBranch) && !currentLabels.includes(targetBranch)) { + context.scheduleIssueLabel(targetBranch); + } + + // Find labels to remove + currentLabels + .filter((label) => BRANCH_LABELS[reposiotyName].has(label) && label !== targetBranch) + .forEach( + async (label) => await context.github.issues.removeLabel(context.issue({ name: label })), + ); + } +} diff --git a/services/bots/src/github-webhook/handlers/code_owners_mention.ts b/services/bots/src/github-webhook/handlers/code_owners_mention.ts new file mode 100644 index 0000000..1cb3157 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/code_owners_mention.ts @@ -0,0 +1,108 @@ +import { IssuesLabeledEvent, PullRequestLabeledEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { issueFromPayload } from '../utils/issue'; +import { BaseWebhookHandler } from './base'; + +import { CodeOwnersEntry, matchFile } from 'codeowners-utils'; + +export class CodeOwnersMention extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + !['issues.labeled', 'pull_request.labeled'].includes(context.eventType) || + ![Repository.CORE, Repository.HOME_ASSISTANT_IO].includes( + context.repo().repo as Repository, + ) || + !context.payload.label || + !context.payload.label.name.startsWith('integration: ') + ) { + return; + } + + const triggerIssue = issueFromPayload(context.payload); + const integrationName = context.payload.label.name.split('integration: ')[1]; + const path = + context.repo().repo === Repository.CORE + ? `homeassistant/components/${integrationName}/*` + : `source/_integrations/${integrationName}.markdown`; + + const codeownersData = await context.github.repos.getContent( + context.repo({ path: 'CODEOWNERS' }), + ); + + const codeownersContent = Buffer.from( + // @ts-ignore + codeownersData.data.content, + 'base64', + ).toString(); + + if (!codeownersContent.includes(integrationName)) { + return; + } + + const match = matchFile(path, parse(codeownersContent)) as CodeOwnerEntry; + if (!match) { + return; + } + + // Remove the `@` and lowercase + const owners = match.owners.map((owner) => owner.substring(1).toLowerCase()); + const codeownersLine = `${ + // @ts-ignore + codeownersData.data.html_url + }#L${match.line}`; + + const assignees = triggerIssue.assignees.map((assignee) => assignee.login.toLowerCase()); + const commentersData = await context.github.issues.listComments( + context.issue({ per_page: 100 }), + ); + const commenters = commentersData.data.map((commenter) => commenter.user.login.toLowerCase()); + const payloadUsername = triggerIssue.user.login.toLowerCase(); + const ownersMinusAuthor = owners.filter((usr) => usr !== payloadUsername); + + await context.github.issues.addAssignees(context.issue({ assignees: ownersMinusAuthor })); + + const mentions = ownersMinusAuthor + .filter((usr) => !assignees.includes(usr) && !commenters.includes(usr)) + // Add `@` because used in a comment. + .map((usr) => `@${usr}`); + + if (mentions.length > 0) { + const triggerLabel = + context.repo().repo === Repository.CORE + ? context.eventType.startsWith('issues') + ? 'issue' + : 'pull request' + : 'feedback'; + + context.scheduleIssueComment( + 'CodeOwnersMention', + `Hey there ${mentions.join( + ', ', + )}, mind taking a look at this ${triggerLabel} as it has been labeled with an integration (\`${integrationName}\`) you are listed as a [code owner](${codeownersLine}) for? Thanks!`, + ); + } + } +} + +// Temporary local patched version of what's in codeowners-utils +// until https://github.com/jamiebuilds/codeowners-utils/pull/1 is merged + +interface CodeOwnerEntry extends CodeOwnersEntry { + line: number; +} + +function parse(str: string) { + const entries: Array = []; + + str.split('\n').forEach((entry, idx) => { + let [content, _] = entry.split('#'); + let trimmed = content.trim(); + if (trimmed === '') return; + let [pattern, ...owners] = trimmed.split(/\s+/); + let line = idx + 1; + entries.push({ pattern, owners, line }); + }); + + return entries.reverse(); +} diff --git a/services/bots/src/github-webhook/handlers/dependency_bump.ts b/services/bots/src/github-webhook/handlers/dependency_bump.ts new file mode 100644 index 0000000..f0f52e1 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/dependency_bump.ts @@ -0,0 +1,40 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { fetchPullRequestFilesFromContext } from '../utils/pull_request'; +import { BaseWebhookHandler } from './base'; + +const DEPENDENCY_FILES = new Set([ + 'setup.py', + 'manifest.json', + 'package_constraints.txt', + 'requirements_all.txt', + 'requirements_docs.txt', + 'requirements_test.txt', + 'requirements_test_all.txt', +]); + +export class DependencyBump extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.senderIsBot || + context.eventType !== 'pull_request.opened' || + context.repo().repo !== Repository.CORE + ) { + return; + } + + const files = await fetchPullRequestFilesFromContext(context); + + const filenames = files.map((file) => { + const parts = file.filename.split('/'); + return parts[parts.length - 1]; + }); + + if (!filenames.every((filename: string) => DEPENDENCY_FILES.has(filename))) { + return; + } + + context.scheduleIssueLabel('dependency-bump'); + } +} diff --git a/services/bots/src/github-webhook/handlers/docs_missing.ts b/services/bots/src/github-webhook/handlers/docs_missing.ts new file mode 100644 index 0000000..9486dad --- /dev/null +++ b/services/bots/src/github-webhook/handlers/docs_missing.ts @@ -0,0 +1,38 @@ +import { + PullRequestLabeledEvent, + PullRequestSynchronizeEvent, + PullRequestUnlabeledEvent, +} from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { BaseWebhookHandler } from './base'; + +export class DocsMissing extends BaseWebhookHandler { + async handle( + context: WebhookContext< + PullRequestLabeledEvent | PullRequestUnlabeledEvent | PullRequestSynchronizeEvent + >, + ) { + if ( + !['pull_request.labeled', 'pull_request.unlabeled', 'pull_request.synchronize'].includes( + context.eventType, + ) || + context.repo().repo !== Repository.CORE + ) { + return; + } + + const hasDocsMissingLabel = context.payload.pull_request.labels + .map((label) => label.name) + .includes('docs-missing'); + + await context.github.repos.createCommitStatus( + context.repo({ + sha: context.payload.pull_request.head.sha, + context: 'docs-missing', + state: hasDocsMissingLabel ? 'failure' : 'success', + description: hasDocsMissingLabel ? `Please open a documentation PR.` : `Documentation ok.`, + }), + ); + } +} diff --git a/services/bots/src/github-webhook/handlers/docs_parenting.ts b/services/bots/src/github-webhook/handlers/docs_parenting.ts new file mode 100644 index 0000000..f023497 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/docs_parenting.ts @@ -0,0 +1,159 @@ +import { + PullRequestClosedEvent, + PullRequestEditedEvent, + PullRequestOpenedEvent, + PullRequestReopenedEvent, +} from '@octokit/webhooks-types'; +import { HOME_ASSISTANT_ORG, Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { + extractIssuesOrPullRequestMarkdownLinks, + extractPullRequestURLLinks, +} from '../utils/text_parser'; +import { BaseWebhookHandler } from './base'; + +export class DocsParenting extends BaseWebhookHandler { + async handle( + context: WebhookContext< + | PullRequestReopenedEvent + | PullRequestOpenedEvent + | PullRequestEditedEvent + | PullRequestClosedEvent + >, + ) { + if ( + ![ + 'pull_request.reopened', + 'pull_request.closed', + 'pull_request.opened', + 'pull_request.edited', + ].includes(context.eventType) + ) { + return; + } + + if (['pull_request.reopened', 'pull_request.closed'].includes(context.eventType)) { + updateDocsParentStatus( + context as WebhookContext, + ); + } else { + if (context.repo().repo === Repository.HOME_ASSISTANT_IO) { + await runDocsParentingDocs( + context as WebhookContext, + ); + } else { + await runDocsParentingNonDocs( + context as WebhookContext, + ); + } + } + } +} + +// Deal with PRs on Home Assistant Python repo +const runDocsParentingNonDocs = async ( + context: WebhookContext, +) => { + const linksToDocs = extractIssuesOrPullRequestMarkdownLinks(context.payload.pull_request.body) + .concat(extractPullRequestURLLinks(context.payload.pull_request.body)) + .filter((link) => link.repo === Repository.HOME_ASSISTANT_IO); + + if (linksToDocs.length === 0) { + return; + } + + if (linksToDocs.length > 2) { + return; + } + + linksToDocs.forEach( + async (link) => + await context.github.issues.addLabels({ + owner: link.owner, + repo: link.repo, + issue_number: link.number, + labels: ['has-parent'], + }), + ); +}; + +// Deal with PRs on Home Assistant.io repo +const runDocsParentingDocs = async ( + context: WebhookContext, +) => { + const linksToNonDocs = extractIssuesOrPullRequestMarkdownLinks(context.payload.pull_request.body) + .concat(extractPullRequestURLLinks(context.payload.pull_request.body)) + .filter( + (link) => link.owner === HOME_ASSISTANT_ORG && link.repo !== Repository.HOME_ASSISTANT_IO, + ); + + if (linksToNonDocs.length === 0) { + return; + } + + context.scheduleIssueLabel('has-parent'); +}; + +/** + * Goal is to reflect the parent status on the docs PR. + * - parent opened: make sure docs PR is open + * - parent closed: make sure docs PR is closed + * - parent merged: add label "parent-merged" + */ +const updateDocsParentStatus = async ( + context: WebhookContext, +) => { + if (context.repo().repo === Repository.HOME_ASSISTANT_IO) { + return; + } + + const linksToDocs = extractIssuesOrPullRequestMarkdownLinks( + context.payload.pull_request.body, + ).filter((link) => link.repo === Repository.HOME_ASSISTANT_IO); + + if (linksToDocs.length !== 1) { + return; + } + + const docLink = linksToDocs[0]; + const parentState = getPRState(context.payload.pull_request); + + if (parentState === 'open') { + // Parent is open, docs issue should be open too. + const docsPR = await context.fetchPullRequestWithCache({ + owner: docLink.owner, + repo: docLink.repo, + pull_number: docLink.number, + }); + const docsPRState = getPRState(docsPR); + + if (['open', 'merged'].includes(docsPRState)) { + return; + } + + // docs PR state == closed + await context.github.pulls.update({ + owner: docLink.owner, + repo: docLink.repo, + pull_number: docLink.number, + state: 'open', + }); + return; + } + + if (parentState === 'closed') { + await context.github.pulls.update({ + owner: docLink.owner, + repo: docLink.repo, + pull_number: docLink.number, + state: 'closed', + }); + return; + } + + // Parent state == merged + context.scheduleIssueLabel('parent-merged'); +}; + +const getPRState = (pr: { state: string; merged: boolean }) => + pr.state === 'open' ? 'open' : pr.merged ? 'merged' : 'closed'; diff --git a/services/bots/src/github-webhook/handlers/docs_target_branch.ts b/services/bots/src/github-webhook/handlers/docs_target_branch.ts new file mode 100644 index 0000000..acddcc9 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/docs_target_branch.ts @@ -0,0 +1,88 @@ +import { PullRequestEditedEvent, PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { HOME_ASSISTANT_ORG, Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { + extractIssuesOrPullRequestMarkdownLinks, + extractPullRequestURLLinks, +} from '../utils/text_parser'; +import { BaseWebhookHandler } from './base'; + +const IGNORE_REPOS = [Repository.BRANDS, Repository.DEVELOPERS_HOME_ASSISTANT]; + +export const bodyShouldTargetCurrent: string = + 'It seems that this PR is targeted against an incorrect branch. Documentation updates which apply to our current stable release should target the `current` branch. Please change the target branch of this PR to `current` and rebase if needed. If this is documentation for a new feature, please add a link to that PR in your description.'; +export const bodyShouldTargetNext: string = + 'It seems that this PR is targeted against an incorrect branch since it has a parent PR on one of our codebases. Documentation that needs to be updated for an upcoming release should target the `next` branch. Please change the target branch of this PR to `next` and rebase if needed.'; + +export class DocsTargetBranch extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.senderIsBot || + !['pull_request.opened', 'pull_request.edited'].includes(context.eventType) || + context.repo().repo !== Repository.HOME_ASSISTANT_IO + ) { + return; + } + + const target = context.payload.pull_request.base.ref; + const links = extractIssuesOrPullRequestMarkdownLinks(context.payload.pull_request.body).concat( + extractPullRequestURLLinks(context.payload.pull_request.body).filter( + (link) => + !IGNORE_REPOS.includes(link.repo as Repository) || HOME_ASSISTANT_ORG !== link.owner, + ), + ); + + if (links.length === 0) { + if (target !== 'current') { + await wrongTargetBranchDetected(context, 'current'); + } else { + await correctTargetBranchDetected(context); + } + return; + } + + if (target !== 'next') { + await wrongTargetBranchDetected(context, 'next'); + } else { + await correctTargetBranchDetected(context); + } + } +} + +const correctTargetBranchDetected = async ( + context: WebhookContext, +) => { + const author = context.payload.sender.login; + const currentLabels = context.payload.pull_request.labels.map((label) => label.name); + if (currentLabels.includes('needs-rebase')) { + await context.github.issues.removeLabel(context.issue({ name: 'needs-rebase' })); + } + + const currentAssignees = context.payload.pull_request.assignees.map((assignee) => assignee.login); + if (currentAssignees.includes(author)) { + await context.github.issues.removeAssignees(context.issue({ assignees: [author] })); + } +}; + +const wrongTargetBranchDetected = async ( + context: WebhookContext, + correctTargetBranch: 'current' | 'next', +) => { + const author = context.payload.sender.login; + const body: string = + correctTargetBranch === 'next' ? bodyShouldTargetNext : bodyShouldTargetCurrent; + + const currentLabels = context.payload.pull_request.labels.map((label) => label.name); + if (currentLabels.includes('needs-rebase')) { + // If the label "needs-rebase" already exists we can assume that this action has run, and we should ignore it. + return; + } + + ['needs-rebase', 'in-progress'].forEach((label) => context.scheduleIssueLabel(label)); + + await context.github.issues.addAssignees(context.issue({ assignees: [author] })); + context.scheduleIssueComment( + 'DocsTargetBranch', + correctTargetBranch === 'next' ? bodyShouldTargetNext : bodyShouldTargetCurrent, + ); +}; diff --git a/services/bots/src/github-webhook/handlers/hacktoberfest.ts b/services/bots/src/github-webhook/handlers/hacktoberfest.ts new file mode 100644 index 0000000..ed5381e --- /dev/null +++ b/services/bots/src/github-webhook/handlers/hacktoberfest.ts @@ -0,0 +1,37 @@ +import { PullRequestClosedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../github-webhook.model'; +import { BaseWebhookHandler } from './base'; + +export const isHacktoberfestLive = () => new Date().getMonth() === 9; + +export class Hacktoberfest extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if (isHacktoberfestLive && context.eventType === 'pull_request.opened') { + await this.handlePullRequestOpened(context); + } else if (context.eventType === 'pull_request.closed') { + await this.handlePullRequestClosed(context); + } + } + + async handlePullRequestOpened(context: WebhookContext) { + context.scheduleIssueLabel('Hacktoberfest'); + } + async handlePullRequestClosed(context: WebhookContext) { + const pullRequest = context.payload.pull_request; + + // Don't do something if the PR got merged or if it had no Hacktoberfest label. + if ( + pullRequest.merged || + pullRequest.labels.find((label) => label.name === 'Hacktoberfest') == undefined + ) { + return; + } + + // If a Hacktoberfest PR got closed, automatically remove the "Hacktoberfest" label + try { + await context.github.issues.removeLabel(context.issue({ name: 'Hacktoberfest' })); + } catch (_) { + // ignroe missing label + } + } +} diff --git a/services/bots/src/github-webhook/handlers/issue_links.ts b/services/bots/src/github-webhook/handlers/issue_links.ts new file mode 100644 index 0000000..779be4d --- /dev/null +++ b/services/bots/src/github-webhook/handlers/issue_links.ts @@ -0,0 +1,25 @@ +import { IssuesLabeledEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { BaseWebhookHandler } from './base'; + +export class IssueLinks extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.eventType !== 'issues.labeled' || + context.repo().repo !== Repository.CORE || + !context.payload.label || + !context.payload.label.name.startsWith('integration: ') + ) { + return; + } + + const domain = context.payload.label.name.split('integration: ')[1]; + const docLink = `https://www.home-assistant.io/integrations/${domain}`; + const codeLink = `https://github.com/home-assistant/core/tree/dev/homeassistant/components/${domain}`; + context.scheduleIssueComment( + 'IssueLinks', + `[${domain} documentation](${docLink})\n[${domain} source](${codeLink})`, + ); + } +} diff --git a/services/bots/src/github-webhook/handlers/label_bot/handler.ts b/services/bots/src/github-webhook/handlers/label_bot/handler.ts new file mode 100644 index 0000000..8876e30 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/handler.ts @@ -0,0 +1,62 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../../github-webhook.const'; +import { WebhookContext } from '../../github-webhook.model'; +import { ParsedPath } from '../../utils/parse_path'; +import { fetchPullRequestFilesFromContext } from '../../utils/pull_request'; +import { BaseWebhookHandler } from '../base'; +import componentAndPlatform from './strategies/componentAndPlatform'; +import configFlow from './strategies/configFlow'; +import hasTests from './strategies/hasTests'; +import markCore from './strategies/markCore'; +import newIntegrationOrPlatform from './strategies/newIntegrationOrPlatform'; +import removePlatform from './strategies/removePlatform'; +import smallPR from './strategies/smallPR'; +import typeOfChange from './strategies/typeOfChange'; +import warnOnMergeToMaster from './strategies/warnOnMergeToMaster'; + +const STRATEGIES = new Set([ + configFlow, + hasTests, + markCore, + newIntegrationOrPlatform, + removePlatform, + smallPR, + typeOfChange, + warnOnMergeToMaster, +]); + +export class LabelBot extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.senderIsBot || + context.eventType !== 'pull_request.opened' || + context.repo().repo !== Repository.CORE + ) { + return; + } + + const files = await fetchPullRequestFilesFromContext(context); + const parsed = files.map((file) => new ParsedPath(file)); + const labelSet: Set = new Set(); + + STRATEGIES.forEach((strategy) => { + for (const label of strategy(context, parsed)) { + labelSet.add(label); + } + }); + + // componentAndPlatform can create many labels, process them separately + const componentLabelSet = new Set(); + for (const label of componentAndPlatform(context, parsed)) { + componentLabelSet.add(label); + } + + if (labelSet.size + componentLabelSet.size <= 9) { + componentLabelSet.forEach(labelSet.add, labelSet); + } + + for (const label of labelSet) { + context.scheduleIssueLabel(label); + } + } +} diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/componentAndPlatform.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/componentAndPlatform.ts new file mode 100644 index 0000000..193a479 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/componentAndPlatform.ts @@ -0,0 +1,6 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.filter((file) => file.component).map((file) => `integration: ${file.component}`); diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/configFlow.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/configFlow.ts new file mode 100644 index 0000000..a1f1f00 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/configFlow.ts @@ -0,0 +1,21 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => { + const addedFlows = new Set( + parsed + .filter( + (fil) => + fil.type == 'component' && fil.status == 'added' && fil.filename === 'config_flow.py', + ) + .map((fil) => fil.component), + ); + // remove new integrations + for (const fil of parsed) { + if (fil.type == 'component' && fil.status == 'added' && fil.filename === '__init__.py') { + addedFlows.delete(fil.component); + } + } + return addedFlows.size ? ['config-flow'] : []; +}; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/hasTests.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/hasTests.ts new file mode 100644 index 0000000..e388e3f --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/hasTests.ts @@ -0,0 +1,6 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.some((item) => item.type === 'test') ? ['has-tests'] : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/markCore.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/markCore.ts new file mode 100644 index 0000000..a62724b --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/markCore.ts @@ -0,0 +1,6 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.some((file) => file.core) ? ['core'] : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/metadataUpdate.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/metadataUpdate.ts new file mode 100644 index 0000000..21cc86c --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/metadataUpdate.ts @@ -0,0 +1,16 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +const METADATA_FILES = new Set([ + 'CODEOWNERS', + 'manifest.json', + 'requirements_all.txt', + 'requirements_docs.txt', + 'requirements_test.txt', + 'requirements_test_all.txt', + 'services.yaml', +]); + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.every((fil) => METADATA_FILES.has(fil.filename)) ? ['metadata-only'] : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/newIntegrationOrPlatform.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/newIntegrationOrPlatform.ts new file mode 100644 index 0000000..bd5b93e --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/newIntegrationOrPlatform.ts @@ -0,0 +1,12 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.some( + (fil) => fil.type == 'component' && fil.status == 'added' && fil.filename === '__init__.py', + ) + ? ['new-integration'] + : parsed.some((fil) => fil.type == 'platform' && fil.status == 'added') + ? ['new-platform'] + : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/removePlatform.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/removePlatform.ts new file mode 100644 index 0000000..043f947 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/removePlatform.ts @@ -0,0 +1,8 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.some((fil) => fil.type == 'platform' && fil.status == 'removed') + ? ['remove-platform'] + : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/smallPR.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/smallPR.ts new file mode 100644 index 0000000..458ab37 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/smallPR.ts @@ -0,0 +1,13 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +const SMALL_PR_THRESHOLD = 30; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + parsed.reduce( + (tot, file) => (file.type === 'test' || file.type === null ? tot : tot + file.additions), + 0, + ) < SMALL_PR_THRESHOLD + ? ['small-pr'] + : []; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/typeOfChange.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/typeOfChange.ts new file mode 100644 index 0000000..308c14e --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/typeOfChange.ts @@ -0,0 +1,54 @@ +import { PullRequest, PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; +import { extractTasks } from '../../../utils/text_parser'; + +const BODYMATCHES = [ + { + description: 'Bugfix (non-breaking change which fixes an issue)', + labels: ['bugfix'], + }, + { + description: 'Dependency upgrade', + labels: ['dependency'], + }, + { + description: 'New integration (thank you!)', + labels: ['new-integration'], + }, + { + description: 'New feature (which adds functionality to an existing integration)', + labels: ['new-feature'], + }, + { + description: 'Deprecation (breaking change to happen in the future)', + labels: ['deprecation'], + }, + { + description: 'Breaking change (fix/feature causing existing functionality to break)', + labels: ['breaking-change'], + }, + { + description: 'Code quality improvements to existing code or addition of tests', + labels: ['code-quality'], + }, +]; + +export default (context: WebhookContext, parsed: ParsedPath[]) => { + const completedTasks = extractTasks((context.payload.pull_request as PullRequest).body || '') + .filter((task) => { + return task.checked; + }) + .map((task) => task.description); + + let labels: string[] = []; + BODYMATCHES.forEach((match) => { + if (completedTasks.includes(match.description)) { + match.labels.forEach((label) => { + labels.push(label); + }); + } + }); + + return labels; +}; diff --git a/services/bots/src/github-webhook/handlers/label_bot/strategies/warnOnMergeToMaster.ts b/services/bots/src/github-webhook/handlers/label_bot/strategies/warnOnMergeToMaster.ts new file mode 100644 index 0000000..f71c34a --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_bot/strategies/warnOnMergeToMaster.ts @@ -0,0 +1,10 @@ +import { PullRequest, PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { ParsedPath } from '../../../utils/parse_path'; + +export default (context: WebhookContext, parsed: ParsedPath[]) => + (context.payload.pull_request as PullRequest).base.ref === 'master' + ? ['merging-to-master'] + : (context.payload.pull_request as PullRequest).base.ref === 'rc' + ? ['merging-to-rc'] + : []; diff --git a/services/bots/src/github-webhook/handlers/label_cleaner.ts b/services/bots/src/github-webhook/handlers/label_cleaner.ts new file mode 100644 index 0000000..8c72cf0 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/label_cleaner.ts @@ -0,0 +1,37 @@ +import { PullRequestClosedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { BaseWebhookHandler } from './base'; + +// Map repositories to labels that need cleaning. +const TO_CLEAN: { [key: string]: string[] } = { + [Repository.CORE]: ['Ready for review'], + [Repository.HOME_ASSISTANT_IO]: [ + 'needs-rebase', + 'in-progress', + 'awaits-parent', + 'ready-for-review', + 'parent-merged', + ], +}; + +export class LabelCleaner extends BaseWebhookHandler { + async handle(context: WebhookContext) { + const repositoryName = context.repo().repo; + if ( + context.eventType !== 'pull_request.closed' || + ![Repository.CORE, Repository.HOME_ASSISTANT_IO].includes(repositoryName as Repository) || + !TO_CLEAN[repositoryName]?.length + ) { + return; + } + + const currentLabels = context.payload.pull_request.labels.map((label) => label.name); + + TO_CLEAN[repositoryName] + .filter((label) => currentLabels.includes(label)) + .forEach(async (label) => { + await context.github.issues.removeLabel(context.issue({ name: label })); + }); + } +} diff --git a/services/bots/src/github-webhook/handlers/review_enforcer.ts b/services/bots/src/github-webhook/handlers/review_enforcer.ts new file mode 100644 index 0000000..6fa1be1 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/review_enforcer.ts @@ -0,0 +1,39 @@ +import { PullRequestOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { ParsedDocsPath } from '../utils/parse_docs_path'; +import { ParsedPath } from '../utils/parse_path'; +import { fetchPullRequestFilesFromContext } from '../utils/pull_request'; +import { BaseWebhookHandler } from './base'; + +const INTEGRATIONS = new Set(['xiaomi_miio']); +const USERS = new Set([]); + +export class ReviewEnforcer extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if (context.senderIsBot || context.eventType !== 'pull_request.opened') { + return; + } + const repositoryName = context.repo().repo as Repository; + if (USERS.has(context.payload.sender.login)) { + context.scheduleIssueComment( + 'ReviewEnforcer', + 'This pull request needs to be manually signed off by @home-assistant/core before it can get merged.', + ); + } else if ([Repository.HOME_ASSISTANT_IO, Repository.CORE].includes(repositoryName)) { + const files = await fetchPullRequestFilesFromContext(context); + const parsed = files.map((file) => + repositoryName === Repository.HOME_ASSISTANT_IO + ? new ParsedDocsPath(file) + : new ParsedPath(file), + ); + + if (parsed.some((file) => file.component && INTEGRATIONS.has(file.component))) { + context.scheduleIssueComment( + 'ReviewEnforcer', + 'This pull request needs to be manually signed off by @home-assistant/core before it can get merged.', + ); + } + } + } +} diff --git a/services/bots/src/github-webhook/handlers/set_documentation_section.ts b/services/bots/src/github-webhook/handlers/set_documentation_section.ts new file mode 100644 index 0000000..d549a96 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/set_documentation_section.ts @@ -0,0 +1,31 @@ +import { Issue, IssuesOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { extractDocumentationSectionsLinks } from '../utils/text_parser'; +import { BaseWebhookHandler } from './base'; + +export class SetDocumentationSection extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.senderIsBot || + context.eventType !== 'issues.opened' || + context.repo().repo !== Repository.HOME_ASSISTANT_IO + ) { + return; + } + + const foundSections = extractDocumentationSectionsLinks((context.payload.issue as Issue).body); + + if (foundSections.includes('integration')) { + // Don't do anything for integration sections + return; + } + + foundSections.forEach(async (section) => { + const exist = await context.github.issues.getLabel(context.issue({ name: section })); + if (exist.status === 200 && exist.data.name === section) { + context.scheduleIssueLabel(exist.data.name); + } + }); + } +} diff --git a/services/bots/src/github-webhook/handlers/set_integration.ts b/services/bots/src/github-webhook/handlers/set_integration.ts new file mode 100644 index 0000000..b265b0a --- /dev/null +++ b/services/bots/src/github-webhook/handlers/set_integration.ts @@ -0,0 +1,29 @@ +import { Issue, IssuesOpenedEvent } from '@octokit/webhooks-types'; +import { Repository } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; +import { extractIntegrationDocumentationLinks } from '../utils/text_parser'; +import { BaseWebhookHandler } from './base'; + +export class SetIntegration extends BaseWebhookHandler { + async handle(context: WebhookContext) { + if ( + context.senderIsBot || + context.eventType !== 'issues.opened' || + ![Repository.CORE, Repository.HOME_ASSISTANT_IO].includes(context.repo().repo as Repository) + ) { + return; + } + + extractIntegrationDocumentationLinks((context.payload.issue as Issue).body).forEach( + async (link) => { + const label = `integration: ${link.integration}`; + const exist = await context.github.issues.getLabel( + context.issue({ name: label, repo: Repository.CORE }), + ); + if (exist.status === 200 && exist.data.name === label) { + context.scheduleIssueLabel(label); + } + }, + ); + } +} diff --git a/services/bots/src/github-webhook/handlers/validate-cla.ts b/services/bots/src/github-webhook/handlers/validate-cla.ts index 357435d..5e19cad 100644 --- a/services/bots/src/github-webhook/handlers/validate-cla.ts +++ b/services/bots/src/github-webhook/handlers/validate-cla.ts @@ -6,6 +6,7 @@ import { ClaIssueLabel } from '@lib/common/github'; import { DynamoDB } from 'aws-sdk'; import { PullRequestEventData } from '../github-webhook.const'; import { WebhookContext } from '../github-webhook.model'; +import { Injectable } from '@nestjs/common'; const ignoredAuthors: Set = new Set([ // Ignore bot accounts that are not masked as bots @@ -34,19 +35,20 @@ const ignoredRepositories: Set = new Set([ const botContextName = 'cla-bot'; +@Injectable() export class ValidateCla extends BaseWebhookHandler { private ddbClient: DynamoDB; private signersTableName: string; private pendingSignersTableName: string; - constructor(configService: ConfigService) { - super(configService); + constructor(private configService: ConfigService) { + super(); this.ddbClient = new DynamoDB({ region: configService.get('dynamodb.cla.region') }); this.signersTableName = configService.get('dynamodb.cla.signersTable'); this.pendingSignersTableName = configService.get('dynamodb.cla.pendingSignersTable'); } - async handle(context: WebhookContext) { + async handle(context: WebhookContext) { if ( ![ 'pull_request.labeled', @@ -58,31 +60,26 @@ export class ValidateCla extends BaseWebhookHandler { return; } - const eventData = context.payload as PullRequestEventData; const authorsWithSignedCLA: Set = new Set(); const authorsNeedingCLA: { sha: string; login: string }[] = []; const commitsWithoutLogins: { sha: string; maybeText: string }[] = []; - if (ignoredRepositories.has(eventData.repository.full_name)) { + if (ignoredRepositories.has(context.payload.repository.full_name)) { return; } - if (eventData.action === 'labeled') { - if (eventData.label.name !== ClaIssueLabel.CLA_RECHECK) { + if (context.payload.action === 'labeled') { + if (context.payload.label.name !== ClaIssueLabel.CLA_RECHECK) { return; } try { - await this.githubApiClient.issues.removeLabel( - context.issue({ name: ClaIssueLabel.CLA_RECHECK }), - ); + await context.github.issues.removeLabel(context.issue({ name: ClaIssueLabel.CLA_RECHECK })); } catch { // ignroe missing label } } - const commits = await this.githubApiClient.pulls.listCommits( - context.pullRequest({ per_page: 100 }), - ); + const commits = await context.github.pulls.listCommits(context.pullRequest({ per_page: 100 })); for await (const commit of commits.data) { if (commit.author?.type === 'Bot' || ignoredAuthors.has(commit.commit?.author?.email)) { @@ -119,15 +116,15 @@ export class ValidateCla extends BaseWebhookHandler { botContextName, noLoginOnShaComment( commitsWithoutLogins, - eventData.pull_request.user.login, - `https://github.com/${eventData.repository.full_name}/pull/${eventData.number}/commits/`, + context.payload.pull_request.user.login, + `https://github.com/${context.payload.repository.full_name}/pull/${context.payload.number}/commits/`, ), ); context.scheduleIssueLabel(ClaIssueLabel.CLA_ERROR); commitsWithoutLogins.forEach((commit) => { - this.githubApiClient.repos.createCommitStatus( + context.github.repos.createCommitStatus( context.repo({ sha: commit.sha, state: 'failure', @@ -144,13 +141,13 @@ export class ValidateCla extends BaseWebhookHandler { botContextName, pullRequestComment( authorsNeedingCLA, - `${eventData.repository.full_name}#${eventData.number}`, + `${context.payload.repository.full_name}#${context.payload.number}`, ), ); context.scheduleIssueLabel(ClaIssueLabel.CLA_NEEDED); authorsNeedingCLA.forEach((entry) => - this.githubApiClient.repos.createCommitStatus( + context.github.repos.createCommitStatus( context.repo({ sha: entry.sha, state: 'failure', @@ -178,10 +175,10 @@ export class ValidateCla extends BaseWebhookHandler { Item: { github_username: { S: author }, commits: { L: missingSign[author].map((entry) => ({ S: entry })) }, - pr: { S: `${eventData.repository.full_name}#${eventData.number}` }, - repository_owner: { S: eventData.repository.owner.login }, - repository: { S: eventData.repository.name }, - pr_number: { S: String(eventData.number) }, + pr: { S: `${context.payload.repository.full_name}#${context.payload.number}` }, + repository_owner: { S: context.payload.repository.owner.login }, + repository: { S: context.payload.repository.name }, + pr_number: { S: String(context.payload.number) }, signatureRequestedAt: { S: new Date().toISOString() }, }, }) @@ -201,7 +198,7 @@ export class ValidateCla extends BaseWebhookHandler { // If we get here, all is good :+1: context.scheduleIssueLabel(ClaIssueLabel.CLA_SIGNED); try { - await this.githubApiClient.issues.removeLabel( + await context.github.issues.removeLabel( context.issue({ name: ClaIssueLabel.CLA_NEEDED, }), @@ -211,7 +208,7 @@ export class ValidateCla extends BaseWebhookHandler { } commits.data.forEach((commit) => { - this.githubApiClient.repos.createCommitStatus( + context.github.repos.createCommitStatus( context.repo({ sha: commit.sha, state: 'success', diff --git a/services/bots/src/github-webhook/utils/issue.ts b/services/bots/src/github-webhook/utils/issue.ts new file mode 100644 index 0000000..d5ac796 --- /dev/null +++ b/services/bots/src/github-webhook/utils/issue.ts @@ -0,0 +1,7 @@ +import { Issue, PullRequest } from '@octokit/webhooks-types'; +import { IssuesEventData, PullRequestEventData } from '../github-webhook.const'; + +// PRs are shaped as issues. This method will help normalize it. +export const issueFromPayload = ( + payload: IssuesEventData | PullRequestEventData, +): Issue | PullRequest => payload['pull_request'] || payload['issue']; diff --git a/services/bots/src/github-webhook/utils/parse_docs_path.ts b/services/bots/src/github-webhook/utils/parse_docs_path.ts new file mode 100644 index 0000000..5d99cba --- /dev/null +++ b/services/bots/src/github-webhook/utils/parse_docs_path.ts @@ -0,0 +1,35 @@ +import { ListPullRequestFiles } from '../github-webhook.const'; + +export class ParsedDocsPath { + public file: ListPullRequestFiles[0]; + public type: 'integration' | null = null; + public component: null | string = null; + public platform: null | string = null; + + constructor(file: ListPullRequestFiles[0]) { + this.file = file; + const parts = file.filename.split('/'); + if (parts.length === 0) { + return; + } + if (parts.shift() !== 'source' || parts.shift() !== '_components') { + return; + } + + this.type = 'integration'; + + let integration = parts.shift(); + if (integration.endsWith('.markdown')) { + integration = integration.substring(0, integration.lastIndexOf('.')); + } + + if (!integration.includes('.')) { + this.component = integration; + return; + } + + const [platform, component] = integration.split('.'); + this.component = component; + this.platform = platform; + } +} diff --git a/services/bots/src/github-webhook/utils/parse_path.ts b/services/bots/src/github-webhook/utils/parse_path.ts new file mode 100644 index 0000000..7457c67 --- /dev/null +++ b/services/bots/src/github-webhook/utils/parse_path.ts @@ -0,0 +1,87 @@ +import { entityComponents, coreComponents } from '../github-webhook.const'; +import { basename } from 'path'; +import { ListPullRequestFiles } from '../github-webhook.const'; + +export class ParsedPath { + public file: ListPullRequestFiles[0]; + public type: + | null + | 'core' + | 'auth' + | 'auth_providers' + | 'generated' + | 'scripts' + | 'helpers' + | 'util' + | 'test' + | 'services' + | 'component' + | 'platform' = null; + public component: null | string = null; + public platform: null | string = null; + public core = false; + + constructor(file: ListPullRequestFiles[0]) { + this.file = file; + const parts = file.filename.split('/'); + const rootFolder = parts.length > 1 ? parts.shift() : undefined; + + if (!['tests', 'homeassistant'].includes(rootFolder)) { + return; + } + + const subfolder = parts.shift(); + + if (!['components', 'fixtures', 'generated'].includes(subfolder)) { + this.core = true; + + if (subfolder.endsWith('.py')) { + this.type = 'core'; + } else { + this.type = subfolder as any; + } + return; + } + + // This is not possible anymore after great migration + if (parts.length < 2) { + return; + } + + this.component = parts.shift(); + let filename = parts[0].replace('.py', ''); + + if (rootFolder === 'tests') { + this.type = 'test'; + filename = filename.replace('test_', ''); + if (entityComponents.has(filename)) { + this.platform = filename; + } + } else if (filename === 'services.yaml') { + this.type = 'services'; + } else if (entityComponents.has(filename)) { + this.type = 'platform'; + this.platform = filename; + } else { + this.type = 'component'; + } + + this.core = coreComponents.has(this.component); + } + + get additions() { + return this.file.additions; + } + + get status() { + return this.file.status; + } + + get path() { + return this.file.filename; + } + + get filename() { + return basename(this.path); + } +} diff --git a/services/bots/src/github-webhook/utils/pull_request.ts b/services/bots/src/github-webhook/utils/pull_request.ts new file mode 100644 index 0000000..cbb18c1 --- /dev/null +++ b/services/bots/src/github-webhook/utils/pull_request.ts @@ -0,0 +1,11 @@ +import { ListPullRequestFiles } from '../github-webhook.const'; +import { WebhookContext } from '../github-webhook.model'; + +export const fetchPullRequestFilesFromContext = async ( + context: WebhookContext, +): Promise => { + if (!context._prFilesCache) { + context._prFilesCache = (await context.github.pulls.listFiles(context.issue())).data; + } + return context._prFilesCache; +}; diff --git a/services/bots/src/github-webhook/utils/text_parser.ts b/services/bots/src/github-webhook/utils/text_parser.ts new file mode 100644 index 0000000..2e59a9e --- /dev/null +++ b/services/bots/src/github-webhook/utils/text_parser.ts @@ -0,0 +1,98 @@ +import { WebhookContext } from '../github-webhook.model'; + +interface IntegrationDocumentationLink { + link: string; + integration: string; +} + +interface Task { + checked: boolean; + description: string; +} + +interface IssuePullInfo { + owner: string; + repo: string; + number: number; +} + +export const extractIntegrationDocumentationLinks = ( + body: string, +): IntegrationDocumentationLink[] => { + const re = /https:\/\/(www.|rc.|next.|)home-assistant.io\/integrations\/(?:\w+\.)?(\w+)/g; + let match; + let results: IntegrationDocumentationLink[] = []; + + do { + match = re.exec(body); + if (match) { + results.push({ link: match, integration: match[2] }); + } + } while (match); + + return results; +}; + +export const extractTasks = (body: string) => { + const matchAll = /- \[( |)(x|X| |)(| )\] /; + const matchChecked = /- \[( |)(x|X)(| )\] /; + const tasks: Task[] = []; + + body.split('\n').forEach((line: string) => { + if (!line.trim().startsWith('- [')) { + return; + } + + const lineSplit = line.split(matchAll); + const checked: boolean = matchChecked.test(line); + const description: string = lineSplit[lineSplit.length - 1].trim().replace(/\\r/g, ''); + tasks.push({ checked, description }); + }); + return tasks; +}; + +export const extractDocumentationSectionsLinks = (body: string): string[] => { + const re = /https:\/\/(www.|rc.|next.|)home-assistant.io\/(.*)\//g; + let match; + let results: string[] = []; + + do { + match = re.exec(body); + if (match) { + const sections = match[2].split('/'); + results = results.concat(sections); + } + } while (match); + + return [...new Set(results)]; +}; + +export const extractIssuesOrPullRequestMarkdownLinks = (body: string) => { + const re = /([\w-\.]+)\/([\w-\.]+)#(\d+)/g; + let match; + const results: IssuePullInfo[] = []; + + do { + match = re.exec(body); + if (match) { + results.push({ owner: match[1], repo: match[2], number: Number(match[3]) }); + } + } while (match); + + return results; +}; + +export const extractPullRequestURLLinks = (body: string) => { + const re = /https:\/\/github.com\/([\w-\.]+)\/([\w-\.]+)\/pull\/(\d+)/g; + let match; + const results: IssuePullInfo[] = []; + + do { + match = re.exec(body); + if (match) { + results.push({ owner: match[1], repo: match[2], number: Number(match[3]) }); + } + } while (match); + + return results; +}; diff --git a/tests/services/bots/github-webhook/handlers/hacktoberfest.spec.ts b/tests/services/bots/github-webhook/handlers/hacktoberfest.spec.ts new file mode 100644 index 0000000..a9ca545 --- /dev/null +++ b/tests/services/bots/github-webhook/handlers/hacktoberfest.spec.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { WebhookContext } from '../../../../../bots/src/github-webhook/github-webhook.model'; +import { + Hacktoberfest, + isHacktoberfestLive, +} from '../../../../../services/bots/src/github-webhook/handlers/hacktoberfest'; +import { mockWebhookContext } from '../../../../utils/test_context'; + +describe('Hacktoberfest', () => { + let handler: Hacktoberfest; + let mockContext: WebhookContext; + let getLabelResponse: any; + let removeLabel: any; + + beforeEach(function () { + handler = new Hacktoberfest(); + getLabelResponse = {}; + removeLabel = undefined; + mockContext = mockWebhookContext({ + eventType: 'pull_request.opened', + payload: { + pull_request: {}, + }, + github: { + issues: { + async getLabel() { + return getLabelResponse; + }, + async removeLabel(label) { + removeLabel = label; + }, + }, + }, + }); + }); + + describe('Check live', () => { + it('Hacktoberfest is live', async () => { + const clock = sinon.useFakeTimers(new Date(2020, 9, 1).getTime()); + assert.strictEqual(isHacktoberfestLive(), true); + clock.restore(); + }); + it('Hacktoberfest is not live', async () => { + const clock = sinon.useFakeTimers(new Date(2020, 8, 1).getTime()); + assert.strictEqual(isHacktoberfestLive(), false); + clock.restore(); + }); + }); + + it('Add hacktoberfest label on new PR', async () => { + const clock = sinon.useFakeTimers(new Date(2020, 9, 1).getTime()); + await handler.handle(mockContext); + clock.restore(); + + assert.deepStrictEqual(mockContext.scheduledlabels, ['Hacktoberfest']); + }); + + it('Remove hacktoberfest label on closed PR', async () => { + mockContext.eventType = 'pull_request.closed'; + mockContext.payload.pull_request = { labels: [{ name: 'Hacktoberfest' }], merged: false }; + await handler.handle(mockContext); + + assert.deepStrictEqual(removeLabel, { + issue_number: 1337, + name: 'Hacktoberfest', + owner: 'home-assistant', + repo: 'core', + }); + }); +}); diff --git a/tests/services/bots/github-webhook/handlers/issue_links.spec.ts b/tests/services/bots/github-webhook/handlers/issue_links.spec.ts new file mode 100644 index 0000000..592b383 --- /dev/null +++ b/tests/services/bots/github-webhook/handlers/issue_links.spec.ts @@ -0,0 +1,42 @@ +// @ts-nocheck +import * as assert from 'assert'; +import { WebhookContext } from '../../../../../bots/src/github-webhook/github-webhook.model'; +import { IssueLinks } from '../../../../../services/bots/src/github-webhook/handlers/issue_links'; +import { mockWebhookContext } from '../../../../utils/test_context'; + +describe('SetIntegration', () => { + let handler: IssueLinks; + let mockContext: WebhookContext; + let getLabelResponse: any; + + beforeEach(function () { + handler = new IssueLinks(); + getLabelResponse = {}; + mockContext = mockWebhookContext({ + eventType: 'issues.labeled', + payload: { + label: { name: 'integration: awesome' }, + issue: {}, + }, + github: { + issues: { + async getLabel() { + return getLabelResponse; + }, + }, + }, + }); + }); + + it('Add comment', async () => { + await handler.handle(mockContext); + + assert.deepStrictEqual(mockContext.scheduledComments, [ + { + handler: 'IssueLinks', + comment: + '[awesome documentation](https://www.home-assistant.io/integrations/awesome)\n[awesome source](https://github.com/home-assistant/core/tree/dev/homeassistant/components/awesome)', + }, + ]); + }); +}); diff --git a/tests/services/bots/github-webhook/handlers/label_bot.spec.ts b/tests/services/bots/github-webhook/handlers/label_bot.spec.ts new file mode 100644 index 0000000..0e00389 --- /dev/null +++ b/tests/services/bots/github-webhook/handlers/label_bot.spec.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +import * as assert from 'assert'; +import { WebhookContext } from '../../../../../bots/src/github-webhook/github-webhook.model'; +import { LabelBot } from '../../../../../services/bots/src/github-webhook/handlers/label_bot/handler'; +import { mockWebhookContext } from '../../../../utils/test_context'; + +describe('LabelBot', () => { + let handler: LabelBot; + let mockContext: WebhookContext; + let getLabelResponse: any; + + beforeEach(function () { + handler = new LabelBot(); + getLabelResponse = {}; + mockContext = mockWebhookContext({ + eventType: 'pull_request.opened', + payload: { + pull_request: {}, + }, + github: { + issues: {}, + }, + }); + }); + + it('works', async () => { + mockContext._prFilesCache = [ + { + filename: 'homeassistant/components/mqtt/climate.py', + }, + ]; + mockContext.payload.pull_request.base = { ref: 'master' }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, [ + 'core', + 'merging-to-master', + 'integration: mqtt', + ]); + }); + + it('many labels', async () => { + mockContext._prFilesCache = [ + { + filename: 'homeassistant/components/mqtt/climate.py', + }, + { + filename: 'homeassistant/components/hue/light.py', + }, + { + filename: 'homeassistant/components/zha/lock.py', + }, + { + filename: 'homeassistant/components/switch/group.py', + }, + { + filename: 'homeassistant/components/camera/__init__.py', + }, + { + filename: 'homeassistant/components/zwave/sensor.py', + }, + { + filename: 'homeassistant/components/zeroconf/usage.py', + }, + { + filename: 'homeassistant/components/xiaomi/device_tracker.py', + }, + { + filename: 'homeassistant/components/tts/notify.py', + }, + { + filename: 'homeassistant/components/serial/sensor.py', + }, + ]; + mockContext.payload.pull_request = { + body: + '\n- [x] Bugfix (non-breaking change which fixes an issue)' + + '\n- [x] Breaking change (fix/feature causing existing functionality to break)', + base: { ref: 'master' }, + }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, [ + 'core', + 'bugfix', + 'breaking-change', + 'merging-to-master', + ]); + }); +}); diff --git a/tests/services/bots/github-webhook/handlers/set_documentation_section.spec.ts b/tests/services/bots/github-webhook/handlers/set_documentation_section.spec.ts new file mode 100644 index 0000000..18094d1 --- /dev/null +++ b/tests/services/bots/github-webhook/handlers/set_documentation_section.spec.ts @@ -0,0 +1,74 @@ +// @ts-nocheck +import * as assert from 'assert'; +import { WebhookContext } from '../../../../../bots/src/github-webhook/github-webhook.model'; +import { SetDocumentationSection } from '../../../../../services/bots/src/github-webhook/handlers/set_documentation_section'; +import { mockWebhookContext } from '../../../../utils/test_context'; + +describe('SetDocumentationSection', () => { + let handler: SetIntegration; + let mockContext: WebhookContext; + let getLabelResponse: any; + + beforeEach(function () { + handler = new SetDocumentationSection(); + getLabelResponse = {}; + mockContext = mockWebhookContext({ + eventType: 'issues.opened', + payload: { + repository: { name: 'home-assistant.io', owner: { login: 'home-assistant' } }, + issue: {}, + }, + github: { + issues: { + async getLabel() { + return getLabelResponse; + }, + }, + }, + }); + }); + + it('Section label does exsist', async () => { + mockContext.payload.issue.body = + 'Link: https://www.home-assistant.io/getting-started/configuration/'; + getLabelResponse = { status: 200, data: { name: 'configuration' } }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, ['configuration']); + }); + + it('Section label does exsist only once', async () => { + mockContext.payload.issue.body = ` + Link: https://www.home-assistant.io/getting-started/configuration/ + Link: https://www.home-assistant.io/getting-started/configuration/ + `; + getLabelResponse = { status: 200, data: { name: 'configuration' } }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, ['configuration']); + }); + + it('Section label does not exsist', async () => { + mockContext.payload.issue.body = + 'Link: https://www.home-assistant.io/getting-started/configuration/'; + getLabelResponse = { status: 404 }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, []); + }); + + it('First section label does not exsist', async () => { + mockContext.payload.issue.body = + 'Link: https://www.home-assistant.io/getting-started/configuration/'; + getLabelResponse = { status: 404 }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, []); + + getLabelResponse = { status: 200, data: { name: 'getting-started' } }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, ['getting-started']); + }); + + it("Don't set section label for integration link", async () => { + mockContext.payload.issue.body = 'Link: https://www.home-assistant.io/integrations/awesome/'; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, []); + }); +}); diff --git a/tests/services/bots/github-webhook/handlers/set_integration.spec.ts b/tests/services/bots/github-webhook/handlers/set_integration.spec.ts new file mode 100644 index 0000000..2f911a1 --- /dev/null +++ b/tests/services/bots/github-webhook/handlers/set_integration.spec.ts @@ -0,0 +1,68 @@ +// @ts-nocheck +import * as assert from 'assert'; +import { WebhookContext } from '../../../../../bots/src/github-webhook/github-webhook.model'; +import { SetIntegration } from '../../../../../services/bots/src/github-webhook/handlers/set_integration'; +import { mockWebhookContext } from '../../../../utils/test_context'; + +describe('SetIntegration', () => { + let handler: SetIntegration; + let mockContext: WebhookContext; + let getLabelResponse: any; + + beforeEach(function () { + handler = new SetIntegration(); + getLabelResponse = {}; + mockContext = mockWebhookContext({ + eventType: 'issues.opened', + payload: { + issue: {}, + }, + github: { + issues: { + async getLabel() { + return getLabelResponse; + }, + }, + }, + }); + }); + + it('Integration label does exsist', async () => { + mockContext.payload.issue.body = 'Link: https://www.home-assistant.io/integrations/awesome'; + getLabelResponse = { status: 200, data: { name: 'integration: awesome' } }; + await handler.handle(mockContext); + + assert.deepStrictEqual(mockContext.scheduledlabels, ['integration: awesome']); + }); + + it('Integration label does not exsist', async () => { + mockContext.payload.issue.body = 'Link: https://www.home-assistant.io/integrations/not_valid'; + getLabelResponse = { status: 404 }; + await handler.handle(mockContext); + assert.deepStrictEqual(mockContext.scheduledlabels, []); + }); + + it('Integration with underscore', async () => { + mockContext.payload.issue.body = + 'Link: https://www.home-assistant.io/integrations/awesome_integration'; + getLabelResponse = { + status: 200, + data: { name: 'integration: awesome_integration' }, + }; + await handler.handle(mockContext); + + assert.deepStrictEqual(mockContext.scheduledlabels, ['integration: awesome_integration']); + }); + + it('Integration with platform', async () => { + mockContext.payload.issue.body = + 'Link: https://www.home-assistant.io/integrations/platform.awesome'; + getLabelResponse = { + status: 200, + data: { name: 'integration: awesome' }, + }; + await handler.handle(mockContext); + + assert.deepStrictEqual(mockContext.scheduledlabels, ['integration: awesome']); + }); +}); diff --git a/tests/services/bots/github-webhook/webhook_context.spec.ts b/tests/services/bots/github-webhook/webhook_context.spec.ts new file mode 100644 index 0000000..9116474 --- /dev/null +++ b/tests/services/bots/github-webhook/webhook_context.spec.ts @@ -0,0 +1,78 @@ +// @ts-nocheck +import * as assert from 'assert'; +import { WebhookContext } from '../../../../services/bots/src/github-webhook/github-webhook.model'; + +describe('WebhookContext', () => { + const context = new WebhookContext({ + github: {}, + payload: { + repository: { owner: { login: 'awesome_owner' }, name: 'awesome_name' }, + sender: {}, + number: 1337, + }, + eventType: '', + }); + it('senderIsBot', () => { + context.payload.sender.type = 'Bot'; + assert.deepStrictEqual(context.senderIsBot, true); + + context.payload.sender.type = 'User'; + context.payload.sender.login = 'homeassistant'; + assert.deepStrictEqual(context.senderIsBot, true); + + context.payload.sender = {}; + assert.deepStrictEqual(context.senderIsBot, false); + }); + describe('Context helpers', () => { + it('repo', () => { + assert.deepStrictEqual(context.repo(), { owner: 'awesome_owner', repo: 'awesome_name' }); + assert.deepStrictEqual(context.repo({ additional: true }), { + owner: 'awesome_owner', + repo: 'awesome_name', + additional: true, + }); + }); + it('issue', () => { + assert.deepStrictEqual(context.issue(), { + owner: 'awesome_owner', + repo: 'awesome_name', + issue_number: 1337, + }); + assert.deepStrictEqual(context.issue({ additional: true }), { + owner: 'awesome_owner', + repo: 'awesome_name', + issue_number: 1337, + additional: true, + }); + }); + it('pullRequest', () => { + assert.deepStrictEqual(context.pullRequest(), { + owner: 'awesome_owner', + repo: 'awesome_name', + pull_number: 1337, + }); + assert.deepStrictEqual(context.pullRequest({ additional: true }), { + owner: 'awesome_owner', + repo: 'awesome_name', + pull_number: 1337, + additional: true, + }); + }); + }); + + describe('Schedule helpers', () => { + const context = new WebhookContext({ + github: {}, + payload: {}, + eventType: '', + }); + it('label', () => { + context.scheduleIssueLabel('test'); + assert.deepStrictEqual(context.scheduledlabels, ['test']); + }); + it('comment', () => { + context.scheduleIssueComment('test', 'hi'); + assert.deepStrictEqual(context.scheduledComments, [{ handler: 'test', comment: 'hi' }]); + }); + }); +}); diff --git a/tests/utils/test_context.ts b/tests/utils/test_context.ts new file mode 100644 index 0000000..5a99b2e --- /dev/null +++ b/tests/utils/test_context.ts @@ -0,0 +1,16 @@ +import { WebhookContext } from '../../services/bots/src/github-webhook/github-webhook.model'; + +export class MockWebhookContext extends WebhookContext {} + +export const mockWebhookContext = (params?: Partial>): WebhookContext => + new WebhookContext({ + // @ts-ignore + github: { ...params?.github }, + payload: { + repository: { name: 'core', owner: { login: 'home-assistant' } }, + sender: { type: 'user', login: 'test-developer#1337' }, + number: 1337, + ...params?.payload, + }, + eventType: params?.eventType || '', + }); diff --git a/yarn.lock b/yarn.lock index b4f9e00..ab9199a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1268,6 +1268,42 @@ __metadata: languageName: node linkType: hard +"@sinonjs/commons@npm:^1.6.0, @sinonjs/commons@npm:^1.7.0, @sinonjs/commons@npm:^1.8.3": + version: 1.8.3 + resolution: "@sinonjs/commons@npm:1.8.3" + dependencies: + type-detect: 4.0.8 + checksum: 6159726db5ce6bf9f2297f8427f7ca5b3dff45b31e5cee23496f1fa6ef0bb4eab878b23fb2c5e6446381f6a66aba4968ef2fc255c1180d753d4b8c271636a2e5 + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:>=5, @sinonjs/fake-timers@npm:^9.1.2": + version: 9.1.2 + resolution: "@sinonjs/fake-timers@npm:9.1.2" + dependencies: + "@sinonjs/commons": ^1.7.0 + checksum: 7d3aef54e17c1073101cb64d953157c19d62a40e261a30923fa1ee337b049c5f29cc47b1f0c477880f42b5659848ba9ab897607ac8ea4acd5c30ddcfac57fca6 + languageName: node + linkType: hard + +"@sinonjs/samsam@npm:^6.1.1": + version: 6.1.1 + resolution: "@sinonjs/samsam@npm:6.1.1" + dependencies: + "@sinonjs/commons": ^1.6.0 + lodash.get: ^4.4.2 + type-detect: ^4.0.8 + checksum: a09b0914bf573f0da82bd03c64ba413df81a7c173818dc3f0a90c2652240ac835ef583f4d52f0b215e626633c91a4095c255e0669f6ead97241319f34f05e7fc + languageName: node + linkType: hard + +"@sinonjs/text-encoding@npm:^0.7.1": + version: 0.7.2 + resolution: "@sinonjs/text-encoding@npm:0.7.2" + checksum: fe690002a32ba06906cf87e2e8fe84d1590294586f2a7fd180a65355b53660c155c3273d8011a5f2b77209b819aa7306678ae6e4aea0df014bd7ffd4bbbcf1ab + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -1699,6 +1735,13 @@ __metadata: languageName: node linkType: hard +"@ungap/promise-all-settled@npm:1.1.2": + version: 1.1.2 + resolution: "@ungap/promise-all-settled@npm:1.1.2" + checksum: 08d37fdfa23a6fe8139f1305313562ebad973f3fac01bcce2773b2bda5bcb0146dfdcf3cb6a722cf0a5f2ca0bc56a827eac8f1e7b3beddc548f654addf1fc34c + languageName: node + linkType: hard + "@webassemblyjs/ast@npm:1.11.1": version: 1.11.1 resolution: "@webassemblyjs/ast@npm:1.11.1" @@ -2045,7 +2088,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^4.1.0": +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: @@ -2269,6 +2312,13 @@ __metadata: languageName: node linkType: hard +"arrify@npm:^1.0.0": + version: 1.0.1 + resolution: "arrify@npm:1.0.1" + checksum: 745075dd4a4624ff0225c331dacb99be501a515d39bcb7c84d24660314a6ec28e68131b137e6f7e16318170842ce97538cd298fc4cd6b2cc798e0b957f2747e7 + languageName: node + linkType: hard + "async-retry@npm:^1.2.1": version: 1.3.3 resolution: "async-retry@npm:1.3.3" @@ -2421,6 +2471,13 @@ __metadata: languageName: node linkType: hard +"browser-stdout@npm:1.3.1": + version: 1.3.1 + resolution: "browser-stdout@npm:1.3.1" + checksum: b717b19b25952dd6af483e368f9bcd6b14b87740c3d226c2977a65e84666ffd67000bddea7d911f111a9b6ddc822b234de42d52ab6507bce4119a4cc003ef7b3 + languageName: node + linkType: hard + "browserslist@npm:^4.14.5": version: 4.21.3 resolution: "browserslist@npm:4.21.3" @@ -2444,7 +2501,7 @@ __metadata: languageName: node linkType: hard -"buffer-from@npm:^1.0.0": +"buffer-from@npm:^1.0.0, buffer-from@npm:^1.1.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb @@ -2538,6 +2595,13 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:^6.0.0": + version: 6.3.0 + resolution: "camelcase@npm:6.3.0" + checksum: 8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d + languageName: node + linkType: hard + "caniuse-lite@npm:^1.0.30001370": version: 1.0.30001393 resolution: "caniuse-lite@npm:1.0.30001393" @@ -2683,6 +2747,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.0 + wrap-ansi: ^7.0.0 + checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -2697,6 +2772,18 @@ __metadata: languageName: node linkType: hard +"codeowners-utils@npm:^1.0.2": + version: 1.0.2 + resolution: "codeowners-utils@npm:1.0.2" + dependencies: + cross-spawn: ^7.0.2 + find-up: ^4.1.0 + ignore: ^5.1.4 + locate-path: ^5.0.0 + checksum: 1e1c1f271ad4d4b4b25f6d19fc61f177f010bfb95de9af26662bb09c2f4f5572c1f3c8e9552aff15924f1c97058812bd5b5064d1eea721cc70e17490dae3fb02 + languageName: node + linkType: hard + "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -2928,7 +3015,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -2949,6 +3036,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^4.0.0": + version: 4.0.0 + resolution: "decamelize@npm:4.0.0" + checksum: b7d09b82652c39eead4d6678bb578e3bebd848add894b76d0f6b395bc45b2d692fb88d977e7cfb93c4ed6c119b05a1347cef261174916c2e75c0a8ca57da1809 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -3024,6 +3118,20 @@ __metadata: languageName: node linkType: hard +"diff@npm:5.0.0": + version: 5.0.0 + resolution: "diff@npm:5.0.0" + checksum: f19fe29284b633afdb2725c2a8bb7d25761ea54d321d8e67987ac851c5294be4afeab532bd84531e02583a3fe7f4014aa314a3eda84f5590e7a9e6b371ef3b46 + languageName: node + linkType: hard + +"diff@npm:^3.1.0": + version: 3.5.0 + resolution: "diff@npm:3.5.0" + checksum: 00842950a6551e26ce495bdbce11047e31667deea546527902661f25cc2e73358967ebc78cf86b1a9736ec3e14286433225f9970678155753a6291c3bca5227b + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -3031,6 +3139,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:^5.0.0": + version: 5.1.0 + resolution: "diff@npm:5.1.0" + checksum: c7bf0df7c9bfbe1cf8a678fd1b2137c4fb11be117a67bc18a0e03ae75105e8533dbfb1cda6b46beb3586ef5aed22143ef9d70713977d5fb1f9114e21455fba90 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -3261,6 +3376,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + "escape-string-regexp@npm:^1.0.5": version: 1.0.5 resolution: "escape-string-regexp@npm:1.0.5" @@ -3268,13 +3390,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 - languageName: node - linkType: hard - "eslint-config-prettier@npm:^8.5.0": version: 8.5.0 resolution: "eslint-config-prettier@npm:8.5.0" @@ -3699,7 +3814,17 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^4.0.0": +"find-up@npm:5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" + dependencies: + locate-path: ^6.0.0 + path-exists: ^4.0.0 + checksum: 07955e357348f34660bde7920783204ff5a26ac2cafcaa28bace494027158a97b9f56faaf2d89a6106211a8174db650dd9f503f9c0d526b1202d5554a00b9095 + languageName: node + linkType: hard + +"find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -3719,6 +3844,15 @@ __metadata: languageName: node linkType: hard +"flat@npm:^5.0.2": + version: 5.0.2 + resolution: "flat@npm:5.0.2" + bin: + flat: cli.js + checksum: 12a1536ac746db74881316a181499a78ef953632ddd28050b7a3a43c62ef5462e3357c8c29d76072bb635f147f7a9a1f0c02efef6b4be28f8db62ceb3d5c7f5d + languageName: node + linkType: hard + "flatted@npm:^3.1.0": version: 3.2.7 resolution: "flatted@npm:3.2.7" @@ -3960,6 +4094,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.2.0": + version: 7.2.0 + resolution: "glob@npm:7.2.0" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.0.4 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: 78a8ea942331f08ed2e055cb5b9e40fe6f46f579d7fd3d694f3412fe5db23223d29b7fee1575440202e9a7ff9a72ab106a39fee39934c7bedafe5e5f8ae20134 + languageName: node + linkType: hard + "glob@npm:^7.0.0, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -4106,6 +4254,15 @@ __metadata: languageName: node linkType: hard +"he@npm:1.2.0": + version: 1.2.0 + resolution: "he@npm:1.2.0" + bin: + he: bin/he + checksum: 3d4d6babccccd79c5c5a3f929a68af33360d6445587d628087f39a965079d84f18ce9c3d3f917ee1e3978916fc833bb8b29377c3b403f919426f91bc6965e7a7 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.0": version: 4.1.0 resolution: "http-cache-semantics@npm:4.1.0" @@ -4195,7 +4352,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.1.8, ignore@npm:^5.2.0": +"ignore@npm:^5.1.4, ignore@npm:^5.1.8, ignore@npm:^5.2.0": version: 5.2.0 resolution: "ignore@npm:5.2.0" checksum: 6b1f926792d614f64c6c83da3a1f9c83f6196c2839aa41e1e32dd7b8d174cef2e329d75caabb62cb61ce9dc432f75e67d07d122a037312db7caa73166a1bdb77 @@ -4471,6 +4628,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^2.1.0": + version: 2.1.0 + resolution: "is-plain-obj@npm:2.1.0" + checksum: cec9100678b0a9fe0248a81743041ed990c2d4c99f893d935545cfbc42876cbe86d207f3b895700c690ad2fa520e568c44afc1605044b535a7820c1d40e38daa + languageName: node + linkType: hard + "is-plain-object@npm:^5.0.0": version: 5.0.0 resolution: "is-plain-object@npm:5.0.0" @@ -4551,6 +4715,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:0.0.1": + version: 0.0.1 + resolution: "isarray@npm:0.0.1" + checksum: 49191f1425681df4a18c2f0f93db3adb85573bcdd6a4482539d98eac9e705d8961317b01175627e860516a2fc45f8f9302db26e5a380a97a520e272e2a40a8d4 + languageName: node + linkType: hard + "isarray@npm:^1.0.0, isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -4625,7 +4796,7 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": +"js-yaml@npm:4.1.0, js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" dependencies: @@ -4704,6 +4875,13 @@ __metadata: languageName: node linkType: hard +"just-extend@npm:^4.0.2": + version: 4.2.1 + resolution: "just-extend@npm:4.2.1" + checksum: ff9fdede240fad313efeeeb68a660b942e5586d99c0058064c78884894a2690dc09bba44c994ad4e077e45d913fef01a9240c14a72c657b53687ac58de53b39c + languageName: node + linkType: hard + "leven@npm:2.1.0": version: 2.1.0 resolution: "leven@npm:2.1.0" @@ -4769,6 +4947,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: ^5.0.0 + checksum: 72eb661788a0368c099a184c59d2fee760b3831c9c1c33955e8a19ae4a21b4116e53fa736dc086cdeb9fce9f7cc508f2f92d2d3aae516f133e16a2bb59a39f5a + languageName: node + linkType: hard + "lodash.clonedeep@npm:^4.5.0": version: 4.5.0 resolution: "lodash.clonedeep@npm:4.5.0" @@ -4776,6 +4963,13 @@ __metadata: languageName: node linkType: hard +"lodash.get@npm:^4.4.2": + version: 4.4.2 + resolution: "lodash.get@npm:4.4.2" + checksum: e403047ddb03181c9d0e92df9556570e2b67e0f0a930fcbbbd779370972368f5568e914f913e93f3b08f6d492abc71e14d4e9b7a18916c31fa04bd2306efe545 + languageName: node + linkType: hard + "lodash.memoize@npm:4.x": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -4818,7 +5012,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^4.1.0": +"log-symbols@npm:4.1.0, log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: @@ -4998,6 +5192,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:5.0.1": + version: 5.0.1 + resolution: "minimatch@npm:5.0.1" + dependencies: + brace-expansion: ^2.0.1 + checksum: b34b98463da4754bc526b244d680c69d4d6089451ebe512edaf6dd9eeed0279399cfa3edb19233513b8f830bf4bfcad911dddcdf125e75074100d52f724774f0 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -5093,7 +5296,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.4": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -5113,6 +5316,39 @@ __metadata: languageName: node linkType: hard +"mocha@npm:^10.0.0": + version: 10.0.0 + resolution: "mocha@npm:10.0.0" + dependencies: + "@ungap/promise-all-settled": 1.1.2 + ansi-colors: 4.1.1 + browser-stdout: 1.3.1 + chokidar: 3.5.3 + debug: 4.3.4 + diff: 5.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 7.2.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.0.1 + ms: 2.1.3 + nanoid: 3.3.3 + serialize-javascript: 6.0.0 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.2.1 + yargs: 16.2.0 + yargs-parser: 20.2.4 + yargs-unparser: 2.0.0 + bin: + _mocha: bin/_mocha + mocha: bin/mocha.js + checksum: ba49ddcf8015a467e744b06c396aab361b1281302e38e7c1269af25ba51ff9ab681a9c36e9046bb7491e751cd7d5ce85e276a00ce7e204f96b2c418e4595edfe + languageName: node + linkType: hard + "moment-timezone@npm:^0.5.x": version: 0.5.37 resolution: "moment-timezone@npm:0.5.37" @@ -5179,6 +5415,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:3.3.3": + version: 3.3.3 + resolution: "nanoid@npm:3.3.3" + bin: + nanoid: bin/nanoid.cjs + checksum: ada019402a07464a694553c61d2dca8a4353645a7d92f2830f0d487fedff403678a0bee5323a46522752b2eab95a0bc3da98b6cccaa7c0c55cd9975130e6d6f0 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -5210,6 +5455,19 @@ __metadata: languageName: node linkType: hard +"nise@npm:^5.1.1": + version: 5.1.1 + resolution: "nise@npm:5.1.1" + dependencies: + "@sinonjs/commons": ^1.8.3 + "@sinonjs/fake-timers": ">=5" + "@sinonjs/text-encoding": ^0.7.1 + just-extend: ^4.0.2 + path-to-regexp: ^1.7.0 + checksum: d8be29e84a014743c9a10f428fac86f294ac5f92bed1f606fe9b551e935f494d8e0ce1af8a12673c6014010ec7f771f2d48aa5c8e116f223eb4f40c5e1ab44b3 + languageName: node + linkType: hard + "node-emoji@npm:1.11.0": version: 1.11.0 resolution: "node-emoji@npm:1.11.0" @@ -5448,6 +5706,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^3.0.2": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: ^0.1.0 + checksum: 7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -5457,6 +5724,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: ^3.0.2 + checksum: 1623088f36cf1cbca58e9b61c4e62bf0c60a07af5ae1ca99a720837356b5b6c5ba3eb1b2127e47a06865fee59dd0453cad7cc844cda9d5a62ac1a5a51b7c86d3 + languageName: node + linkType: hard + "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0" @@ -5550,6 +5826,15 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^1.7.0": + version: 1.8.0 + resolution: "path-to-regexp@npm:1.8.0" + dependencies: + isarray: 0.0.1 + checksum: 709f6f083c0552514ef4780cb2e7e4cf49b0cc89a97439f2b7cc69a608982b7690fb5d1720a7473a59806508fc2dae0be751ba49f495ecf89fd8fbc62abccbcd + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -5958,6 +6243,13 @@ __metadata: languageName: node linkType: hard +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80 + languageName: node + linkType: hard + "require-from-string@npm:^2.0.2": version: 2.0.2 resolution: "require-from-string@npm:2.0.2" @@ -6173,7 +6465,7 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^6.0.0": +"serialize-javascript@npm:6.0.0, serialize-javascript@npm:^6.0.0": version: 6.0.0 resolution: "serialize-javascript@npm:6.0.0" dependencies: @@ -6223,6 +6515,7 @@ __metadata: "@typescript-eslint/parser": ^5.21.0 apollo-server-express: ^3.6.7 aws-sdk: ^2.1211.0 + codeowners-utils: ^1.0.2 convict: ^6.2.3 discord.js: ^14.3.0 eslint: 8.14.0 @@ -6230,6 +6523,7 @@ __metadata: eslint-plugin-import: ^2.26.0 find-up: ^4.0.0 graphql: ^16.6.0 + mocha: ^10.0.0 nestjs-pino: ^2.5.2 node-fetch: 2 pino: ^8.4.2 @@ -6239,9 +6533,11 @@ __metadata: reflect-metadata: ^0.1.13 rimraf: ^3.0.2 rxjs: ^7.5.5 + sinon: ^14.0.0 source-map-support: ^0.5.21 ts-jest: 27.1.4 ts-loader: ^9.3.0 + ts-mocha: ^10.0.0 ts-morph: ^15.1.0 ts-node: ^10.7.0 tsconfig-paths: ^3.14.1 @@ -6323,6 +6619,20 @@ __metadata: languageName: node linkType: hard +"sinon@npm:^14.0.0": + version: 14.0.0 + resolution: "sinon@npm:14.0.0" + dependencies: + "@sinonjs/commons": ^1.8.3 + "@sinonjs/fake-timers": ^9.1.2 + "@sinonjs/samsam": ^6.1.1 + diff: ^5.0.0 + nise: ^5.1.1 + supports-color: ^7.2.0 + checksum: b2aeeb0cdc2cd30f904ccbcd60bae4e1b3dcf3aeeface09c1832db0336be0dbaa461f3b91b769bed84f05c83d45d5072a9da7ee14bc7289daeda2a1214fe173c + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -6376,7 +6686,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:0.5.21, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": +"source-map-support@npm:0.5.21, source-map-support@npm:^0.5.21, source-map-support@npm:^0.5.6, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -6518,7 +6828,7 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": +"strip-json-comments@npm:3.1.1, strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 @@ -6550,6 +6860,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:8.1.1, supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: ^4.0.0 + checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -6559,7 +6878,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.1.0": +"supports-color@npm:^7.1.0, supports-color@npm:^7.2.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: @@ -6568,15 +6887,6 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": - version: 8.1.1 - resolution: "supports-color@npm:8.1.1" - dependencies: - has-flag: ^4.0.0 - checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 - languageName: node - linkType: hard - "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -6793,6 +7103,23 @@ __metadata: languageName: node linkType: hard +"ts-mocha@npm:^10.0.0": + version: 10.0.0 + resolution: "ts-mocha@npm:10.0.0" + dependencies: + ts-node: 7.0.1 + tsconfig-paths: ^3.5.0 + peerDependencies: + mocha: ^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X + dependenciesMeta: + tsconfig-paths: + optional: true + bin: + ts-mocha: bin/ts-mocha + checksum: b11f2a8ceecf195b0db724da429159982fef12e4357088fe900289223587217e8c126ead7929679edd58bf19ad96c5da5911535d26f535386632e18fbff10c40 + languageName: node + linkType: hard + "ts-morph@npm:^15.1.0": version: 15.1.0 resolution: "ts-morph@npm:15.1.0" @@ -6803,6 +7130,24 @@ __metadata: languageName: node linkType: hard +"ts-node@npm:7.0.1": + version: 7.0.1 + resolution: "ts-node@npm:7.0.1" + dependencies: + arrify: ^1.0.0 + buffer-from: ^1.1.0 + diff: ^3.1.0 + make-error: ^1.1.1 + minimist: ^1.2.0 + mkdirp: ^0.5.1 + source-map-support: ^0.5.6 + yn: ^2.0.0 + bin: + ts-node: dist/bin.js + checksum: 07ed6ea1805361828737a767cfd6c57ea6e267ee8679282afb933610af02405e1a87c1f2aea1d38ed8e66b34fcbf6272b6021ab95d78849105d2e57fc283870b + languageName: node + linkType: hard + "ts-node@npm:^10.7.0": version: 10.9.1 resolution: "ts-node@npm:10.9.1" @@ -6852,7 +7197,7 @@ __metadata: languageName: node linkType: hard -"tsconfig-paths@npm:3.14.1, tsconfig-paths@npm:^3.14.1, tsconfig-paths@npm:^3.9.0": +"tsconfig-paths@npm:3.14.1, tsconfig-paths@npm:^3.14.1, tsconfig-paths@npm:^3.5.0, tsconfig-paths@npm:^3.9.0": version: 3.14.1 resolution: "tsconfig-paths@npm:3.14.1" dependencies: @@ -6898,6 +7243,13 @@ __metadata: languageName: node linkType: hard +"type-detect@npm:4.0.8, type-detect@npm:^4.0.8": + version: 4.0.8 + resolution: "type-detect@npm:4.0.8" + checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15 + languageName: node + linkType: hard + "type-fest@npm:^0.20.2": version: 0.20.2 resolution: "type-fest@npm:0.20.2" @@ -7298,6 +7650,24 @@ __metadata: languageName: node linkType: hard +"workerpool@npm:6.2.1": + version: 6.2.1 + resolution: "workerpool@npm:6.2.1" + checksum: c2c6eebbc5225f10f758d599a5c016fa04798bcc44e4c1dffb34050cd361d7be2e97891aa44419e7afe647b1f767b1dc0b85a5e046c409d890163f655028b09d + languageName: node + linkType: hard + +"wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + languageName: node + linkType: hard + "wrappy@npm:1": version: 1.0.2 resolution: "wrappy@npm:1.0.2" @@ -7386,6 +7756,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -7400,16 +7777,64 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:20.x, yargs-parser@npm:^20.2.7": +"yargs-parser@npm:20.2.4": + version: 20.2.4 + resolution: "yargs-parser@npm:20.2.4" + checksum: d251998a374b2743a20271c2fd752b9fbef24eb881d53a3b99a7caa5e8227fcafd9abf1f345ac5de46435821be25ec12189a11030c12ee6481fef6863ed8b924 + languageName: node + linkType: hard + +"yargs-parser@npm:20.x, yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.7": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 languageName: node linkType: hard +"yargs-unparser@npm:2.0.0": + version: 2.0.0 + resolution: "yargs-unparser@npm:2.0.0" + dependencies: + camelcase: ^6.0.0 + decamelize: ^4.0.0 + flat: ^5.0.2 + is-plain-obj: ^2.1.0 + checksum: 68f9a542c6927c3768c2f16c28f71b19008710abd6b8f8efbac6dcce26bbb68ab6503bed1d5994bdbc2df9a5c87c161110c1dfe04c6a3fe5c6ad1b0e15d9a8a3 + languageName: node + linkType: hard + +"yargs@npm:16.2.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: ^7.0.2 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.0 + y18n: ^5.0.5 + yargs-parser: ^20.2.2 + checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" checksum: 2c487b0e149e746ef48cda9f8bad10fc83693cd69d7f9dcd8be4214e985de33a29c9e24f3c0d6bcf2288427040a8947406ab27f7af67ee9456e6b84854f02dd6 languageName: node linkType: hard + +"yn@npm:^2.0.0": + version: 2.0.0 + resolution: "yn@npm:2.0.0" + checksum: 9d49527cb3e9a0948cc057223810bf30607bf04b9ff7666cc1681a6501d660b60d90000c16f9e29311b0f28d8a06222ada565ccdca5f1049cdfefb1908217572 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 + languageName: node + linkType: hard