diff --git a/__tests__/changedFiles.test.ts b/__tests__/changedFiles.test.ts index 0f611c5b9..cbcebb4d3 100644 --- a/__tests__/changedFiles.test.ts +++ b/__tests__/changedFiles.test.ts @@ -4,12 +4,16 @@ import { checkAnyChangedFiles, toChangedFilesMatchConfig } from '../src/changedFiles'; +import {PrFileType} from '../src/labeler'; jest.mock('@actions/core'); jest.mock('@actions/github'); describe('checkAllChangedFiles', () => { - const changedFiles = ['foo.txt', 'bar.txt']; + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 6}, + {name: 'bar.txt', size: 20} + ]; describe('when the globs match every file that has been changed', () => { const globs = ['*.txt']; @@ -31,7 +35,10 @@ describe('checkAllChangedFiles', () => { }); describe('checkAnyChangedFiles', () => { - const changedFiles = ['foo.txt', 'bar.txt']; + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 6}, + {name: 'bar.txt', size: 20} + ]; describe('when any glob matches any of the files that have changed', () => { const globs = ['*.txt', '*.md']; diff --git a/__tests__/fixtures/all_options.yml b/__tests__/fixtures/all_options.yml index f4d5ecc5f..ce0d4fed6 100644 --- a/__tests__/fixtures/all_options.yml +++ b/__tests__/fixtures/all_options.yml @@ -1,14 +1,24 @@ -label1: - - any: - - changed-files: ['glob'] - - head-branch: ['regexp'] - - base-branch: ['regexp'] - - all: - - changed-files: ['glob'] - - head-branch: ['regexp'] - - base-branch: ['regexp'] +label-config: + label1: + - any: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] + - all: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] -label2: - - changed-files: ['glob'] - - head-branch: ['regexp'] - - base-branch: ['regexp'] + label2: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] +size-config: > + { + "100": "XS", + "200": "S", + "500": "M", + "800": "L", + "1000": "XL", + "2000": "XXL" + } diff --git a/__tests__/fixtures/any_and_all.yml b/__tests__/fixtures/any_and_all.yml index 5b8901e5b..4abe8cc95 100644 --- a/__tests__/fixtures/any_and_all.yml +++ b/__tests__/fixtures/any_and_all.yml @@ -1,6 +1,7 @@ -tests: - - any: - - head-branch: ['^tests/', '^test/'] - - changed-files: ['tests/**/*'] - - all: - - changed-files: ['!tests/requirements.txt'] +label-config: + tests: + - any: + - head-branch: ['^tests/', '^test/'] + - changed-files: ['tests/**/*'] + - all: + - changed-files: ['!tests/requirements.txt'] diff --git a/__tests__/fixtures/branches.yml b/__tests__/fixtures/branches.yml index 1ad12ac6f..8484d3136 100644 --- a/__tests__/fixtures/branches.yml +++ b/__tests__/fixtures/branches.yml @@ -1,11 +1,12 @@ -test-branch: - - head-branch: '^test/' +label-config: + test-branch: + - head-branch: '^test/' -feature-branch: - - head-branch: '/feature/' + feature-branch: + - head-branch: '/feature/' -bug-branch: - - head-branch: '^bug/|fix/' + bug-branch: + - head-branch: '^bug/|fix/' -array-branch: - - head-branch: ['^array/'] + array-branch: + - head-branch: ['^array/'] diff --git a/__tests__/fixtures/no_size_config.yml b/__tests__/fixtures/no_size_config.yml new file mode 100644 index 000000000..fc413be70 --- /dev/null +++ b/__tests__/fixtures/no_size_config.yml @@ -0,0 +1,15 @@ +label-config: + label1: + - any: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] + - all: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] + + label2: + - changed-files: ['glob'] + - head-branch: ['regexp'] + - base-branch: ['regexp'] diff --git a/__tests__/fixtures/not_supported.yml b/__tests__/fixtures/not_supported.yml index 8af7523ae..5ac43e684 100644 --- a/__tests__/fixtures/not_supported.yml +++ b/__tests__/fixtures/not_supported.yml @@ -1,3 +1,4 @@ -label: - - all: - - unknown: 'this-is-not-supported' +label-config: + label: + - all: + - unknown: 'this-is-not-supported' diff --git a/__tests__/fixtures/only_pdfs.yml b/__tests__/fixtures/only_pdfs.yml index a645acfc6..301587871 100644 --- a/__tests__/fixtures/only_pdfs.yml +++ b/__tests__/fixtures/only_pdfs.yml @@ -1,2 +1,3 @@ -touched-a-pdf-file: - - changed-files: ['*.pdf'] +label-config: + touched-a-pdf-file: + - changed-files: ['*.pdf'] diff --git a/__tests__/fixtures/only_pdfs_custom_size.yml b/__tests__/fixtures/only_pdfs_custom_size.yml new file mode 100644 index 000000000..e7565b77a --- /dev/null +++ b/__tests__/fixtures/only_pdfs_custom_size.yml @@ -0,0 +1,12 @@ +label-config: + touched-a-pdf-file: + - changed-files: ['*.pdf'] +size-config: > + { + "100": "XS", + "200": "S", + "500": "M", + "800": "L", + "1000": "XL", + "2000": "XXL" + } diff --git a/__tests__/labeler.test.ts b/__tests__/labeler.test.ts index 0b9fb307b..62b0f779f 100644 --- a/__tests__/labeler.test.ts +++ b/__tests__/labeler.test.ts @@ -1,9 +1,12 @@ import { checkMatchConfigs, + checkSizeConfigs, MatchConfig, toMatchConfig, getLabelConfigMapFromObject, - BaseMatchConfig + getSizeConfigMapFromObject, + BaseMatchConfig, + PrFileType } from '../src/labeler'; import * as yaml from 'js-yaml'; import * as core from '@actions/core'; @@ -58,6 +61,40 @@ describe('getLabelConfigMapFromObject', () => { }); }); +describe('getSizeConfigMapFromObject', () => { + describe('get default sizes when size-config is present', () => { + const yamlObject = loadYaml('__tests__/fixtures/no_size_config.yml'); + const expected = new Map(); + expected.set(0, 'XS'); + expected.set(10, 'S'); + expected.set(30, 'M'); + expected.set(100, 'L'); + expected.set(500, 'XL'); + expected.set(1000, 'XXL'); + + it('returns a SizeConfig', () => { + const result = getSizeConfigMapFromObject(yamlObject); + expect(result).toEqual(expected); + }); + }); + + describe('get configured sizes when size-config is present', () => { + const yamlObject = loadYaml('__tests__/fixtures/all_options.yml'); + const expected = new Map(); + expected.set(100, 'XS'); + expected.set(200, 'S'); + expected.set(500, 'M'); + expected.set(800, 'L'); + expected.set(1000, 'XL'); + expected.set(2000, 'XXL'); + + it('returns a SizeConfig', () => { + const result = getSizeConfigMapFromObject(yamlObject); + expect(result).toEqual(expected); + }); + }); +}); + describe('toMatchConfig', () => { describe('when all expected config options are present', () => { const config = { @@ -92,14 +129,17 @@ describe('checkMatchConfigs', () => { const matchConfig: MatchConfig[] = [{any: [{changedFiles: ['*.txt']}]}]; it('returns true when our pattern does match changed files', () => { - const changedFiles = ['foo.txt', 'bar.txt']; + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 6}, + {name: 'bar.txt', size: 20} + ]; const result = checkMatchConfigs(changedFiles, matchConfig); expect(result).toBeTruthy(); }); it('returns false when our pattern does not match changed files', () => { - const changedFiles = ['foo.docx']; + const changedFiles: PrFileType[] = [{name: 'foo.docx', size: 13}]; const result = checkMatchConfigs(changedFiles, matchConfig); expect(result).toBeFalsy(); @@ -109,7 +149,10 @@ describe('checkMatchConfigs', () => { const matchConfig: MatchConfig[] = [ {any: [{changedFiles: ['*.txt']}, {headBranch: ['some-branch']}]} ]; - const changedFiles = ['foo.txt', 'bar.txt']; + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 6}, + {name: 'bar.txt', size: 20} + ]; const result = checkMatchConfigs(changedFiles, matchConfig); expect(result).toBe(true); @@ -121,7 +164,10 @@ describe('checkMatchConfigs', () => { {any: [{changedFiles: ['*.txt']}]}, {any: [{headBranch: ['some-branch']}]} ]; - const changedFiles = ['foo.txt', 'bar.md']; + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 6}, + {name: 'bar.md', size: 20} + ]; it('returns false when only one config matches', () => { const result = checkMatchConfigs(changedFiles, matchConfig); @@ -138,3 +184,92 @@ describe('checkMatchConfigs', () => { }); }); }); + +describe('checkSizeConfigs', () => { + describe('when a single size config is provided', () => { + const sizeConfig: Map = new Map(); + sizeConfig.set(100, 'XS'); + sizeConfig.set(200, 'S'); + sizeConfig.set(500, 'M'); + sizeConfig.set(800, 'L'); + sizeConfig.set(1000, 'XL'); + sizeConfig.set(2000, 'XXL'); + + it('returns size/XXS when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 10}, + {name: 'baz.txt', size: 10}, + {name: 'bar.txt', size: 10} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/XXS'); + }); + + it('returns size/XS when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 50}, + {name: 'baz.txt', size: 50}, + {name: 'bar.txt', size: 50} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/XS'); + }); + + it('returns size/S when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 100}, + {name: 'baz.txt', size: 100}, + {name: 'bar.txt', size: 100} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/S'); + }); + + it('returns size/M when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 200}, + {name: 'baz.txt', size: 200}, + {name: 'bar.txt', size: 200} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/M'); + }); + + it('returns size/L when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 300}, + {name: 'baz.txt', size: 300}, + {name: 'bar.txt', size: 300} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/L'); + }); + + it('returns size/XL when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 400}, + {name: 'baz.txt', size: 400}, + {name: 'bar.txt', size: 400} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/XL'); + }); + + it('returns size/XXL when the size is less than the smallest size', () => { + const changedFiles: PrFileType[] = [ + {name: 'foo.txt', size: 700}, + {name: 'baz.txt', size: 700}, + {name: 'bar.txt', size: 700} + ]; + const result = checkSizeConfigs(changedFiles, sizeConfig); + + expect(result).toBe('size/XXL'); + }); + }); +}); diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 4dfe51120..b234674ed 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,4 +1,4 @@ -import {run} from '../src/labeler'; +import {run, PrFileType} from '../src/labeler'; import * as github from '@actions/github'; import * as core from '@actions/core'; @@ -7,6 +7,13 @@ const fs = jest.requireActual('fs'); jest.mock('@actions/core'); jest.mock('@actions/github'); +type mockGitHubResponseChangedFilesType = { + filename: string; + additions: number; + deletions: number; + changes: number; +}; + const gh = github.getOctokit('_'); const addLabelsMock = jest.spyOn(gh.rest.issues, 'addLabels'); const removeLabelMock = jest.spyOn(gh.rest.issues, 'removeLabel'); @@ -17,6 +24,9 @@ const getPullMock = jest.spyOn(gh.rest.pulls, 'get'); const yamlFixtures = { 'branches.yml': fs.readFileSync('__tests__/fixtures/branches.yml'), 'only_pdfs.yml': fs.readFileSync('__tests__/fixtures/only_pdfs.yml'), + 'only_pdfs_custom_size.yml': fs.readFileSync( + '__tests__/fixtures/only_pdfs_custom_size.yml' + ), 'not_supported.yml': fs.readFileSync('__tests__/fixtures/not_supported.yml'), 'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml') }; @@ -26,7 +36,12 @@ afterAll(() => jest.restoreAllMocks()); describe('run', () => { it('adds labels to PRs that match our glob patterns', async () => { usingLabelerConfigYaml('only_pdfs.yml'); - mockGitHubResponseChangedFiles('foo.pdf'); + mockGitHubResponseChangedFiles({ + filename: 'foo.pdf', + additions: 10, + deletions: 10, + changes: 10 + }); await run(); @@ -42,7 +57,12 @@ describe('run', () => { it('does not add labels to PRs that do not match our glob patterns', async () => { usingLabelerConfigYaml('only_pdfs.yml'); - mockGitHubResponseChangedFiles('foo.txt'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); await run(); @@ -75,7 +95,12 @@ describe('run', () => { ); usingLabelerConfigYaml('only_pdfs.yml'); - mockGitHubResponseChangedFiles('foo.txt'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); getPullMock.mockResolvedValue({ data: { labels: [{name: 'touched-a-pdf-file'}] @@ -111,7 +136,12 @@ describe('run', () => { ); usingLabelerConfigYaml('only_pdfs.yml'); - mockGitHubResponseChangedFiles('foo.txt'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); getPullMock.mockResolvedValue({ data: { labels: [{name: 'touched-a-pdf-file'}] @@ -184,7 +214,12 @@ describe('run', () => { it('adds a label when matching any and all patterns are provided', async () => { usingLabelerConfigYaml('any_and_all.yml'); - mockGitHubResponseChangedFiles('tests/test.ts'); + mockGitHubResponseChangedFiles({ + filename: 'tests/test.ts', + additions: 10, + deletions: 10, + changes: 10 + }); await run(); expect(addLabelsMock).toHaveBeenCalledTimes(1); @@ -198,12 +233,143 @@ describe('run', () => { it('does not add a label when not all any and all patterns are matched', async () => { usingLabelerConfigYaml('any_and_all.yml'); - mockGitHubResponseChangedFiles('tests/requirements.txt'); + mockGitHubResponseChangedFiles({ + filename: 'tests/requirements.txt', + additions: 10, + deletions: 10, + changes: 10 + }); await run(); expect(addLabelsMock).toHaveBeenCalledTimes(0); expect(removeLabelMock).toHaveBeenCalledTimes(0); }); + + it('(with check-size: true, sync-labels: true) it deletes preexisting PR labels that no longer match the glob pattern and adds the size label', async () => { + const mockInput = { + 'repo-token': 'foo', + 'configuration-path': 'bar', + 'check-size': 'true', + 'sync-labels': 'true' + }; + + jest + .spyOn(core, 'getInput') + .mockImplementation((name: string, ...opts) => mockInput[name]); + jest + .spyOn(core, 'getBooleanInput') + .mockImplementation( + (name: string, ...opts) => mockInput[name] === 'true' + ); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); + getPullMock.mockResolvedValue({ + data: { + labels: [{name: 'touched-a-pdf-file'}] + } + }); + + await run(); + + expect(addLabelsMock).toHaveBeenCalledTimes(1); + expect(addLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + labels: ['size/M'] + }); + expect(removeLabelMock).toHaveBeenCalledTimes(1); + expect(removeLabelMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + name: 'touched-a-pdf-file' + }); + }); + + it('(with check-size: true) adds a label based on the PR size', async () => { + const mockInput = { + 'repo-token': 'foo', + 'configuration-path': 'bar', + 'check-size': 'true' + }; + + jest + .spyOn(core, 'getInput') + .mockImplementation((name: string, ...opts) => mockInput[name]); + jest + .spyOn(core, 'getBooleanInput') + .mockImplementation( + (name: string, ...opts) => mockInput[name] === 'true' + ); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); + getPullMock.mockResolvedValue({ + data: { + labels: [{name: 'touched-a-pdf-file'}] + } + }); + + await run(); + + expect(addLabelsMock).toHaveBeenCalledTimes(1); + expect(addLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + labels: ['size/M'] + }); + }); +}); + +it('(with check-size: true with custom size config) adds a label based on the PR size and customf size config', async () => { + const mockInput = { + 'repo-token': 'foo', + 'configuration-path': 'bar', + 'check-size': 'true' + }; + + jest + .spyOn(core, 'getInput') + .mockImplementation((name: string, ...opts) => mockInput[name]); + jest + .spyOn(core, 'getBooleanInput') + .mockImplementation((name: string, ...opts) => mockInput[name] === 'true'); + + usingLabelerConfigYaml('only_pdfs_custom_size.yml'); + mockGitHubResponseChangedFiles({ + filename: 'foo.txt', + additions: 10, + deletions: 10, + changes: 10 + }); + getPullMock.mockResolvedValue({ + data: { + labels: [{name: 'touched-a-pdf-file'}] + } + }); + + await run(); + + expect(addLabelsMock).toHaveBeenCalledTimes(1); + expect(addLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + labels: ['size/XXS'] + }); }); function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void { @@ -212,7 +378,14 @@ function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void { }); } -function mockGitHubResponseChangedFiles(...files: string[]): void { - const returnValue = files.map(f => ({filename: f})); +function mockGitHubResponseChangedFiles( + ...files: mockGitHubResponseChangedFilesType[] +): void { + const returnValue = files.map(f => ({ + filename: f.filename, + additions: f.additions, + deletions: f.deletions, + changes: f.changes + })); paginateMock.mockReturnValue(returnValue); } diff --git a/action.yml b/action.yml index a3df342b6..1f6bd40f2 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,10 @@ inputs: description: 'Whether or not to remove labels when matching files are reverted' default: false required: false + check-size: + description: 'Whether or not to apply labels based on PR size' + default: false + required: false runs: using: 'node16' diff --git a/dist/index.js b/dist/index.js index 71af26a98..4b59e4032 100644 --- a/dist/index.js +++ b/dist/index.js @@ -162,7 +162,12 @@ function getChangedFiles(client, prNumber) { pull_number: prNumber }); const listFilesResponse = yield client.paginate(listFilesOptions); - const changedFiles = listFilesResponse.map((f) => f.filename); + const changedFiles = listFilesResponse.map((f) => { + return { + name: f.filename, + size: (f.additions + f.deletions + f.changes) + }; + }); core.debug('found changed files:'); for (const file of changedFiles) { core.debug(' ' + file); @@ -190,7 +195,7 @@ function isAnyMatch(changedFile, matchers) { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { core.debug(` - ${printPattern(matcher)}`); - if (matcher.match(changedFile)) { + if (matcher.match(changedFile.name)) { core.debug(` ${printPattern(matcher)} matched`); return true; } @@ -202,7 +207,7 @@ function isAllMatch(changedFile, matchers) { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { core.debug(` - ${printPattern(matcher)}`); - if (!matcher.match(changedFile)) { + if (!matcher.match(changedFile.name)) { core.debug(` ${printPattern(matcher)} did not match`); return false; } @@ -276,19 +281,28 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.checkAll = exports.checkAny = exports.checkMatchConfigs = exports.toMatchConfig = exports.getLabelConfigMapFromObject = exports.run = void 0; +exports.checkAll = exports.checkAny = exports.checkSizeConfigs = exports.checkMatchConfigs = exports.toMatchConfig = exports.getSizeConfigMapFromObject = exports.getLabelConfigMapFromObject = exports.run = void 0; const core = __importStar(__nccwpck_require__(2186)); const github = __importStar(__nccwpck_require__(5438)); const yaml = __importStar(__nccwpck_require__(1917)); const changedFiles_1 = __nccwpck_require__(7358); const branch_1 = __nccwpck_require__(8045); const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const DEFAULT_SIZES = new Map([ + [0, 'XS'], + [10, 'S'], + [30, 'M'], + [100, 'L'], + [500, 'XL'], + [1000, 'XXL'] +]); function run() { return __awaiter(this, void 0, void 0, function* () { try { const token = core.getInput('repo-token'); const configPath = core.getInput('configuration-path', { required: true }); const syncLabels = core.getBooleanInput('sync-labels'); + const checkSizeEnabled = core.getBooleanInput('check-size'); const prNumber = getPrNumber(); if (!prNumber) { core.info('Could not get pull request number from context, exiting'); @@ -302,10 +316,10 @@ function run() { }); core.debug(`fetching changed files for pr #${prNumber}`); const changedFiles = yield (0, changedFiles_1.getChangedFiles)(client, prNumber); - const labelConfigs = yield getMatchConfigs(client, configPath); + const labelerConfigs = yield getConfigs(client, configPath); const labels = []; const labelsToRemove = []; - for (const [label, configs] of labelConfigs.entries()) { + for (const [label, configs] of labelerConfigs.labels.entries()) { core.debug(`processing ${label}`); if (checkMatchConfigs(changedFiles, configs)) { labels.push(label); @@ -314,6 +328,10 @@ function run() { labelsToRemove.push(label); } } + if (checkSizeEnabled) { + const sizeLabel = checkSizeConfigs(changedFiles, labelerConfigs.size); + labels.push(sizeLabel); + } if (labels.length > 0) { yield addLabels(client, prNumber, labels); } @@ -335,13 +353,16 @@ function getPrNumber() { } return pullRequest.number; } -function getMatchConfigs(client, configurationPath) { +function getConfigs(client, configurationPath) { return __awaiter(this, void 0, void 0, function* () { const configurationContent = yield fetchContent(client, configurationPath); // loads (hopefully) a `{[label:string]: MatchConfig[]}`, but is `any`: const configObject = yaml.load(configurationContent); - // transform `any` => `Map` or throw if yaml is malformed: - return getLabelConfigMapFromObject(configObject); + return { + // transform `any` => `Map` or throw if yaml is malformed: + labels: getLabelConfigMapFromObject(configObject), + size: getSizeConfigMapFromObject(configObject) + }; }); } function fetchContent(client, repoPath) { @@ -356,9 +377,10 @@ function fetchContent(client, repoPath) { }); } function getLabelConfigMapFromObject(configObject) { + const labelConfigObject = configObject['label-config']; const labelMap = new Map(); - for (const label in configObject) { - const configOptions = configObject[label]; + for (const label in labelConfigObject) { + const configOptions = labelConfigObject[label]; if (!Array.isArray(configOptions) || !configOptions.every(opts => typeof opts === 'object')) { throw Error(`found unexpected type for label '${label}' (should be array of config options)`); @@ -403,6 +425,23 @@ function getLabelConfigMapFromObject(configObject) { return labelMap; } exports.getLabelConfigMapFromObject = getLabelConfigMapFromObject; +function getSizeConfigMapFromObject(configObject) { + const sizeConfigObject = configObject['size-config']; + if (sizeConfigObject === undefined) { + return DEFAULT_SIZES; + } + const sizesConfig = new Map(); + const sizesObject = JSON.parse(sizeConfigObject); + for (const [key, value] of Object.entries(sizesObject)) { + const keyNum = Number(key); + if (Number.isNaN(keyNum)) { + throw Error(`found non-number as key in size config ${key} (keys of size config should always be a valid number)`); + } + sizesConfig.set(keyNum, value); + } + return sizesConfig; +} +exports.getSizeConfigMapFromObject = getSizeConfigMapFromObject; function toMatchConfig(config) { const changedFilesConfig = (0, changedFiles_1.toChangedFilesMatchConfig)(config); const branchConfig = (0, branch_1.toBranchMatchConfig)(config); @@ -436,6 +475,27 @@ function checkMatch(changedFiles, matchConfig) { } return true; } +function checkSizeConfigs(changedFiles, sizeConfigs) { + let label = undefined; + let totalSize = 0; + for (const file of changedFiles) { + totalSize += file.size; + } + const sortedSizeKeys = [...sizeConfigs.keys()].sort((a, b) => { + return a - b; + }); + for (const lines of sortedSizeKeys) { + if (totalSize >= lines) { + label = `size/${sizeConfigs.get(lines)}`; + } + } + if (label === undefined) { + core.warning(`The size of the PR is smaller than the smallest configured size ${sortedSizeKeys.slice(-1)}. Setting size to XXS.`); + label = 'size/XXS'; + } + return label; +} +exports.checkSizeConfigs = checkSizeConfigs; // equivalent to "Array.some()" but expanded for debugging and clarity function checkAny(matchConfigs, changedFiles) { core.debug(` checking "any" patterns`); diff --git a/src/changedFiles.ts b/src/changedFiles.ts index cd83f3d62..34850aed8 100644 --- a/src/changedFiles.ts +++ b/src/changedFiles.ts @@ -1,6 +1,7 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; import {Minimatch} from 'minimatch'; +import {PrFileType} from './labeler'; export interface ChangedFilesMatchConfig { changedFiles?: string[]; @@ -11,7 +12,7 @@ type ClientType = ReturnType; export async function getChangedFiles( client: ClientType, prNumber: number -): Promise { +): Promise { const listFilesOptions = client.rest.pulls.listFiles.endpoint.merge({ owner: github.context.repo.owner, repo: github.context.repo.repo, @@ -19,7 +20,12 @@ export async function getChangedFiles( }); const listFilesResponse = await client.paginate(listFilesOptions); - const changedFiles = listFilesResponse.map((f: any) => f.filename); + const changedFiles = listFilesResponse.map((f: any) => { + return { + name: f.filename as string, + size: (f.additions + f.deletions + f.changes) as number + }; + }); core.debug('found changed files:'); for (const file of changedFiles) { @@ -49,11 +55,11 @@ function printPattern(matcher: Minimatch): string { return (matcher.negate ? '!' : '') + matcher.pattern; } -function isAnyMatch(changedFile: string, matchers: Minimatch[]): boolean { +function isAnyMatch(changedFile: PrFileType, matchers: Minimatch[]): boolean { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { core.debug(` - ${printPattern(matcher)}`); - if (matcher.match(changedFile)) { + if (matcher.match(changedFile.name)) { core.debug(` ${printPattern(matcher)} matched`); return true; } @@ -63,11 +69,11 @@ function isAnyMatch(changedFile: string, matchers: Minimatch[]): boolean { return false; } -function isAllMatch(changedFile: string, matchers: Minimatch[]): boolean { +function isAllMatch(changedFile: PrFileType, matchers: Minimatch[]): boolean { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { core.debug(` - ${printPattern(matcher)}`); - if (!matcher.match(changedFile)) { + if (!matcher.match(changedFile.name)) { core.debug(` ${printPattern(matcher)} did not match`); return false; } @@ -78,7 +84,7 @@ function isAllMatch(changedFile: string, matchers: Minimatch[]): boolean { } export function checkAnyChangedFiles( - changedFiles: string[], + changedFiles: PrFileType[], globs: string[] ): boolean { const matchers = globs.map(g => new Minimatch(g)); @@ -94,7 +100,7 @@ export function checkAnyChangedFiles( } export function checkAllChangedFiles( - changedFiles: string[], + changedFiles: PrFileType[], globs: string[] ): boolean { const matchers = globs.map(g => new Minimatch(g)); diff --git a/src/labeler.ts b/src/labeler.ts index ca6a113b8..e063cbb24 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -4,16 +4,16 @@ import * as yaml from 'js-yaml'; import { ChangedFilesMatchConfig, - getChangedFiles, - toChangedFilesMatchConfig, checkAllChangedFiles, - checkAnyChangedFiles + checkAnyChangedFiles, + getChangedFiles, + toChangedFilesMatchConfig } from './changedFiles'; import { - checkAnyBranch, + BranchMatchConfig, checkAllBranch, - toBranchMatchConfig, - BranchMatchConfig + checkAnyBranch, + toBranchMatchConfig } from './branch'; export type BaseMatchConfig = BranchMatchConfig & ChangedFilesMatchConfig; @@ -23,15 +23,34 @@ export type MatchConfig = { all?: BaseMatchConfig[]; }; +export type LabelerConfig = { + labels: Map; + size: Map; +}; + +export type PrFileType = { + name: string; + size: number; +}; + type ClientType = ReturnType; const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const DEFAULT_SIZES = new Map([ + [0, 'XS'], + [10, 'S'], + [30, 'M'], + [100, 'L'], + [500, 'XL'], + [1000, 'XXL'] +]); export async function run() { try { const token = core.getInput('repo-token'); const configPath = core.getInput('configuration-path', {required: true}); const syncLabels = core.getBooleanInput('sync-labels'); + const checkSizeEnabled = core.getBooleanInput('check-size'); const prNumber = getPrNumber(); if (!prNumber) { @@ -48,15 +67,12 @@ export async function run() { }); core.debug(`fetching changed files for pr #${prNumber}`); - const changedFiles: string[] = await getChangedFiles(client, prNumber); - const labelConfigs: Map = await getMatchConfigs( - client, - configPath - ); + const changedFiles: PrFileType[] = await getChangedFiles(client, prNumber); + const labelerConfigs: LabelerConfig = await getConfigs(client, configPath); const labels: string[] = []; const labelsToRemove: string[] = []; - for (const [label, configs] of labelConfigs.entries()) { + for (const [label, configs] of labelerConfigs.labels.entries()) { core.debug(`processing ${label}`); if (checkMatchConfigs(changedFiles, configs)) { labels.push(label); @@ -65,6 +81,11 @@ export async function run() { } } + if (checkSizeEnabled) { + const sizeLabel = checkSizeConfigs(changedFiles, labelerConfigs.size); + labels.push(sizeLabel); + } + if (labels.length > 0) { await addLabels(client, prNumber, labels); } @@ -87,10 +108,10 @@ function getPrNumber(): number | undefined { return pullRequest.number; } -async function getMatchConfigs( +async function getConfigs( client: ClientType, configurationPath: string -): Promise> { +): Promise { const configurationContent: string = await fetchContent( client, configurationPath @@ -99,8 +120,11 @@ async function getMatchConfigs( // loads (hopefully) a `{[label:string]: MatchConfig[]}`, but is `any`: const configObject: any = yaml.load(configurationContent); - // transform `any` => `Map` or throw if yaml is malformed: - return getLabelConfigMapFromObject(configObject); + return { + // transform `any` => `Map` or throw if yaml is malformed: + labels: getLabelConfigMapFromObject(configObject), + size: getSizeConfigMapFromObject(configObject) + }; } async function fetchContent( @@ -120,9 +144,10 @@ async function fetchContent( export function getLabelConfigMapFromObject( configObject: any ): Map { + const labelConfigObject = configObject['label-config']; const labelMap: Map = new Map(); - for (const label in configObject) { - const configOptions = configObject[label]; + for (const label in labelConfigObject) { + const configOptions = labelConfigObject[label]; if ( !Array.isArray(configOptions) || !configOptions.every(opts => typeof opts === 'object') @@ -174,6 +199,28 @@ export function getLabelConfigMapFromObject( return labelMap; } +export function getSizeConfigMapFromObject( + configObject: any +): Map { + const sizeConfigObject = configObject['size-config']; + if (sizeConfigObject === undefined) { + return DEFAULT_SIZES; + } + + const sizesConfig: Map = new Map(); + const sizesObject = JSON.parse(sizeConfigObject); + for (const [key, value] of Object.entries(sizesObject)) { + const keyNum = Number(key); + if (Number.isNaN(keyNum)) { + throw Error( + `found non-number as key in size config ${key} (keys of size config should always be a valid number)` + ); + } + sizesConfig.set(keyNum, value as string); + } + return sizesConfig; +} + export function toMatchConfig(config: any): BaseMatchConfig { const changedFilesConfig = toChangedFilesMatchConfig(config); const branchConfig = toBranchMatchConfig(config); @@ -185,7 +232,7 @@ export function toMatchConfig(config: any): BaseMatchConfig { } export function checkMatchConfigs( - changedFiles: string[], + changedFiles: PrFileType[], matchConfigs: MatchConfig[] ): boolean { for (const config of matchConfigs) { @@ -198,7 +245,10 @@ export function checkMatchConfigs( return true; } -function checkMatch(changedFiles: string[], matchConfig: MatchConfig): boolean { +function checkMatch( + changedFiles: PrFileType[], + matchConfig: MatchConfig +): boolean { if (!Object.keys(matchConfig).length) { core.debug(` no "any" or "all" patterns to check`); return false; @@ -219,10 +269,39 @@ function checkMatch(changedFiles: string[], matchConfig: MatchConfig): boolean { return true; } +export function checkSizeConfigs( + changedFiles: PrFileType[], + sizeConfigs: Map +): string { + let label: string | undefined = undefined; + let totalSize = 0; + for (const file of changedFiles) { + totalSize += file.size; + } + const sortedSizeKeys = [...sizeConfigs.keys()].sort((a, b) => { + return a - b; + }); + for (const lines of sortedSizeKeys) { + if (totalSize >= lines) { + label = `size/${sizeConfigs.get(lines)}`; + } + } + if (label === undefined) { + core.warning( + `The size of the PR is smaller than the smallest configured size ${sortedSizeKeys.slice( + -1 + )}. Setting size to XXS.` + ); + label = 'size/XXS'; + } + + return label; +} + // equivalent to "Array.some()" but expanded for debugging and clarity export function checkAny( matchConfigs: BaseMatchConfig[], - changedFiles: string[] + changedFiles: PrFileType[] ): boolean { core.debug(` checking "any" patterns`); if ( @@ -260,7 +339,7 @@ export function checkAny( // equivalent to "Array.every()" but expanded for debugging and clarity export function checkAll( matchConfigs: BaseMatchConfig[], - changedFiles: string[] + changedFiles: PrFileType[] ): boolean { core.debug(` checking "all" patterns`); if (