diff --git a/README.md b/README.md index 05bfaea6a..21aab32eb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The base match object is defined as: - all-globs-to-all-files: ['list', 'of', 'globs'] - base-branch: ['list', 'of', 'regexps'] - head-branch: ['list', 'of', 'regexps'] +- title: ['list', 'of', 'regexps'] ``` There are two top-level keys, `any` and `all`, which both accept the same configuration options: @@ -49,6 +50,7 @@ There are two top-level keys, `any` and `all`, which both accept the same config - all-globs-to-all-files: ['list', 'of', 'globs'] - base-branch: ['list', 'of', 'regexps'] - head-branch: ['list', 'of', 'regexps'] + - title: ['list', 'of', 'regexps'] - all: - changed-files: - any-glob-to-any-file: ['list', 'of', 'globs'] @@ -57,6 +59,7 @@ There are two top-level keys, `any` and `all`, which both accept the same config - all-globs-to-all-files: ['list', 'of', 'globs'] - base-branch: ['list', 'of', 'regexps'] - head-branch: ['list', 'of', 'regexps'] + - title: ['list', 'of', 'regexps'] ``` From a boolean logic perspective, top-level match objects, and options within `all` are `AND`-ed together and individual match rules within the `any` object are `OR`-ed. @@ -65,13 +68,14 @@ One or all fields can be provided for fine-grained matching. The fields are defined as follows: - `all`: ALL of the provided options must match for the label to be applied - `any`: if ANY of the provided options match then the label will be applied - - `base-branch`: match regexps against the base branch name - - `head-branch`: match regexps against the head branch name - `changed-files`: match glob patterns against the changed paths - `any-glob-to-any-file`: ANY glob must match against ANY changed file - `any-glob-to-all-files`: ANY glob must match against ALL changed files - `all-globs-to-any-file`: ALL globs must match against ANY changed file - `all-globs-to-all-files`: ALL globs must match against ALL changed files + - `base-branch`: match regexps against the base branch name + - `head-branch`: match regexps against the head branch name + - `title`: match regexps against the pull request title If a base option is provided without a top-level key, then it will default to `any`. More specifically, the following two configurations are equivalent: ```yml @@ -144,6 +148,19 @@ feature: # Add 'release' label to any PR that is opened against the `main` branch release: - base-branch: 'main' + +# Add 'chore' label to any PR where the title starts with `chore` +chore: + - title: '^chore' + +# Add 'ci' label to any PR where the title starts with `ci` or `build`: +ci: + - title: ['^ci', '^build'] + +# Add 'web' label to any PR where the title includes conventional commits optional scope +web: + - title: '^\w+\(web\):' + ``` ### Create Workflow diff --git a/__mocks__/@actions/github.ts b/__mocks__/@actions/github.ts index 5d6ecd56d..9dd0ebdc0 100644 --- a/__mocks__/@actions/github.ts +++ b/__mocks__/@actions/github.ts @@ -7,7 +7,8 @@ export const context = { }, base: { ref: 'base-branch-name' - } + }, + title: 'pr-title' } }, repo: { diff --git a/__tests__/title.test.ts b/__tests__/title.test.ts new file mode 100644 index 000000000..264475547 --- /dev/null +++ b/__tests__/title.test.ts @@ -0,0 +1,143 @@ +import { + getTitle, + checkAnyTitle, + checkAllTitle, + toTitleMatchConfig, + TitleMatchConfig +} from '../src/title'; +import * as github from '@actions/github'; + +jest.mock('@actions/core'); +jest.mock('@actions/github'); + +describe('getTitle', () => { + describe('when the pull requests title is requested', () => { + it('returns the title', () => { + const result = getTitle(); + expect(result).toEqual('pr-title'); + }); + }); +}); + +describe('checkAllTitle', () => { + beforeEach(() => { + github.context.payload.pull_request!.title = 'type(scope): description'; + }); + + describe('when a single pattern is provided', () => { + describe('and the pattern matches the title', () => { + it('returns true', () => { + const result = checkAllTitle(['^type']); + expect(result).toBe(true); + }); + }); + + describe('and the pattern does not match the title', () => { + it('returns false', () => { + const result = checkAllTitle(['^feature/']); + expect(result).toBe(false); + }); + }); + }); + + describe('when multiple patterns are provided', () => { + describe('and not all patterns matched', () => { + it('returns false', () => { + const result = checkAllTitle(['^type', '^test']); + expect(result).toBe(false); + }); + }); + + describe('and all patterns match', () => { + it('returns true', () => { + const result = checkAllTitle(['^type', '^\\w+\\(scope\\):']); + expect(result).toBe(true); + }); + }); + + describe('and no patterns match', () => { + it('returns false', () => { + const result = checkAllTitle(['^feature', 'test$']); + expect(result).toBe(false); + }); + }); + }); +}); + +describe('checkAnyTitle', () => { + beforeEach(() => { + github.context.payload.pull_request!.title = 'type(scope): description'; + }); + + describe('when a single pattern is provided', () => { + describe('and the pattern matches the title', () => { + it('returns true', () => { + const result = checkAnyTitle(['^type']); + expect(result).toBe(true); + }); + }); + + describe('and the pattern does not match the title', () => { + it('returns false', () => { + const result = checkAnyTitle(['^test']); + expect(result).toBe(false); + }); + }); + }); + + describe('when multiple patterns are provided', () => { + describe('and at least one pattern matches', () => { + it('returns true', () => { + const result = checkAnyTitle(['^type', '^test']); + expect(result).toBe(true); + }); + }); + + describe('and all patterns match', () => { + it('returns true', () => { + const result = checkAnyTitle(['^type', '^\\w+\\(scope\\):']); + expect(result).toBe(true); + }); + }); + + describe('and no patterns match', () => { + it('returns false', () => { + const result = checkAllTitle(['^feature', 'test$']); + expect(result).toBe(false); + }); + }); + }); +}); + +describe('toTitleMatchConfig', () => { + describe('when there are no title keys in the config', () => { + const config = {'changed-files': [{any: ['testing']}]}; + + it('returns an empty object', () => { + const result = toTitleMatchConfig(config); + expect(result).toEqual({}); + }); + }); + + describe('when the config contains a title option', () => { + const config = {title: ['testing']}; + + it('sets title in the matchConfig', () => { + const result = toTitleMatchConfig(config); + expect(result).toEqual({ + title: ['testing'] + }); + }); + + describe('and the matching option is a string', () => { + const stringConfig = {title: 'testing'}; + + it('sets title in the matchConfig', () => { + const result = toTitleMatchConfig(stringConfig); + expect(result).toEqual({ + title: ['testing'] + }); + }); + }); + }); +}); diff --git a/dist/index.js b/dist/index.js index c049f02d7..be8d87f8d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -277,7 +277,13 @@ const fs_1 = __importDefault(__nccwpck_require__(9896)); const get_content_1 = __nccwpck_require__(6519); const changedFiles_1 = __nccwpck_require__(5145); const branch_1 = __nccwpck_require__(2234); -const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const title_1 = __nccwpck_require__(9798); +const ALLOWED_CONFIG_KEYS = [ + 'changed-files', + 'head-branch', + 'base-branch', + 'title' +]; const getLabelConfigs = (client, configurationPath) => Promise.resolve() .then(() => { if (!fs_1.default.existsSync(configurationPath)) { @@ -352,7 +358,8 @@ function getLabelConfigMapFromObject(configObject) { function toMatchConfig(config) { const changedFilesConfig = (0, changedFiles_1.toChangedFilesMatchConfig)(config); const branchConfig = (0, branch_1.toBranchMatchConfig)(config); - return Object.assign(Object.assign({}, changedFilesConfig), branchConfig); + const titleConfig = (0, title_1.toTitleMatchConfig)(config); + return Object.assign(Object.assign(Object.assign({}, changedFilesConfig), branchConfig), titleConfig); } @@ -1039,6 +1046,7 @@ const lodash_isequal_1 = __importDefault(__nccwpck_require__(9471)); const get_inputs_1 = __nccwpck_require__(1219); const changedFiles_1 = __nccwpck_require__(5145); const branch_1 = __nccwpck_require__(2234); +const title_1 = __nccwpck_require__(9798); // GitHub Issues cannot have more than 100 labels const GITHUB_MAX_LABELS = 100; const run = () => labeler().catch(error => { @@ -1162,6 +1170,12 @@ function checkAny(matchConfigs, changedFiles, dot) { return true; } } + if (matchConfig.title) { + if ((0, title_1.checkAnyTitle)(matchConfig.title)) { + core.debug(` "any" patterns matched`); + return true; + } + } } core.debug(` "any" patterns did not match any configs`); return false; @@ -1197,12 +1211,129 @@ function checkAll(matchConfigs, changedFiles, dot) { return false; } } + if (matchConfig.title) { + if (!(0, title_1.checkAllTitle)(matchConfig.title)) { + core.debug(` "all" patterns did not match`); + return false; + } + } } core.debug(` "all" patterns matched all configs`); return true; } +/***/ }), + +/***/ 9798: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.toTitleMatchConfig = toTitleMatchConfig; +exports.getTitle = getTitle; +exports.checkAnyTitle = checkAnyTitle; +exports.checkAllTitle = checkAllTitle; +const core = __importStar(__nccwpck_require__(7484)); +const github = __importStar(__nccwpck_require__(3228)); +function toTitleMatchConfig(config) { + if (!config['title']) { + return {}; + } + const titleConfig = { + title: config['title'] + }; + if (typeof titleConfig.title === 'string') { + titleConfig.title = [titleConfig.title]; + } + return titleConfig; +} +function getTitle() { + const pullRequest = github.context.payload.pull_request; + if (!pullRequest) { + return undefined; + } + return pullRequest.title; +} +function checkAnyTitle(regexps) { + const title = getTitle(); + if (!title) { + core.debug(` cannot fetch title from the pull request`); + return false; + } + core.debug(` checking "title" pattern against ${title}`); + const matchers = regexps.map(regexp => new RegExp(regexp)); + for (const matcher of matchers) { + if (matchTitlePattern(matcher, title)) { + core.debug(` "title" patterns matched against ${title}`); + return true; + } + } + core.debug(` "title" patterns did not match against ${title}`); + return false; +} +function checkAllTitle(regexps) { + const title = getTitle(); + if (!title) { + core.debug(` cannot fetch title from the pull request`); + return false; + } + core.debug(` checking "title" pattern against ${title}`); + const matchers = regexps.map(regexp => new RegExp(regexp)); + for (const matcher of matchers) { + if (!matchTitlePattern(matcher, title)) { + core.debug(` "title" patterns did not match against ${title}`); + return false; + } + } + core.debug(` "title" patterns matched against ${title}`); + return true; +} +function matchTitlePattern(matcher, title) { + core.debug(` - ${matcher}`); + if (matcher.test(title)) { + core.debug(` "title" pattern matched`); + return true; + } + core.debug(` ${matcher} did not match`); + return false; +} + + /***/ }), /***/ 9277: diff --git a/src/api/get-label-configs.ts b/src/api/get-label-configs.ts index 4db33f28e..68c359387 100644 --- a/src/api/get-label-configs.ts +++ b/src/api/get-label-configs.ts @@ -11,14 +11,23 @@ import { import {toBranchMatchConfig, BranchMatchConfig} from '../branch'; +import {toTitleMatchConfig, TitleMatchConfig} from '../title'; + export interface MatchConfig { all?: BaseMatchConfig[]; any?: BaseMatchConfig[]; } -export type BaseMatchConfig = BranchMatchConfig & ChangedFilesMatchConfig; +export type BaseMatchConfig = BranchMatchConfig & + ChangedFilesMatchConfig & + TitleMatchConfig; -const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const ALLOWED_CONFIG_KEYS = [ + 'changed-files', + 'head-branch', + 'base-branch', + 'title' +]; export const getLabelConfigs = ( client: ClientType, @@ -118,9 +127,11 @@ export function getLabelConfigMapFromObject( export function toMatchConfig(config: any): BaseMatchConfig { const changedFilesConfig = toChangedFilesMatchConfig(config); const branchConfig = toBranchMatchConfig(config); + const titleConfig = toTitleMatchConfig(config); return { ...changedFilesConfig, - ...branchConfig + ...branchConfig, + ...titleConfig }; } diff --git a/src/labeler.ts b/src/labeler.ts index 816544390..d402414f7 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -11,6 +11,8 @@ import {checkAllChangedFiles, checkAnyChangedFiles} from './changedFiles'; import {checkAnyBranch, checkAllBranch} from './branch'; +import {checkAnyTitle, checkAllTitle} from './title'; + type ClientType = ReturnType; // GitHub Issues cannot have more than 100 labels @@ -173,6 +175,13 @@ export function checkAny( return true; } } + + if (matchConfig.title) { + if (checkAnyTitle(matchConfig.title)) { + core.debug(` "any" patterns matched`); + return true; + } + } } core.debug(` "any" patterns did not match any configs`); @@ -220,6 +229,13 @@ export function checkAll( return false; } } + + if (matchConfig.title) { + if (!checkAllTitle(matchConfig.title)) { + core.debug(` "all" patterns did not match`); + return false; + } + } } core.debug(` "all" patterns matched all configs`); diff --git a/src/title.ts b/src/title.ts new file mode 100644 index 000000000..d5a2c5b5e --- /dev/null +++ b/src/title.ts @@ -0,0 +1,83 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +export interface TitleMatchConfig { + title?: string[]; +} + +export function toTitleMatchConfig(config: any): TitleMatchConfig { + if (!config['title']) { + return {}; + } + + const titleConfig = { + title: config['title'] + }; + + if (typeof titleConfig.title === 'string') { + titleConfig.title = [titleConfig.title]; + } + + return titleConfig; +} + +export function getTitle(): string | undefined { + const pullRequest = github.context.payload.pull_request; + + if (!pullRequest) { + return undefined; + } + + return pullRequest.title; +} + +export function checkAnyTitle(regexps: string[]): boolean { + const title = getTitle(); + if (!title) { + core.debug(` cannot fetch title from the pull request`); + return false; + } + + core.debug(` checking "title" pattern against ${title}`); + const matchers = regexps.map(regexp => new RegExp(regexp)); + for (const matcher of matchers) { + if (matchTitlePattern(matcher, title)) { + core.debug(` "title" patterns matched against ${title}`); + return true; + } + } + + core.debug(` "title" patterns did not match against ${title}`); + return false; +} + +export function checkAllTitle(regexps: string[]): boolean { + const title = getTitle(); + if (!title) { + core.debug(` cannot fetch title from the pull request`); + return false; + } + + core.debug(` checking "title" pattern against ${title}`); + const matchers = regexps.map(regexp => new RegExp(regexp)); + for (const matcher of matchers) { + if (!matchTitlePattern(matcher, title)) { + core.debug(` "title" patterns did not match against ${title}`); + return false; + } + } + + core.debug(` "title" patterns matched against ${title}`); + return true; +} + +function matchTitlePattern(matcher: RegExp, title: string): boolean { + core.debug(` - ${matcher}`); + if (matcher.test(title)) { + core.debug(` "title" pattern matched`); + return true; + } + + core.debug(` ${matcher} did not match`); + return false; +}