From 98ac2f437f3d955793355ad7885a8b3db51e3c01 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Wed, 11 Oct 2023 07:14:10 +0000 Subject: [PATCH] Implement codeowner commands to add/remove labels --- .../handlers/code_owners_mention.ts | 4 +- .../issue_comment_commands/commands/base.ts | 9 +- .../issue_comment_commands/commands/close.ts | 7 +- .../commands/label-add.ts | 37 +++++ .../commands/label-remove.ts | 42 ++++++ .../issue_comment_commands/commands/rename.ts | 7 +- .../issue_comment_commands/commands/reopen.ts | 7 +- .../commands/unassign.ts | 10 +- .../handlers/issue_comment_commands/const.ts | 28 ++-- .../issue_comment_commands/handler.ts | 7 +- .../handlers/issue_comment_commands.spec.ts | 139 +++++++++++++++++- tests/utils/test_context.ts | 3 +- 12 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-add.ts create mode 100644 services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-remove.ts diff --git a/services/bots/src/github-webhook/handlers/code_owners_mention.ts b/services/bots/src/github-webhook/handlers/code_owners_mention.ts index cce2bad..3b8138a 100755 --- a/services/bots/src/github-webhook/handlers/code_owners_mention.ts +++ b/services/bots/src/github-webhook/handlers/code_owners_mention.ts @@ -9,6 +9,7 @@ import { CodeOwnersEntry, matchFile } from 'codeowners-utils'; import { ISSUE_COMMENT_COMMANDS } from './issue_comment_commands/handler'; const generateCodeOwnersMentionComment = ( + context: WebhookContext, integration: string, codeOwners: string[], triggerLabel: string, @@ -34,7 +35,7 @@ ${ISSUE_COMMENT_COMMANDS.filter((command) => command.invokerType === 'code_owner ] .filter((item) => item !== undefined) .join(' ') - .trim()}\` ${command.description?.replace('', triggerLabel)}`, + .trim()}\` ${command.description(context)}`, ) .join('\n')} @@ -119,6 +120,7 @@ export class CodeOwnersMention extends BaseWebhookHandler { context.scheduleIssueComment({ handler: 'CodeOwnersMention', comment: generateCodeOwnersMentionComment( + context, integrationName, mentions, triggerLabel, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/base.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/base.ts index 5e3dac2..d7b54ff 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/base.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/base.ts @@ -2,15 +2,16 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../../github-webhook.model'; import { IssueCommentCommandContext } from '../const'; -export class IssueCommentCommandBase { +export abstract class IssueCommentCommandBase { command: string; - description: string; invokerType?: string; requireAdditional?: boolean; exampleAdditional?: string; - async handle( + abstract description(context: WebhookContext): string; + + abstract handle( context: WebhookContext, command: IssueCommentCommandContext, - ) {} + ): Promise; } diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/close.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/close.ts index 53c457c..a2311c1 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/close.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/close.ts @@ -1,13 +1,16 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../../github-webhook.model'; -import { invokerIsCodeOwner, IssueCommentCommandContext } from '../const'; +import { invokerIsCodeOwner, IssueCommentCommandContext, triggerType } from '../const'; import { IssueCommentCommandBase } from './base'; export class CloseIssueCommentCommand extends IssueCommentCommandBase { command = 'close'; - description = 'Closes the .'; invokerType = 'code_owner'; + description(context: WebhookContext) { + return `Closes the ${triggerType(context)}.`; + } + async handle( context: WebhookContext, command: IssueCommentCommandContext, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-add.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-add.ts new file mode 100644 index 0000000..21a5566 --- /dev/null +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-add.ts @@ -0,0 +1,37 @@ +import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { + invokerIsCodeOwner, + IssueCommentCommandContext, + ManageableLabels, + triggerType, +} from '../const'; +import { IssueCommentCommandBase } from './base'; + +export class LabelAddCommentCommand implements IssueCommentCommandBase { + command = 'add-label'; + exampleAdditional = 'needs-more-information'; + invokerType = 'code_owner'; + requireAdditional = true; + + description(context: WebhookContext) { + const validLabels = Array.from(ManageableLabels[context.repository]); + return `Add a label (${validLabels.join(', ')}) to the ${triggerType(context)}.`; + } + + async handle( + context: WebhookContext, + command: IssueCommentCommandContext, + ) { + if (!invokerIsCodeOwner(command)) { + throw new Error('Only the code owner can add labels.'); + } + + if (!ManageableLabels[context.repository].has(command.additional)) { + throw new Error( + `The requested label ${command.additional} is not valid for ${context.repository}`, + ); + } + await context.github.issues.addLabels(context.issue({ labels: [command.additional] })); + } +} diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-remove.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-remove.ts new file mode 100644 index 0000000..d2e8ffd --- /dev/null +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/label-remove.ts @@ -0,0 +1,42 @@ +import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; +import { WebhookContext } from '../../../github-webhook.model'; +import { + invokerIsCodeOwner, + IssueCommentCommandContext, + ManageableLabels, + triggerType, +} from '../const'; +import { IssueCommentCommandBase } from './base'; + +export class LabelRemoveCommentCommand implements IssueCommentCommandBase { + command = 'remove-label'; + exampleAdditional = 'needs-more-information'; + invokerType = 'code_owner'; + requireAdditional = true; + + description(context: WebhookContext) { + const validLabels = Array.from(ManageableLabels[context.repository]); + return `Remove a label (${validLabels.join(', ')}) on the ${triggerType(context)}.`; + } + + async handle( + context: WebhookContext, + command: IssueCommentCommandContext, + ) { + if (!invokerIsCodeOwner(command)) { + throw new Error('Only the code owner can remove labels.'); + } + + if (!ManageableLabels[context.repository].has(command.additional)) { + throw new Error( + `The requested label ${command.additional} is not valid for ${context.repository}`, + ); + } + + if (!command.currentLabels.includes(command.additional)) { + throw new Error(`The requested label ${command.additional} is not active on the issue.`); + } + + await context.github.issues.removeLabel(context.issue({ name: command.additional })); + } +} diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/rename.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/rename.ts index 0e76eb2..66230a4 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/rename.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/rename.ts @@ -1,15 +1,18 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../../github-webhook.model'; -import { invokerIsCodeOwner, IssueCommentCommandContext } from '../const'; +import { invokerIsCodeOwner, IssueCommentCommandContext, triggerType } from '../const'; import { IssueCommentCommandBase } from './base'; export class RenameIssueCommentCommand implements IssueCommentCommandBase { command = 'rename'; - description = 'Renames the .'; exampleAdditional = 'Awesome new title'; invokerType = 'code_owner'; requireAdditional = true; + description(context: WebhookContext) { + return `Renames the ${triggerType(context)}.`; + } + async handle( context: WebhookContext, command: IssueCommentCommandContext, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/reopen.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/reopen.ts index 0745f61..7f2c2db 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/reopen.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/reopen.ts @@ -1,13 +1,16 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../../github-webhook.model'; -import { invokerIsCodeOwner, IssueCommentCommandContext } from '../const'; +import { invokerIsCodeOwner, IssueCommentCommandContext, triggerType } from '../const'; import { IssueCommentCommandBase } from './base'; export class ReopenIssueCommentCommand implements IssueCommentCommandBase { command = 'reopen'; - description = 'Reopen the .'; invokerType = 'code_owner'; + description(context: WebhookContext) { + return `Reopen the ${triggerType(context)}.`; + } + async handle( context: WebhookContext, command: IssueCommentCommandContext, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/unassign.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/unassign.ts index 43361bd..566e82c 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/unassign.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/commands/unassign.ts @@ -1,16 +1,20 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../../github-webhook.model'; -import { invokerIsCodeOwner, IssueCommentCommandContext } from '../const'; +import { invokerIsCodeOwner, IssueCommentCommandContext, triggerType } from '../const'; import { IssueCommentCommandBase } from './base'; export class UnassignIssueCommentCommand implements IssueCommentCommandBase { command = 'unassign'; - description = - 'Removes the current integration label and assignees on the , add the integration domain after the command.'; exampleAdditional = ''; invokerType = 'code_owner'; requireAdditional = true; + description(context: WebhookContext) { + return `Removes the current integration label and assignees on the ${triggerType( + context, + )}, add the integration domain after the command.`; + } + async handle( context: WebhookContext, command: IssueCommentCommandContext, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/const.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/const.ts index a7ad226..d4be51b 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/const.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/const.ts @@ -1,6 +1,23 @@ import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; import { WebhookContext } from '../../github-webhook.model'; import { IntegrationManifest } from '../../utils/integration'; +import { HomeAssistantRepository } from '../../github-webhook.const'; + +export const ManageableLabels = { + [HomeAssistantRepository.CORE]: new Set([ + 'needs-more-information', + 'problem in dependency', + 'problem in custom component', + ]), + [HomeAssistantRepository.HOME_ASSISTANT_IO]: new Set(['needs-more-information']), +}; + +export const triggerType = (context: WebhookContext) => + context.repository === HomeAssistantRepository.CORE + ? context.eventType.startsWith('issues') + ? 'issue' + : 'pull request' + : 'feedback'; export interface IssueCommentCommandContext { invoker: string; @@ -9,17 +26,6 @@ export interface IssueCommentCommandContext { integrationManifests: { [domain: string]: IntegrationManifest }; } -export interface IssueCommentCommand { - description: string; - exampleAdditional?: string; - invokerType?: 'code_owner'; - requireAdditional?: boolean; - handler: ( - context: WebhookContext, - command: IssueCommentCommandContext, - ) => Promise; -} - export const invokerIsCodeOwner = ( command: IssueCommentCommandContext, manifest?: IntegrationManifest, diff --git a/services/bots/src/github-webhook/handlers/issue_comment_commands/handler.ts b/services/bots/src/github-webhook/handlers/issue_comment_commands/handler.ts index 9ca15c1..0701489 100644 --- a/services/bots/src/github-webhook/handlers/issue_comment_commands/handler.ts +++ b/services/bots/src/github-webhook/handlers/issue_comment_commands/handler.ts @@ -9,14 +9,19 @@ import { CloseIssueCommentCommand } from './commands/close'; import { RenameIssueCommentCommand } from './commands/rename'; import { ReopenIssueCommentCommand } from './commands/reopen'; import { UnassignIssueCommentCommand } from './commands/unassign'; +import { LabelAddCommentCommand } from './commands/label-add'; +import { LabelRemoveCommentCommand } from './commands/label-remove'; -const COMMAND_REGEX: RegExp = /^(?@home-assistant)\s(?\w*)(\s(?.*))?$/; +const COMMAND_REGEX: RegExp = + /^(?@home-assistant)\s(?[\w|-]*)(\s(?.*))?$/; export const ISSUE_COMMENT_COMMANDS: IssueCommentCommandBase[] = [ new CloseIssueCommentCommand(), new RenameIssueCommentCommand(), new ReopenIssueCommentCommand(), new UnassignIssueCommentCommand(), + new LabelAddCommentCommand(), + new LabelRemoveCommentCommand(), ]; export class IssueCommentCommands extends BaseWebhookHandler { diff --git a/tests/services/bots/github-webhook/handlers/issue_comment_commands.spec.ts b/tests/services/bots/github-webhook/handlers/issue_comment_commands.spec.ts index 8889d2a..997ac2c 100644 --- a/tests/services/bots/github-webhook/handlers/issue_comment_commands.spec.ts +++ b/tests/services/bots/github-webhook/handlers/issue_comment_commands.spec.ts @@ -2,8 +2,13 @@ import { WebhookContext } from '../../../../../services/bots/src/github-webhook/ import { mockWebhookContext } from '../../../../utils/test_context'; import { loadJsonFixture } from '../../../../utils/fixture'; import { IssueCommentCommands } from '../../../../../services/bots/src/github-webhook/handlers/issue_comment_commands/handler'; -import { IssueCommentCreatedEvent } from '@octokit/webhooks-types'; -import { EventType } from '../../../../../services/bots/src/github-webhook/github-webhook.const'; +import { IssueCommentCreatedEvent, Label } from '@octokit/webhooks-types'; +import { + EventType, + HomeAssistantRepository, +} from '../../../../../services/bots/src/github-webhook/github-webhook.const'; + +const mockedLabel = (name: string) => ({ name } as unknown as Label); describe('IssueCommentCommands', () => { let handler: IssueCommentCommands; @@ -28,10 +33,7 @@ describe('IssueCommentCommands', () => { //@ts-ignore { login: 'test' }, ], - labels: [ - //@ts-ignore - { name: 'integration: awesome' }, - ], + labels: [mockedLabel('integration: awesome')], }, }), }); @@ -174,4 +176,129 @@ describe('IssueCommentCommands', () => { ); }); }); + + describe('command: add-label', () => { + it('by codeowner with valid label', async () => { + mockContext.payload.comment.body = '@home-assistant add-label needs-more-information'; + mockContext.payload.comment.user.login = 'test'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '+1' }), + ); + expect(mockContext.github.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['needs-more-information'] }), + ); + }); + it('not by codeowner with valid label', async () => { + mockContext.payload.comment.body = '@home-assistant add-label needs-more-information'; + mockContext.payload.comment.user.login = 'other'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.addLabels).not.toHaveBeenCalled(); + }); + it('by codeowner without label', async () => { + mockContext.payload.comment.body = '@home-assistant add-label'; + mockContext.payload.comment.user.login = 'test'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.addLabels).not.toHaveBeenCalled(); + }); + it('not by codeowner without label', async () => { + mockContext.payload.comment.body = '@home-assistant add-label'; + mockContext.payload.comment.user.login = 'other'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.addLabels).not.toHaveBeenCalled(); + }); + }); + + describe('command: remove-label', () => { + it('by codeowner with valid label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label needs-more-information'; + mockContext.payload.issue.labels.push(mockedLabel('needs-more-information')); + mockContext.payload.comment.user.login = 'test'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '+1' }), + ); + expect(mockContext.github.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ name: 'needs-more-information' }), + ); + }); + it('by codeowner with invalid label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label some-label'; + mockContext.payload.issue.labels.push(mockedLabel('needs-more-information')); + mockContext.payload.comment.user.login = 'test'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.removeLabel).not.toHaveBeenCalled(); + }); + it('by codeowner with not set label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label some-label'; + mockContext.payload.comment.user.login = 'test'; + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.removeLabel).not.toHaveBeenCalled(); + }); + it('not by codeowner with valid label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label needs-more-information'; + mockContext.payload.comment.user.login = 'other'; + mockContext.payload.issue.labels.push(mockedLabel('needs-more-information')); + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.removeLabel).not.toHaveBeenCalled(); + }); + it('by codeowner without label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label'; + mockContext.payload.comment.user.login = 'test'; + mockContext.payload.issue.labels.push(mockedLabel('needs-more-information')); + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.removeLabel).not.toHaveBeenCalled(); + }); + it('not by codeowner without label', async () => { + mockContext.payload.comment.body = '@home-assistant remove-label'; + mockContext.payload.comment.user.login = 'other'; + mockContext.payload.issue.labels.push(mockedLabel('needs-more-information')); + mockContext.repository = HomeAssistantRepository.CORE; + await handler.handle(mockContext); + + expect(mockContext.github.reactions.createForIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '-1' }), + ); + expect(mockContext.github.issues.removeLabel).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/utils/test_context.ts b/tests/utils/test_context.ts index 43b6bd1..3ce1dda 100644 --- a/tests/utils/test_context.ts +++ b/tests/utils/test_context.ts @@ -15,11 +15,12 @@ export const mockWebhookContext = (params: Partial>): Webho issues: { update: jest.fn(), removeLabel: jest.fn(), + addLabels: jest.fn(), removeAssignees: jest.fn(), }, teams: { listMembersInOrg: jest.fn(), - } + }, }, { ...params?.github }, ),