diff --git a/README.md b/README.md index 0c0da9b..ccfcf46 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Github auto-labeler action [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action?ref=badge_shield) - GitHub actions to auto label a pull request based on committed files.

@@ -54,7 +53,6 @@ action "PR label by Files" { uses = "decathlon/pull-request-labeler-action@v1.0.0" secrets = ["GITHUB_TOKEN"] } - ``` When configuring the action, you need to provide the right link into `uses` field here. @@ -72,6 +70,5 @@ You may need to set it up so it uses the node configuration (`package.json`) and - To run unit tests: `npm run test:watch` - To build: `npm run build:main` - ## License -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FDecathlon%2Fpull-request-labeler-action?ref=badge_large) diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 2677e66..a6db5ca 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -8,14 +8,24 @@ import { Exit } from 'actions-toolkit/lib/exit'; import { GitHub } from 'actions-toolkit/lib/github'; import { LoggerFunc, Signale } from 'signale'; import { Filter, Repository } from './types'; -import { buildIssueRemoveLabelParams, processListFilesResponses } from './utils'; +import { buildIssueRemoveLabelParams, filterConfiguredIssueLabels, intersectLabels, processListFilesResponses } from './utils'; + +const LOGO: string = ` +██████╗ ███████╗ ██████╗ █████╗ ████████╗██╗ ██╗██╗ ██████╗ ███╗ ██╗ +██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██║ ██║██║ ██╔═══██╗████╗ ██║ +██║ ██║█████╗ ██║ ███████║ ██║ ███████║██║ ██║ ██║██╔██╗ ██║ +██║ ██║██╔══╝ ██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╗██║ +██████╔╝███████╗╚██████╗██║ ██║ ██║ ██║ ██║███████╗╚██████╔╝██║ ╚████║ +╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ +`; const args: ToolkitOptions = { event: ['pull_request.opened', 'pull_request.synchronize'], secrets: ['GITHUB_TOKEN'] }; -const findRepositoryInformation = (gitHubEventPath: string, log: LoggerFunc & Signale, exit: Exit): Repository => { +// Returns the repository information using provided gitHubEventPath +const findRepositoryInformation = (gitHubEventPath: string, log: LoggerFunc & Signale, exit: Exit): IssuesListLabelsOnIssueParams => { const payload: WebhookPayloadWithRepository = require(gitHubEventPath); if (payload.number === undefined) { exit.neutral('Action not triggered by a PullRequest action. PR ID is missing') @@ -28,17 +38,33 @@ const findRepositoryInformation = (gitHubEventPath: string, log: LoggerFunc & Si }; }; -const logo = ` -██████╗ ███████╗ ██████╗ █████╗ ████████╗██╗ ██╗██╗ ██████╗ ███╗ ██╗ -██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██║ ██║██║ ██╔═══██╗████╗ ██║ -██║ ██║█████╗ ██║ ███████║ ██║ ███████║██║ ██║ ██║██╔██╗ ██║ -██║ ██║██╔══╝ ██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╗██║ -██████╔╝███████╗╚██████╗██║ ██║ ██║ ██║ ██║███████╗╚██████╔╝██║ ╚████║ -╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ -` +// Find configured filters from the issue labels +const findIssueLabels = (issuesListLabelsOnIssueParams: IssuesListLabelsOnIssueParams, issues, filters: Filter[]): Promise => { + // Find issue labels that are configured in .github/label-pr.yml + return issues.listLabelsOnIssue(issuesListLabelsOnIssueParams) + .then(({ data: labels }: Response) => labels.reduce((acc, label) => acc.concat(label.name), [])) + .then(issueLabels => filterConfiguredIssueLabels(issueLabels, filters)); +}; + +// Remove provided labels +const removeIssueLabels = (labels: string[], { log, exit }: Toolkit, repository: Repository, issues): void => { + log.info('Labels to remove: ', labels); + buildIssueRemoveLabelParams(repository, labels) + .forEach(value => issues.removeLabel(value).catch(reason => exit.failure(reason))); +}; + +// Build labels to add +const getLabelsToAdd = (labels: string[], issueLabels: string[], { log, exit }: Toolkit): string[] => { + const labelsToAdd: string[] = intersectLabels(labels, issueLabels); + log.info('Labels to add: ', labelsToAdd); + if (labelsToAdd.length === 0) { + exit.neutral("No labels to add"); + } + return labelsToAdd; +}; Toolkit.run(async (toolkit: Toolkit) => { - toolkit.log.info('Open sourced by\n'+logo); + toolkit.log.info('Open sourced by\n' + LOGO); toolkit.log.info('Running Action'); const filters: Filter[] = toolkit.config('.github/label-pr.yml'); @@ -47,17 +73,13 @@ Toolkit.run(async (toolkit: Toolkit) => { if (!process.env.GITHUB_EVENT_PATH) { toolkit.exit.failure('Process env GITHUB_EVENT_PATH is undefined'); } else { - const { owner, issue_number, repo }: Repository = findRepositoryInformation(process.env.GITHUB_EVENT_PATH, toolkit.log, toolkit.exit); - const params: PullsListFilesParams = { owner, pull_number: issue_number, repo }; + const { owner, issue_number, repo }: IssuesListLabelsOnIssueParams = findRepositoryInformation(process.env.GITHUB_EVENT_PATH, toolkit.log, toolkit.exit); const { pulls: { listFiles }, issues }: GitHub = toolkit.github; - const issuesListLabelsOnIssueParams: IssuesListLabelsOnIssueParams = { issue_number, owner, repo }; - // Remove issue labels that are configured in .github/label-pr.yml - await issues.listLabelsOnIssue(issuesListLabelsOnIssueParams) - .then(({ data }: Response) => data.reduce((acc, item) => acc.concat(item.name), [])) - .then(issueLabels => buildIssueRemoveLabelParams({ owner, issue_number, repo }, issueLabels, filters)) - .then(issueRemoveLabelParams => issueRemoveLabelParams.forEach(value => issues.removeLabel(value))) - .catch(reason => toolkit.log.error(reason)); + // First, we need to retrieve the existing issue labels and filter them over the configured one in config file + const issueLabels: string[] = await findIssueLabels({ issue_number, owner, repo }, issues, filters); + + const params: PullsListFilesParams = { owner, pull_number: issue_number, repo }; await listFiles(params) .then((response: Response) => response.data) @@ -68,17 +90,15 @@ Toolkit.run(async (toolkit: Toolkit) => { .then((files: PullsListFilesResponseItem[]) => processListFilesResponses(files, filters)) .then((eligibleFilters: Filter[]) => eligibleFilters.reduce((acc: string[], eligibleFilter: Filter) => acc.concat(eligibleFilter.labels), [])) .then((labels: string[]) => { - toolkit.log.info('Labels to add: ', labels); - if (labels.length === 0) { - toolkit.exit.neutral("No labels to add"); + removeIssueLabels(intersectLabels(issueLabels, labels), toolkit, { owner, issue_number, repo }, issues); + return { issue_number, labels: getLabelsToAdd(labels, issueLabels, toolkit), owner, repo }; } - return { issue_number, labels, owner, repo }; - }) + ) .then((addLabelsParams: IssuesAddLabelsParams) => issues.addLabels(addLabelsParams)) .then((value: Response) => toolkit.log.info(`Adding label status: ${value.status}`)) .catch(reason => toolkit.exit.failure(reason)); } - toolkit.exit.success('Labels were added to pull request') + toolkit.exit.success('Labels were update into pull request') }, args ); diff --git a/src/utils.ts b/src/utils.ts index 4efc44e..1ba443a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,18 +6,22 @@ export const processListFilesResponses = (files: PullsListFilesResponseItem[], f filters.filter(filter => files.find(file => new RegExp(filter.regExp).test(file.filename))); // Filter the list of provided labels to return those that are part of provided filters -export const getLabelsToRemove = (labels: string[], filters: Filter[]): string[] => { +export const filterConfiguredIssueLabels = (labels: string[], filters: Filter[]): string[] => { const configuredLabels: string[] = filters.reduce((acc: string[], filter: Filter) => acc.concat(filter.labels), []); // To filter and have a distinct list of labels to remove return [...new Set(configuredLabels.filter(label => labels.includes(label)))]; }; // Build a list of IssueRemoveLabelParams from the list of provided labels -export const buildIssueRemoveLabelParams = ({ repo, issue_number, owner }: Repository, labels: string[], filters: Filter[]): IssuesRemoveLabelParams[] => { - return getLabelsToRemove(labels, filters).map(label => ({ +export const buildIssueRemoveLabelParams = ({ repo, issue_number, owner }: Repository, labels: string[]): IssuesRemoveLabelParams[] => { + return labels.map(label => ({ issue_number, name: label, owner, repo })); }; + +// Filter over the provided labels to return only those that do not appear in provided standard list +export const intersectLabels = (labels: string[], standard: string[]): string[] => + labels.filter(label => !standard.includes(label)); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index ac1500a..fcbdca5 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,7 +1,7 @@ // tslint:disable-next-line:no-implicit-dependencies import { IssuesRemoveLabelParams, PullsListFilesResponseItem } from '@octokit/rest'; import { Filter, Repository } from '../src/types'; -import { buildIssueRemoveLabelParams, getLabelsToRemove, processListFilesResponses } from '../src/utils'; +import { buildIssueRemoveLabelParams, filterConfiguredIssueLabels, intersectLabels, processListFilesResponses } from '../src/utils'; const IMAGE_REGEXP_AS_STRING: string = ".*\\.png+$"; const DOCUMENTATION_REGEXP_AS_STRING: string = ".*\\.md+$"; @@ -51,52 +51,37 @@ describe('processListFilesResponses', () => { filename: "whatever.png" }; - it('should return an empty array if no filters are provided', () => { - const result: Filter[] = processListFilesResponses([ANY_RESPONSE_ITEM], []); - expect(result).toEqual([]); - }); + it('should return an empty array if no filters are provided', + () => expect(processListFilesResponses([ANY_RESPONSE_ITEM], [])).toEqual([])); - it('should return an empty array if response has no data', () => { - const result: Filter[] = processListFilesResponses([], ANY_FILTERS); - expect(result).toEqual([]); - }); + it('should return an empty array if response has no data', + () => expect(processListFilesResponses([], ANY_FILTERS)).toEqual([])); - it('should return an empty array if none filename are defined in filters', () => { - const result: Filter[] = processListFilesResponses([ANY_RESPONSE_ITEM], ANY_FILTERS); - expect(result).toEqual([]); - }); + it('should return an empty array if none filename are defined in filters', + () => expect(processListFilesResponses([ANY_RESPONSE_ITEM], ANY_FILTERS)).toEqual([])); - it('should return an array with eligible filter if files are defined for provided filters', () => { - const result: Filter[] = processListFilesResponses([ANY_RESPONSE_ITEM, ANY_DOCUMENTATION_RESPONSE_ITEM], ANY_FILTERS); - expect(result).toEqual([DOCUMENTATION_FILTER]); - }); + it('should return an array with eligible filter if files are defined for provided filters', + () => expect(processListFilesResponses([ANY_RESPONSE_ITEM, ANY_DOCUMENTATION_RESPONSE_ITEM], ANY_FILTERS)).toEqual([DOCUMENTATION_FILTER])); - it('should return an array with all filters if multiple files are defined for provided filters', () => { - const result: Filter[] = processListFilesResponses([ANY_RESPONSE_ITEM, ANY_DOCUMENTATION_RESPONSE_ITEM, ANY_OTHER_DOCUMENTATION_RESPONSE_ITEM, ANY_IMAGE_RESPONSE_ITEM], ANY_FILTERS); - expect(result).toEqual(ANY_FILTERS); - }); + it('should return an array with all filters if multiple files are defined for provided filters', + () => expect(processListFilesResponses([ANY_RESPONSE_ITEM, ANY_DOCUMENTATION_RESPONSE_ITEM, ANY_OTHER_DOCUMENTATION_RESPONSE_ITEM, ANY_IMAGE_RESPONSE_ITEM], ANY_FILTERS)).toEqual(ANY_FILTERS)); }); -describe('getLabelsToRemove', () => { - it('should return empty list when no filters', () => { - expect(getLabelsToRemove(ANY_LABELS, [])).toStrictEqual([]); - }); +describe('filterConfiguredIssueLabels', () => { + it('should return empty list when no filters', + () => expect(filterConfiguredIssueLabels(ANY_LABELS, [])).toStrictEqual([])); - it('should return full list of filters if no labels', () => { - expect(getLabelsToRemove([], ANY_FILTERS)).toStrictEqual([]); - }); + it('should return full list of filters if no labels', + () => expect(filterConfiguredIssueLabels([], ANY_FILTERS)).toStrictEqual([])); - it('should return empty list if none of the labels are in filters', () => { - expect(getLabelsToRemove(ANY_OTHER_LABELS, ANY_FILTERS)).toStrictEqual([]); - }); + it('should return empty list if none of the labels are in filters', + () => expect(filterConfiguredIssueLabels(ANY_OTHER_LABELS, ANY_FILTERS)).toStrictEqual([])); - it('should return labels that are common with filters', () => { - expect(getLabelsToRemove(ANY_LABELS, ANY_FILTERS)).toStrictEqual(ANY_LABELS); - }); + it('should return labels that are common with filters', + () => expect(filterConfiguredIssueLabels(ANY_LABELS, ANY_FILTERS)).toStrictEqual(ANY_LABELS)); - it('should return labels that are common with filters but with distinct', () => { - expect(getLabelsToRemove(ANY_LABELS, ANY_FILTERS_WITH_DUPLICATES)).toStrictEqual(ANY_LABELS); - }); + it('should return labels that are common with filters but with distinct', + () => expect(filterConfiguredIssueLabels(ANY_LABELS, ANY_FILTERS_WITH_DUPLICATES)).toStrictEqual(ANY_LABELS)); }); describe('buildIssueRemoveLabelParams', () => { @@ -112,27 +97,34 @@ describe('buildIssueRemoveLabelParams', () => { repo: ANY_REPOSITORY.repo }; - it('should return an empty list if no labels are provided', () => { - expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, [], ANY_FILTERS)).toEqual([]); - }); - - it('should return an empty list if no labels match provided filters', () => { - expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, ANY_OTHER_LABELS, ANY_FILTERS)).toEqual([]); - }); + it('should return an empty list if no labels are provided', + () => expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, [])).toEqual([])); it('should return labelParams with provided repository and labels', () => { const expected: IssuesRemoveLabelParams[] = [{ ...DOCUMENTATION_REMOVE_LABEL_PARAM, name: "images" }, DOCUMENTATION_REMOVE_LABEL_PARAM]; - expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, ANY_LABELS, ANY_FILTERS)).toEqual(expected); + expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, ANY_LABELS)).toEqual(expected); }); +}); - it('should return only distinct labelParams to delete when providing repository and labels', () => { - const expected: IssuesRemoveLabelParams[] = [{ - ...DOCUMENTATION_REMOVE_LABEL_PARAM, - name: "images" - }, DOCUMENTATION_REMOVE_LABEL_PARAM]; - expect(buildIssueRemoveLabelParams(ANY_REPOSITORY, ANY_LABELS, ANY_FILTERS_WITH_DUPLICATES)).toEqual(expected); - }); +describe('intersectLabels', () => { + it('should return an empty list when providing list are both empty', + () => expect(intersectLabels([], [])).toEqual([])); + + it('should return an empty list when providing list is empty and standard is not', + () => expect(intersectLabels([], ANY_LABELS)).toEqual([])); + + it('should return provided list of labels when providing an empty standard list', + () => expect(intersectLabels(ANY_LABELS, [])).toEqual(ANY_LABELS)); + + it('should return an empty list when providing both identical list', + () => expect(intersectLabels(ANY_LABELS, ANY_LABELS)).toEqual([])); + + it('should return provided list of labels when none of the standard list match', + () => expect(intersectLabels(ANY_LABELS, ANY_OTHER_LABELS)).toEqual(ANY_LABELS)); + + it('should return an intersection of both list of labels when some matches', + () => expect(intersectLabels(ANY_LABELS, ANY_OTHER_LABELS.concat("documentation"))).toEqual(["images"])); });