From 64661e73afcb20eef48d4963099b956145b638d5 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Mon, 24 Aug 2020 13:24:39 -0400 Subject: [PATCH 1/2] Refactor configEntry to be an object that performs its own label matching --- __tests__/configEntry.test.ts | 94 +++++++++++++++++++++++++++++++++++ lib/ConfigEntry.js | 48 ++++++++++++++++++ lib/config.js | 3 +- lib/labelMatcher.js | 40 +++++++++++++++ lib/main.js | 36 +++----------- src/ConfigEntry.ts | 51 ++++++++++++++++++- src/config.ts | 2 +- src/main.ts | 31 +++--------- 8 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 __tests__/configEntry.test.ts create mode 100644 lib/labelMatcher.js diff --git a/__tests__/configEntry.test.ts b/__tests__/configEntry.test.ts new file mode 100644 index 000000000..7bf119535 --- /dev/null +++ b/__tests__/configEntry.test.ts @@ -0,0 +1,94 @@ +import "jest-extended"; +import nock from "nock"; +import { ConfigEntry } from "../src/ConfigEntry"; + +nock.disableNetConnect(); + +describe("Config entry", () => { + let main; + + beforeEach(() => { + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("shorthand array", () => { + it("adds the 'support' label for 'support/FOO-42-assisting' to 'master'", async () => { + + // Arrange + const entry = new ConfigEntry({ label: "support", head: ["support/*", "sup/*"] }) + + // Act + const label = entry.getLabel("support/FOO-42-assisting", "master"); + + // Assert + expect(label).toEqual("support"); + expect.assertions(1); + }); + }); + + describe("Regular head and base usage", () => { + it("adds the 'bugfix' label for 'bugfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", head: ["bugfix/*", "hotfix/*"] }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); + + it("adds the 'bugfix' label for 'hotfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", head: ["bugfix/*", "hotfix/*"] }) + + // Act + const label = entry.getLabel("hotfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); + + it("adds the 'release' and 'fix' labels for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "release", base: "release/*" }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-changes", "release/1.0.0"); + + // Assert + expect(label).toEqual("release"); + expect.assertions(1); + }); + + it("adds the '🧩 Subtask' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "🧩 Subtask", head: "feature/*", base: "feature/*" }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("🧩 Subtask"); + expect.assertions(1); + }); + }); + + + it("adds no labels if the branch doesn't match any patterns", async () => { + // Arrange + const entry = new ConfigEntry({ label: '' }) + + // Act + const label = entry.getLabel("fix-the-build", "master"); + + // Assert + expect(label).toEqual(undefined); + expect.assertions(1); + }); +}); diff --git a/lib/ConfigEntry.js b/lib/ConfigEntry.js index c8ad2e549..cb2b2076b 100644 --- a/lib/ConfigEntry.js +++ b/lib/ConfigEntry.js @@ -1,2 +1,50 @@ "use strict"; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const matcher_1 = __importDefault(require("matcher")); +class ConfigEntry { + constructor(raw) { + this.label = raw.label; + if (raw.head) { + this.head = raw.head; + } + if (raw.base) { + this.base = raw.base; + } + } + getLabel(headRef, baseRef) { + if (this.head && this.base) { + if (ConfigEntry.isMatch(headRef, this.head) && ConfigEntry.isMatch(baseRef, this.base)) { + core.debug(`Matched "${headRef}" to "${this.head}" and "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + return this.label; + } + return undefined; + } + if (this.head && ConfigEntry.isMatch(headRef, this.head)) { + core.debug(`Matched "${headRef}" to "${this.head}". Setting label to "${this.label}"`); + return this.label; + } + if (this.base && ConfigEntry.isMatch(baseRef, this.base)) { + core.debug(`Matched "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + return this.label; + } + return undefined; + } + static isMatch(ref, patterns) { + return Array.isArray(patterns) + ? patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) + : matcher_1.default.isMatch(ref, patterns); + } +} +exports.ConfigEntry = ConfigEntry; +; diff --git a/lib/config.js b/lib/config.js index 25198b5b5..4cd378fe6 100644 --- a/lib/config.js +++ b/lib/config.js @@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(require("@actions/core")); const js_yaml_1 = __importDefault(require("js-yaml")); const path_1 = __importDefault(require("path")); +const ConfigEntry_1 = require("./ConfigEntry"); const CONFIG_PATH = ".github"; function getConfig(github, fileName, context) { return __awaiter(this, void 0, void 0, function* () { @@ -61,7 +62,7 @@ function parseConfig(content) { const headPattern = object.head || (typeof object === "string" || Array.isArray(object) ? object : undefined); const basePattern = object.base; if (headPattern || basePattern) { - entries.push({ label: label, head: headPattern, base: basePattern }); + entries.push(new ConfigEntry_1.ConfigEntry({ label: label, head: headPattern, base: basePattern })); } else { throw new Error("config.yml has invalid structure."); diff --git a/lib/labelMatcher.js b/lib/labelMatcher.js new file mode 100644 index 000000000..513c75b5d --- /dev/null +++ b/lib/labelMatcher.js @@ -0,0 +1,40 @@ +"use strict"; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const matcher_1 = __importDefault(require("matcher")); +class LabelMatcher { + static match(headRef, baseRef, entry) { + if (entry.head && entry.base) { + if (isMatch(headRef, entry.head) && isMatch(baseRef, entry.base)) { + core.debug(`Matched "${headRef}" to "${entry.head}" and "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); + return entry.label; + } + return undefined; + } + if (entry.head && isMatch(headRef, entry.head)) { + core.debug(`Matched "${headRef}" to "${entry.head}". Setting label to "${entry.label}"`); + return entry.label; + } + if (entry.base && isMatch(baseRef, entry.base)) { + core.debug(`Matched "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); + return entry.label; + } + return undefined; + } +} +exports.LabelMatcher = LabelMatcher; +function isMatch(ref, patterns) { + return Array.isArray(patterns) + ? patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) + : matcher_1.default.isMatch(ref, patterns); +} diff --git a/lib/main.js b/lib/main.js index 9714fed85..00157d0a8 100644 --- a/lib/main.js +++ b/lib/main.js @@ -14,19 +14,16 @@ var __importStar = (this && this.__importStar) || function (mod) { result["default"] = mod; return result; }; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(require("@actions/core")); const github = __importStar(require("@actions/github")); -const matcher_1 = __importDefault(require("matcher")); const config_1 = require("./config"); +const ConfigEntry_1 = require("./ConfigEntry"); const CONFIG_FILENAME = "pr-branch-labeler.yml"; const defaults = [ - { label: "feature", head: "feature/*", base: undefined }, - { label: "bugfix", head: ["bugfix/*", "hotfix/*"], base: undefined }, - { label: "chore", head: "chore/*", base: undefined } + new ConfigEntry_1.ConfigEntry({ label: "feature", head: "feature/*" }), + new ConfigEntry_1.ConfigEntry({ label: "bugfix", head: ["bugfix/*", "hotfix/*"] }), + new ConfigEntry_1.ConfigEntry({ label: "chore", head: "chore/*" }) ]; // Export the context to be able to mock the payload during tests. exports.context = github.context; @@ -43,23 +40,9 @@ function run() { core.debug(`config: ${JSON.stringify(config)}`); const headRef = exports.context.payload.pull_request.head.ref; const baseRef = exports.context.payload.pull_request.base.ref; - const labelsToAdd = config.reduce((labels, entry) => { - if (entry.head && entry.base) { - if (isMatch(headRef, entry.head) && isMatch(baseRef, entry.base)) { - core.debug(`Matched "${headRef}" to "${entry.head}" and "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } - } - else if (entry.head && isMatch(headRef, entry.head)) { - core.debug(`Matched "${headRef}" to "${entry.head}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } - else if (entry.base && isMatch(baseRef, entry.base)) { - core.debug(`Matched "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } - return labels; - }, []); + const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) + .filter(label => label !== undefined) + .map(label => label); if (labelsToAdd.length > 0) { core.debug(`Adding labels: ${labelsToAdd}`); yield octokit.issues.addLabels(Object.assign({ issue_number: exports.context.payload.pull_request.number, labels: labelsToAdd }, exports.context.repo)); @@ -73,9 +56,4 @@ function run() { }); } exports.run = run; -function isMatch(ref, patterns) { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) - : matcher_1.default.isMatch(ref, patterns); -} run(); diff --git a/src/ConfigEntry.ts b/src/ConfigEntry.ts index 07373a20f..0b5edc599 100644 --- a/src/ConfigEntry.ts +++ b/src/ConfigEntry.ts @@ -1,5 +1,54 @@ -export interface ConfigEntry { +import * as core from "@actions/core"; +import matcher from "matcher"; + +export class ConfigEntry implements ConfigEntryParams { label: string; head?: string | string[]; base?: string | string[]; + + constructor(raw: ConfigEntryParams) { + this.label = raw.label; + + if (raw.head) { + this.head = raw.head; + } + + if (raw.base) { + this.base = raw.base; + } + } + + getLabel(headRef: string, baseRef: string): string | undefined { + if (this.head && this.base) { + if (ConfigEntry.isMatch(headRef, this.head) && ConfigEntry.isMatch(baseRef, this.base)) { + core.debug(`Matched "${headRef}" to "${this.head}" and "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + return this.label; + } + return undefined; + } + if (this.head && ConfigEntry.isMatch(headRef, this.head)) { + core.debug(`Matched "${headRef}" to "${this.head}". Setting label to "${this.label}"`); + return this.label; + } + + if (this.base && ConfigEntry.isMatch(baseRef, this.base)) { + core.debug(`Matched "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + return this.label; + } + + return undefined; + + } + + private static isMatch(ref: string, patterns: string | string[]): boolean { + return Array.isArray(patterns) + ? patterns.some(pattern => matcher.isMatch(ref, pattern)) + : matcher.isMatch(ref, patterns); + } } + +export interface ConfigEntryParams { + label: string; + head?: string | string[]; + base?: string | string[]; +}; diff --git a/src/config.ts b/src/config.ts index ea741ac51..f38c40d39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,7 +44,7 @@ function parseConfig(content: string): ConfigEntry[] { const headPattern = object.head || (typeof object === "string" || Array.isArray(object) ? object : undefined); const basePattern = object.base; if (headPattern || basePattern) { - entries.push({ label: label, head: headPattern, base: basePattern }); + entries.push(new ConfigEntry({ label: label, head: headPattern, base: basePattern })); } else { throw new Error("config.yml has invalid structure."); } diff --git a/src/main.ts b/src/main.ts index e604a5f37..fa86dbf2c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,13 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; -import matcher from "matcher"; import { getConfig } from "./config"; import { ConfigEntry } from "./ConfigEntry"; const CONFIG_FILENAME = "pr-branch-labeler.yml"; const defaults: ConfigEntry[] = [ - { label: "feature", head: "feature/*", base: undefined }, - { label: "bugfix", head: ["bugfix/*", "hotfix/*"], base: undefined }, - { label: "chore", head: "chore/*", base: undefined } + new ConfigEntry({ label: "feature", head: "feature/*" }), + new ConfigEntry({ label: "bugfix", head: ["bugfix/*", "hotfix/*"] }), + new ConfigEntry({ label: "chore", head: "chore/*" }) ]; // Export the context to be able to mock the payload during tests. @@ -28,22 +27,10 @@ export async function run() { core.debug(`config: ${JSON.stringify(config)}`); const headRef = context.payload.pull_request.head.ref; const baseRef = context.payload.pull_request.base.ref; - const labelsToAdd = config.reduce((labels: string[], entry) => { - if (entry.head && entry.base) { - if (isMatch(headRef, entry.head) && isMatch(baseRef, entry.base)) { - core.debug(`Matched "${headRef}" to "${entry.head}" and "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } - } else if (entry.head && isMatch(headRef, entry.head)) { - core.debug(`Matched "${headRef}" to "${entry.head}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } else if (entry.base && isMatch(baseRef, entry.base)) { - core.debug(`Matched "${baseRef}" to "${entry.base}". Setting label to "${entry.label}"`); - labels.push(entry.label); - } - return labels; - }, []); + const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) + .filter(label => label !== undefined) + .map(label => label!); if (labelsToAdd.length > 0) { core.debug(`Adding labels: ${labelsToAdd}`); @@ -60,10 +47,4 @@ export async function run() { } } -function isMatch(ref: string, patterns: string | string[]): boolean { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher.isMatch(ref, pattern)) - : matcher.isMatch(ref, patterns); -} - run(); From 8d304086502e9697d1bae9ef7aa81b8f702973fa Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Mon, 24 Aug 2020 22:00:05 -0400 Subject: [PATCH 2/2] Add support for regular expression matching/labels --- README.md | 19 +++ __tests__/config.test.ts | 39 +++++- __tests__/configEntry.test.ts | 228 ++++++++++++++++++++++++++++++++-- __tests__/fixtures/config.yml | 15 +++ __tests__/main.test.ts | 140 ++++++++++++++++++++- lib/ConfigEntry.js | 94 +++++++++++--- lib/config.js | 29 ++++- lib/main.js | 47 +++---- src/ConfigEntry.ts | 116 ++++++++++++++--- src/config.ts | 31 ++++- src/main.ts | 63 +++++----- tsconfig.json | 5 +- 12 files changed, 716 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 00e8a924c..fe9f5d83e 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,22 @@ If you specify both, `head` and `base`, it will be seen as an AND condition: ``` Note: If there are multiple rules matching one branch, all of the labels will be added to the PR. One example of this would be a configuration that contains the feature and subtask rules. If a new PR with `head` and `base` matching `feature/*` will be opened, the PR gets the labels `feature` AND `🧩 Subtask`. + +You can also specify regular expressions: + +```yaml +# Apply label "🧩 Subtask" if head and base match "feature/*" +🧩 Subtask: + headRegExp: 'feature[/].*' + baseRegExp: 'feature[/].*' +``` + +When using regular expressions you may also use group match numbers as labels: + +```yaml +# Apply whatever comes after "release/" as the label when matching against base of "release/*" +$1: + baseRegExp: 'release[/](.*)' +``` + +In this example if you were merging into the `release/1.0.0` branch, the label `1.0.0` would be applied. diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index 295cfd10e..ee162905f 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -38,12 +38,45 @@ describe("Config file loader", () => { // Assert expect(getConfigScope.isDone()).toBeTrue(); - expect(config.length).toEqual(6); + expect(config.length).toEqual(11); expect.assertions(2); }); - it("throws an error for an invalid config file", async () => { + it("converts regular expressions", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get("/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=0123456") + .reply(200, configFixture()); + + const octokit = new github.GitHub("token"); + + const config = await getConfig(octokit, "pr-branch-labeler.yml", context); + const headRegExpConfigs = config.filter(x => x.headRegExp); + const headRegExpConfigsWithRegExp = headRegExpConfigs.filter(x => + Array.isArray(x.headRegExp) + ? x.headRegExp.map(x => x !== null && x.exec !== undefined).reduce((a, v) => a && v, true) + : x.headRegExp!.exec !== undefined + ) + + const baseRegExpConfigs = config.filter(x => x.baseRegExp); + const baseRegExpConfigsWithRegExp = baseRegExpConfigs.filter(x => + Array.isArray(x.baseRegExp) + ? x.baseRegExp.map(x => x !== null && x.exec !== undefined).reduce((a, v) => a && v, true) + : x.baseRegExp!.exec !== undefined + ) + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(headRegExpConfigs.length).toBeGreaterThan(0); + expect(headRegExpConfigs.length).toEqual(headRegExpConfigsWithRegExp.length); + expect(baseRegExpConfigs.length).toBeGreaterThan(0); + expect(baseRegExpConfigs.length).toEqual(baseRegExpConfigs.length); + expect.assertions(5); + }); + + it("throws an error for an invalid config file", async () => { // Arrange const getConfigScope = nock("https://api.github.com") .persist() @@ -60,7 +93,6 @@ describe("Config file loader", () => { }); it("throws an error for a directory", async () => { - // Arrange const getConfigScope = nock("https://api.github.com") .persist() @@ -77,7 +109,6 @@ describe("Config file loader", () => { }); it("throws an error for no contents", async () => { - // Arrange const getConfigScope = nock("https://api.github.com") .persist() diff --git a/__tests__/configEntry.test.ts b/__tests__/configEntry.test.ts index 7bf119535..e620db651 100644 --- a/__tests__/configEntry.test.ts +++ b/__tests__/configEntry.test.ts @@ -5,9 +5,10 @@ import { ConfigEntry } from "../src/ConfigEntry"; nock.disableNetConnect(); describe("Config entry", () => { - let main; + let shared; beforeEach(() => { + shared = require('./shared'); }); afterEach(() => { @@ -54,7 +55,7 @@ describe("Config entry", () => { expect.assertions(1); }); - it("adds the 'release' and 'fix' labels for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { + it("adds the 'release' label for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { // Arrange const entry = new ConfigEntry({ label: "release", base: "release/*" }) @@ -77,18 +78,225 @@ describe("Config entry", () => { expect(label).toEqual("🧩 Subtask"); expect.assertions(1); }); + + it("adds no labels if the branch doesn't match any patterns", async () => { + // Arrange + const entry = new ConfigEntry({ label: '' }) + + // Act + const label = entry.getLabel("fix-the-build", "master"); + + // Assert + expect(label).toEqual(undefined); + expect.assertions(1); + }); + }); + + describe("Regular headRegExp and baseRegExp usage", () => { + it("adds the 'bugfix' label for 'bugfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", headRegExp: [/bugfix[/].*/, /hotfix[/].*/] }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); + + it("adds the 'bugfix' label for 'hotfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", headRegExp: [/bugfix[/].*/, /hotfix[/].*/] }) + + // Act + const label = entry.getLabel("hotfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); + + it("adds the 'release' label for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "release", baseRegExp: /release[/].*/ }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-changes", "release/1.0.0"); + + // Assert + expect(label).toEqual("release"); + expect.assertions(1); + }); + + it("adds the '🧩 Subtask' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "🧩 Subtask", headRegExp: /feature[/].*/, baseRegExp: /feature[/].*/ }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("🧩 Subtask"); + expect.assertions(1); + }); }); - it("adds no labels if the branch doesn't match any patterns", async () => { - // Arrange - const entry = new ConfigEntry({ label: '' }) + describe("Regular headRegExp and baseRegExp usage", () => { + it("adds the 'bugfix' label for 'bugfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", headRegExp: [/bugfix[/].*/, /hotfix[/].*/] }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); - // Act - const label = entry.getLabel("fix-the-build", "master"); + it("adds the 'bugfix' label for 'hotfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "bugfix", headRegExp: [/bugfix[/].*/, /hotfix[/].*/] }) - // Assert - expect(label).toEqual(undefined); - expect.assertions(1); + // Act + const label = entry.getLabel("hotfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("bugfix"); + expect.assertions(1); + }); + + it("adds the 'release' label for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "release", baseRegExp: /release[/].*/ }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-changes", "release/1.0.0"); + + // Assert + expect(label).toEqual("release"); + expect.assertions(1); + }); + + it("adds the '🧩 Subtask' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "🧩 Subtask", headRegExp: /feature[/].*/, baseRegExp: /feature[/].*/ }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("🧩 Subtask"); + expect.assertions(1); + }); + }); + + + describe("Mixed regular and RegExp usage", () => { + it("adds the '🧩 Subtask' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole' head to baseRegExp", async () => { + // Arrange + const entry = new ConfigEntry({ label: "🧩 Subtask", head: "feature/*", baseRegExp: /feature[/].*/ }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("🧩 Subtask"); + expect.assertions(1); + }); + + it("adds the '🧩 Subtask' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole' headRegExp to base", async () => { + // Arrange + const entry = new ConfigEntry({ label: "🧩 Subtask", headRegExp: /feature[/].*/, base: "feature/*" }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("🧩 Subtask"); + expect.assertions(1); + }); + }); + + + describe("Dynamic headRegExp and baseRegExp usage", () => { + it("adds the 'FOO-42-squash-bugs' label for 'bugfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "$1", headRegExp: [/bugfix[/](.*)/, /hotfix[/](.*)/] }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("FOO-42-squash-bugs"); + expect.assertions(1); + }); + + it("adds the 'FOO-42-squash-bugs' label for 'hotfix/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "$1", headRegExp: [/bugfix[/].*/, /hotfix[/](.*)/] }) + + // Act + const label = entry.getLabel("hotfix/FOO-42-squash-bugs", "master"); + + // Assert + expect(label).toEqual("FOO-42-squash-bugs"); + expect.assertions(1); + }); + + it("adds the '1.0.0' label for 'bugfix/FOO-42-changes' to 'release/1.0.0'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "$1", baseRegExp: /release[/](.*)/ }) + + // Act + const label = entry.getLabel("bugfix/FOO-42-changes", "release/1.0.0"); + + // Assert + expect(label).toEqual("1.0.0"); + expect.assertions(1); + }); + + it("adds the 'FOO-42-part' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "$1", headRegExp: /feature[/](.*)/, baseRegExp: /feature[/](.*)/ }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("FOO-42-part"); + expect.assertions(1); + }); + + it("adds the 'FOO-42-whole' label for 'feature/FOO-42-part' to 'feature/FOO-42-whole'", async () => { + // Arrange + const entry = new ConfigEntry({ label: "$2", headRegExp: /feature[/](.*)/, baseRegExp: /feature[/](.*)/ }) + + // Act + const label = entry.getLabel("feature/FOO-42-part", "feature/FOO-42-whole"); + + // Assert + expect(label).toEqual("FOO-42-whole"); + expect.assertions(1); + }); + }); + + describe("Disallows mixing of matching and regex for same branch", () => { + it("errors when using head and headRegExp", async () => { + // Assert + expect(() => { new ConfigEntry({ label: "bugfix", head: "bugfix/*", headRegExp: /bugfix[/].*/ }) }) + + .toThrow(new Error("Config can only contain one of: head, headRegExp")); + expect.assertions(1); + }); + + it("errors when using base and baseRegExp", async () => { + // Assert + expect(() => { new ConfigEntry({ label: "bugfix", base: "bugfix/*", baseRegExp: /bugfix[/].*/ }) }) + .toThrow(new Error("Config can only contain one of: base, baseRegExp")) + expect.assertions(1); + }); }); }); diff --git a/__tests__/fixtures/config.yml b/__tests__/fixtures/config.yml index 8d4306c7f..9b659b57d 100644 --- a/__tests__/fixtures/config.yml +++ b/__tests__/fixtures/config.yml @@ -13,3 +13,18 @@ release: # Apply label "release" if base matches "release/*" 🧩 Subtask: # Apply label "🧩 Subtask" if head and base match "feature/*" head: "feature/*" base: "feature/*" + +# RegExp head and base usage +choreregex: + headRegExp: 'chore-regex[/].*' # Apply label "choreregex" if head matches "chore-regex/*" +bugfixregex: # Apply label "bugfixregex" if head matches one of "bugfix-regex/*" or "hotfix-regex/*" + headRegExp: ['bugfix-regex[/].*', 'hotfix-regex[/].*'] +releaseregex: # Apply label "releaseregex" if base matches "release-regex/*" + baseRegExp: 'release-regex[/].*' +🧩 Subtaskregex: # Apply label "🧩 Subtaskregex" if head and base match "feature-regex/*" + headRegExp: 'feature-regex[/].*' + baseRegExp: 'feature-regex[/].*' + +# Dynamic tags based on branch +$1: + baseRegExp: 'release-regexp[/](.*)' diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 776859250..1c7120b44 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -214,6 +214,142 @@ describe("PR Branch Labeler", () => { }); }); + describe("Regular headRegExp and baseRegExp usage", () => { + it("adds the 'bugfixregex' label for 'bugfix-regex/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get(`/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=${main.context.payload.pull_request.head.sha}`) + .reply(200, configFixture()); + + const postLabelsScope = nock("https://api.github.com") + .persist() + .post("/repos/Codertocat/Hello-World/issues/42/labels", body => { + expect(body).toMatchObject({ + labels: ["bugfixregex"] + }); + return true; + }) + .reply(200); + + main.context.payload = createPullRequestOpenedFixture("bugfix-regex/FOO-42-squash-bugs", "master", main.context.payload.pull_request.head.sha); + + // Act + await main.run(); + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(postLabelsScope.isDone()).toBeTrue(); + expect.assertions(3); + }); + + it("adds the 'bugfixregex' label for 'hotfix-regex/FOO-42-squash-bugs' to 'master'", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get(`/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=${main.context.payload.pull_request.head.sha}`) + .reply(200, configFixture()); + + const postLabelsScope = nock("https://api.github.com") + .persist() + .post("/repos/Codertocat/Hello-World/issues/42/labels", body => { + expect(body).toMatchObject({ + labels: ["bugfixregex"] + }); + return true; + }) + .reply(200); + + main.context.payload = createPullRequestOpenedFixture("hotfix-regex/FOO-42-squash-bugs", "master", main.context.payload.pull_request.head.sha); + + // Act + await main.run(); + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(postLabelsScope.isDone()).toBeTrue(); + expect.assertions(3); + }); + + it("adds the 'releaseregex' and 'fixregex' labels for 'bugfix-regex/FOO-42-changes' to 'release-regex/1.0.0'", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get(`/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=${main.context.payload.pull_request.head.sha}`) + .reply(200, configFixture()); + + const postLabelsScope = nock("https://api.github.com") + .persist() + .post("/repos/Codertocat/Hello-World/issues/42/labels", body => { + expect(body.labels).toIncludeAllMembers(["bugfixregex", "releaseregex"]); + return true; + }) + .reply(200); + + main.context.payload = createPullRequestOpenedFixture("bugfix-regex/FOO-42-changes", "release-regex/1.0.0", main.context.payload.pull_request.head.sha); + + // Act + await main.run(); + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(postLabelsScope.isDone()).toBeTrue(); + expect.assertions(3); + }); + + it("adds the '🧩 Subtaskregex' label for 'feature-regex/FOO-42-part' to 'feature-regex/FOO-42-whole'", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get(`/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=${main.context.payload.pull_request.head.sha}`) + .reply(200, configFixture()); + + const postLabelsScope = nock("https://api.github.com") + .persist() + .post("/repos/Codertocat/Hello-World/issues/42/labels", body => { + expect(body.labels).toIncludeAllMembers(["🧩 Subtaskregex"]); + return true; + }) + .reply(200); + + main.context.payload = createPullRequestOpenedFixture("feature-regex/FOO-42-part", "feature-regex/FOO-42-whole", main.context.payload.pull_request.head.sha); + + // Act + await main.run(); + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(postLabelsScope.isDone()).toBeTrue(); + expect.assertions(3); + }); + + it("adds the '1.0.0' label for 'feature-regex/FOO-42-part' to 'release-regexp/1.0.0'", async () => { + // Arrange + const getConfigScope = nock("https://api.github.com") + .persist() + .get(`/repos/Codertocat/Hello-World/contents/.github/pr-branch-labeler.yml?ref=${main.context.payload.pull_request.head.sha}`) + .reply(200, configFixture()); + + const postLabelsScope = nock("https://api.github.com") + .persist() + .post("/repos/Codertocat/Hello-World/issues/42/labels", body => { + expect(body.labels).toIncludeAllMembers(["1.0.0"]); + return true; + }) + .reply(200); + + main.context.payload = createPullRequestOpenedFixture("feature-regex/FOO-42-part", "release-regexp/1.0.0", main.context.payload.pull_request.head.sha); + + // Act + await main.run(); + + // Assert + expect(getConfigScope.isDone()).toBeTrue(); + expect(postLabelsScope.isDone()).toBeTrue(); + expect.assertions(3); + }); + }); + it("uses the default config when no config was provided", async () => { // Arrange const getConfigScope = nock("https://api.github.com") @@ -284,7 +420,9 @@ describe("PR Branch Labeler", () => { main.context.payload = createPullRequestOpenedFixture("feature/FOO-42-awesome-stuff", "master", main.context.payload.pull_request.head.sha); // Act - await expect(main.run()).rejects.toThrow(new Error("config.yml has invalid structure.")); + await expect(main.run()) + .rejects + .toThrow(new Error("config.yml has invalid structure.")); // Assert expect(getConfigScope.isDone()).toBeTrue(); diff --git a/lib/ConfigEntry.js b/lib/ConfigEntry.js index cb2b2076b..45cd5877f 100644 --- a/lib/ConfigEntry.js +++ b/lib/ConfigEntry.js @@ -15,35 +15,97 @@ const matcher_1 = __importDefault(require("matcher")); class ConfigEntry { constructor(raw) { this.label = raw.label; - if (raw.head) { - this.head = raw.head; + this.head = raw.head; + this.headRegExp = raw.headRegExp; + if (this.head && this.headRegExp) { + throw new Error("Config can only contain one of: head, headRegExp"); } - if (raw.base) { - this.base = raw.base; + this.base = raw.base; + this.baseRegExp = raw.baseRegExp; + if (this.base && this.baseRegExp) { + throw new Error("Config can only contain one of: base, baseRegExp"); } } getLabel(headRef, baseRef) { - if (this.head && this.base) { - if (ConfigEntry.isMatch(headRef, this.head) && ConfigEntry.isMatch(baseRef, this.base)) { - core.debug(`Matched "${headRef}" to "${this.head}" and "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); - return this.label; + const headMatches = ConfigEntry.getMatches(headRef, this.head, this.headRegExp); + const baseMatches = ConfigEntry.getMatches(baseRef, this.base, this.baseRegExp); + core.debug('*** getLabel ***'); + core.debug(JSON.stringify(this)); + core.debug('headRef'); + core.debug(headRef); + core.debug('headMatches'); + core.debug(JSON.stringify(headMatches)); + core.debug('baseRef'); + core.debug(baseRef); + core.debug('baseMatches'); + core.debug(JSON.stringify(baseMatches)); + if ((this.head || this.headRegExp) && (this.base || this.baseRegExp)) { + if (headMatches && baseMatches) { + const label = this.getLabelFromMatches(headMatches.concat(baseMatches)); + core.debug(`Matched "${headRef}" to "${this.head ? this.head : this.headRegExp.toString()}" and "${baseRef}" to "${this.base ? this.base : this.baseRegExp.toString()}". Setting label to "${label}"`); + return label; } return undefined; } - if (this.head && ConfigEntry.isMatch(headRef, this.head)) { - core.debug(`Matched "${headRef}" to "${this.head}". Setting label to "${this.label}"`); + if ((this.head || this.headRegExp) && headMatches) { + const label = this.getLabelFromMatches(headMatches); + core.debug(`Matched "${headRef}" to "${this.head ? this.head : this.headRegExp.toString()}". Setting label to "${label}"`); + return label; + } + if ((this.base || this.baseRegExp) && baseMatches) { + const label = this.getLabelFromMatches(baseMatches); + core.debug(`Matched "${baseRef}" to "${this.base ? this.base : this.baseRegExp.toString()}". Setting label to "${label}"`); + return label; + } + //core.debug('label', undefined); + return undefined; + } + getLabelFromMatches(matches) { + if (!this.label.startsWith('$')) { return this.label; } - if (this.base && ConfigEntry.isMatch(baseRef, this.base)) { - core.debug(`Matched "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + const matchPosString = this.label.substr(1); + const matchPosNumber = parseInt(matchPosString); + if (isNaN(matchPosNumber) || matchPosNumber < 1) { return this.label; } + const actualMatches = matches.filter(match => match != ''); + if (matchPosNumber > actualMatches.length) { + return this.label; + } + return actualMatches[matchPosNumber - 1]; + } + static getMatches(ref, patterns, patternsRegExp) { + if (patterns) { + if (Array.isArray(patterns)) { + core.debug(`Trying to match "${ref}" to ${JSON.stringify(patterns)}`); + return patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) ? [''] : undefined; + } + core.debug(`Trying to match "${ref}" to "${patterns}"`); + return matcher_1.default.isMatch(ref, patterns) ? [''] : undefined; + } + if (patternsRegExp) { + if (Array.isArray(patternsRegExp)) { + core.debug(`Trying to match "${ref}" to ${JSON.stringify(patternsRegExp.map(x => x.toString()))}`); + const matches = patternsRegExp + .map((pattern) => this.getRegExpMatch(ref, pattern) || null) + .filter((match) => match !== null); + return matches.length === 0 ? undefined : matches.flat(); + } + core.debug(`Trying to match "${ref}" to "${patternsRegExp.toString()}"`); + return ConfigEntry.getRegExpMatch(ref, patternsRegExp); + } return undefined; } - static isMatch(ref, patterns) { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) - : matcher_1.default.isMatch(ref, patterns); + static getRegExpMatch(ref, pattern) { + const regExpResult = pattern.exec(ref); + if (regExpResult === null) { + return undefined; + } + if (regExpResult.length === 0) { + return ['']; + } + return regExpResult.slice(1); } } exports.ConfigEntry = ConfigEntry; diff --git a/lib/config.js b/lib/config.js index 4cd378fe6..2440dc22e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -25,7 +25,7 @@ const ConfigEntry_1 = require("./ConfigEntry"); const CONFIG_PATH = ".github"; function getConfig(github, fileName, context) { return __awaiter(this, void 0, void 0, function* () { - console.log('getConfig context', context); + // console.log('getConfig context', context); try { const configFile = { owner: context.repo.owner, @@ -44,7 +44,6 @@ function getConfig(github, fileName, context) { return parseConfig(response.data.content); } catch (error) { - core.error(`ERROR! ${JSON.stringify(error)}`); if (error.status === 404) { return []; } @@ -61,8 +60,23 @@ function parseConfig(content) { return Object.entries(configObject).reduce((entries, [label, object]) => { const headPattern = object.head || (typeof object === "string" || Array.isArray(object) ? object : undefined); const basePattern = object.base; - if (headPattern || basePattern) { - entries.push(new ConfigEntry_1.ConfigEntry({ label: label, head: headPattern, base: basePattern })); + let headRegExp; + let baseRegExp; + try { + headRegExp = extractRegExp(object.headRegExp); + baseRegExp = extractRegExp(object.baseRegExp); + } + catch (_a) { + throw new Error("config.yml has invalid structure."); + } + if (headPattern || basePattern || headRegExp || baseRegExp) { + entries.push(new ConfigEntry_1.ConfigEntry({ + label: label, + head: headPattern, + headRegExp: headRegExp, + base: basePattern, + baseRegExp: baseRegExp, + })); } else { throw new Error("config.yml has invalid structure."); @@ -70,3 +84,10 @@ function parseConfig(content) { return entries; }, []); } +function extractRegExp(regExpString) { + if (!regExpString) + return undefined; + return Array.isArray(regExpString) + ? regExpString.map(x => new RegExp(x.replace('/\/', '/'))) + : new RegExp(regExpString.replace('/\/', '/')); +} diff --git a/lib/main.js b/lib/main.js index 00157d0a8..1ae486422 100644 --- a/lib/main.js +++ b/lib/main.js @@ -29,31 +29,32 @@ const defaults = [ exports.context = github.context; function run() { return __awaiter(this, void 0, void 0, function* () { - try { - const repoToken = core.getInput("repo-token", { required: true }); - core.debug(`context: ${exports.context ? JSON.stringify(exports.context) : ''}`); - if (exports.context && exports.context.payload && exports.context.payload.repository && exports.context.payload.pull_request) { - const octokit = new github.GitHub(repoToken); - const repoConfig = yield config_1.getConfig(octokit, CONFIG_FILENAME, exports.context); - core.debug(`repoConfig: ${JSON.stringify(repoConfig)}`); - const config = repoConfig.length > 0 ? repoConfig : defaults; - core.debug(`config: ${JSON.stringify(config)}`); - const headRef = exports.context.payload.pull_request.head.ref; - const baseRef = exports.context.payload.pull_request.base.ref; - const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) - .filter(label => label !== undefined) - .map(label => label); - if (labelsToAdd.length > 0) { - core.debug(`Adding labels: ${labelsToAdd}`); - yield octokit.issues.addLabels(Object.assign({ issue_number: exports.context.payload.pull_request.number, labels: labelsToAdd }, exports.context.repo)); - } + const repoToken = core.getInput("repo-token", { required: true }); + core.debug(`context: ${exports.context ? JSON.stringify(exports.context) : ''}`); + if (exports.context && exports.context.payload && exports.context.payload.repository && exports.context.payload.pull_request) { + const octokit = new github.GitHub(repoToken); + const repoConfig = yield config_1.getConfig(octokit, CONFIG_FILENAME, exports.context); + core.debug(`repoConfig: ${JSON.stringify(repoConfig)}`); + const config = repoConfig.length > 0 ? repoConfig : defaults; + core.debug(`config: ${JSON.stringify(config)}`); + const headRef = exports.context.payload.pull_request.head.ref; + const baseRef = exports.context.payload.pull_request.base.ref; + const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) + .filter(label => label !== undefined) + .map(label => label); + core.debug(`Adding labels: ${labelsToAdd}`); + if (labelsToAdd.length > 0) { + yield octokit.issues.addLabels(Object.assign({ issue_number: exports.context.payload.pull_request.number, labels: labelsToAdd }, exports.context.repo)); } } - catch (error) { - core.setFailed(error.message); - throw error; - } }); } exports.run = run; -run(); +try { + run(); +} +catch (error) { + core.error(`ERROR! ${JSON.stringify(error)}`); + core.setFailed(error.message); + throw error; +} diff --git a/src/ConfigEntry.ts b/src/ConfigEntry.ts index 0b5edc599..894721a39 100644 --- a/src/ConfigEntry.ts +++ b/src/ConfigEntry.ts @@ -4,51 +4,133 @@ import matcher from "matcher"; export class ConfigEntry implements ConfigEntryParams { label: string; head?: string | string[]; + headRegExp?: RegExp | RegExp[]; base?: string | string[]; + baseRegExp?: RegExp | RegExp[]; constructor(raw: ConfigEntryParams) { this.label = raw.label; + this.head = raw.head; + this.headRegExp = raw.headRegExp; - if (raw.head) { - this.head = raw.head; + if (this.head && this.headRegExp) { + throw new Error("Config can only contain one of: head, headRegExp"); } - if (raw.base) { - this.base = raw.base; + this.base = raw.base; + this.baseRegExp = raw.baseRegExp; + + if (this.base && this.baseRegExp) { + throw new Error("Config can only contain one of: base, baseRegExp"); } } getLabel(headRef: string, baseRef: string): string | undefined { - if (this.head && this.base) { - if (ConfigEntry.isMatch(headRef, this.head) && ConfigEntry.isMatch(baseRef, this.base)) { - core.debug(`Matched "${headRef}" to "${this.head}" and "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); - return this.label; + const headMatches = ConfigEntry.getMatches(headRef, this.head, this.headRegExp); + const baseMatches = ConfigEntry.getMatches(baseRef, this.base, this.baseRegExp); + + core.debug('*** getLabel ***'); + core.debug(JSON.stringify(this)); + core.debug('headRef'); + core.debug(headRef); + core.debug('headMatches'); + core.debug(JSON.stringify(headMatches)); + core.debug('baseRef'); + core.debug(baseRef); + core.debug('baseMatches'); + core.debug(JSON.stringify(baseMatches)); + + if ((this.head || this.headRegExp) && (this.base || this.baseRegExp)) { + if (headMatches && baseMatches) { + const label = this.getLabelFromMatches(headMatches.concat(baseMatches)); + core.debug(`Matched "${headRef}" to "${this.head ? this.head : this.headRegExp!.toString()}" and "${baseRef}" to "${this.base ? this.base : this.baseRegExp!.toString()}". Setting label to "${label}"`); + return label; } return undefined; } - if (this.head && ConfigEntry.isMatch(headRef, this.head)) { - core.debug(`Matched "${headRef}" to "${this.head}". Setting label to "${this.label}"`); + + if ((this.head || this.headRegExp) && headMatches) { + const label = this.getLabelFromMatches(headMatches); + core.debug(`Matched "${headRef}" to "${this.head ? this.head : this.headRegExp!.toString()}". Setting label to "${label}"`); + return label; + } + + if ((this.base || this.baseRegExp) && baseMatches) { + const label = this.getLabelFromMatches(baseMatches); + core.debug(`Matched "${baseRef}" to "${this.base ? this.base : this.baseRegExp!.toString()}". Setting label to "${label}"`); + return label; + } + + //core.debug('label', undefined); + return undefined; + } + + getLabelFromMatches(matches: string[]): string { + if (!this.label.startsWith('$')) { return this.label; } - if (this.base && ConfigEntry.isMatch(baseRef, this.base)) { - core.debug(`Matched "${baseRef}" to "${this.base}". Setting label to "${this.label}"`); + const matchPosString = this.label.substr(1); + + const matchPosNumber = parseInt(matchPosString); + + if (isNaN(matchPosNumber) || matchPosNumber < 1) { return this.label; } - return undefined; + const actualMatches = matches.filter(match => match != ''); + + if (matchPosNumber > actualMatches.length) { + return this.label; + } + + return actualMatches[matchPosNumber - 1]; + } + + private static getMatches(ref: string, patterns?: string | string[], patternsRegExp?: RegExp | RegExp[]): string[] | undefined { + if (patterns) { + if (Array.isArray(patterns)) { + core.debug(`Trying to match "${ref}" to ${JSON.stringify(patterns)}`); + return patterns.some(pattern => matcher.isMatch(ref, pattern)) ? [''] : undefined; + } + + core.debug(`Trying to match "${ref}" to "${patterns}"`); + return matcher.isMatch(ref, patterns as string) ? [''] : undefined; + } + + if (patternsRegExp) { + if (Array.isArray(patternsRegExp)) { + core.debug(`Trying to match "${ref}" to ${JSON.stringify(patternsRegExp.map(x => x.toString()))}`); + const matches: string[][] = patternsRegExp + .map((pattern: RegExp) => this.getRegExpMatch(ref, pattern) || null) + .filter((match): match is string[] => match !== null) + return matches.length === 0 ? undefined : matches.flat(); + } + + core.debug(`Trying to match "${ref}" to "${patternsRegExp.toString()}"`); + return ConfigEntry.getRegExpMatch(ref, patternsRegExp); + } + + return undefined; } - private static isMatch(ref: string, patterns: string | string[]): boolean { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher.isMatch(ref, pattern)) - : matcher.isMatch(ref, patterns); + private static getRegExpMatch(ref: string, pattern: RegExp): string[] | undefined { + const regExpResult = pattern.exec(ref); + if (regExpResult === null) { + return undefined; + } + if (regExpResult.length === 0) { + return ['']; + } + return regExpResult.slice(1); } } export interface ConfigEntryParams { label: string; head?: string | string[]; + headRegExp?: RegExp | RegExp[]; base?: string | string[]; + baseRegExp?: RegExp | RegExp[]; }; diff --git a/src/config.ts b/src/config.ts index f38c40d39..8a83ca572 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,8 @@ import { ConfigEntry } from "./ConfigEntry"; const CONFIG_PATH = ".github"; export async function getConfig(github: github.GitHub, fileName: string, context: Context.Context): Promise { - console.log('getConfig context', context); + // console.log('getConfig context', context); + try { const configFile = { owner: context.repo.owner, @@ -25,7 +26,6 @@ export async function getConfig(github: github.GitHub, fileName: string, context } return parseConfig(response.data.content); } catch (error) { - core.error(`ERROR! ${JSON.stringify(error)}`); if (error.status === 404) { return []; } @@ -43,8 +43,24 @@ function parseConfig(content: string): ConfigEntry[] { return Object.entries(configObject).reduce((entries: ConfigEntry[], [label, object]: [string, any]) => { const headPattern = object.head || (typeof object === "string" || Array.isArray(object) ? object : undefined); const basePattern = object.base; - if (headPattern || basePattern) { - entries.push(new ConfigEntry({ label: label, head: headPattern, base: basePattern })); + let headRegExp; + let baseRegExp; + + try { + headRegExp = extractRegExp(object.headRegExp); + baseRegExp = extractRegExp(object.baseRegExp); + } catch { + throw new Error("config.yml has invalid structure."); + } + + if (headPattern || basePattern || headRegExp || baseRegExp) { + entries.push(new ConfigEntry({ + label: label, + head: headPattern, + headRegExp: headRegExp, + base: basePattern, + baseRegExp: baseRegExp, + })); } else { throw new Error("config.yml has invalid structure."); } @@ -52,3 +68,10 @@ function parseConfig(content: string): ConfigEntry[] { return entries; }, []); } + +function extractRegExp(regExpString?: string | string[]): RegExp | RegExp[] | undefined { + if (!regExpString) return undefined + return Array.isArray(regExpString) + ? regExpString.map(x => new RegExp(x.replace('/\/', '/'))) + : new RegExp(regExpString.replace('/\/', '/')) +} diff --git a/src/main.ts b/src/main.ts index fa86dbf2c..6a50cfb37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,37 +14,40 @@ const defaults: ConfigEntry[] = [ export const context = github.context; export async function run() { - try { - const repoToken: string = core.getInput("repo-token", { required: true }); - - core.debug(`context: ${context ? JSON.stringify(context) : ''}`); - - if (context && context.payload && context.payload.repository && context.payload.pull_request) { - const octokit = new github.GitHub(repoToken); - const repoConfig: ConfigEntry[] = await getConfig(octokit, CONFIG_FILENAME, context); - core.debug(`repoConfig: ${JSON.stringify(repoConfig)}`); - const config: ConfigEntry[] = repoConfig.length > 0 ? repoConfig : defaults; - core.debug(`config: ${JSON.stringify(config)}`); - const headRef = context.payload.pull_request.head.ref; - const baseRef = context.payload.pull_request.base.ref; - - const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) - .filter(label => label !== undefined) - .map(label => label!); - - if (labelsToAdd.length > 0) { - core.debug(`Adding labels: ${labelsToAdd}`); - await octokit.issues.addLabels({ - issue_number: context.payload.pull_request.number, - labels: labelsToAdd, - ...context.repo - }); - } + const repoToken: string = core.getInput("repo-token", { required: true }); + + core.debug(`context: ${context ? JSON.stringify(context) : ''}`); + + if (context && context.payload && context.payload.repository && context.payload.pull_request) { + const octokit = new github.GitHub(repoToken); + const repoConfig: ConfigEntry[] = await getConfig(octokit, CONFIG_FILENAME, context); + core.debug(`repoConfig: ${JSON.stringify(repoConfig)}`); + const config: ConfigEntry[] = repoConfig.length > 0 ? repoConfig : defaults; + core.debug(`config: ${JSON.stringify(config)}`); + const headRef = context.payload.pull_request.head.ref; + const baseRef = context.payload.pull_request.base.ref; + + const labelsToAdd = config.map(entry => entry.getLabel(headRef, baseRef)) + .filter(label => label !== undefined) + .map(label => label!); + + core.debug(`Adding labels: ${labelsToAdd}`); + + if (labelsToAdd.length > 0) { + await octokit.issues.addLabels({ + issue_number: context.payload.pull_request.number, + labels: labelsToAdd, + ...context.repo + }); } - } catch (error) { - core.setFailed(error.message); - throw error; } + } -run(); +try { + run(); +} catch (error) { + core.error(`ERROR! ${JSON.stringify(error)}`); + core.setFailed(error.message); + throw error; +} diff --git a/tsconfig.json b/tsconfig.json index 048698849..84cd5c0dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,7 +45,7 @@ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ @@ -58,6 +58,9 @@ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + "lib": [ + "ES2019" + ] }, "exclude": [ "node_modules",