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 new file mode 100644 index 000000000..e620db651 --- /dev/null +++ b/__tests__/configEntry.test.ts @@ -0,0 +1,302 @@ +import "jest-extended"; +import nock from "nock"; +import { ConfigEntry } from "../src/ConfigEntry"; + +nock.disableNetConnect(); + +describe("Config entry", () => { + let shared; + + beforeEach(() => { + shared = require('./shared'); + }); + + 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' label 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); + }); + }); + + 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); + }); + }); + + + 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); + }); + }); + + + 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 c8ad2e549..45cd5877f 100644 --- a/lib/ConfigEntry.js +++ b/lib/ConfigEntry.js @@ -1,2 +1,112 @@ "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; + this.head = raw.head; + this.headRegExp = raw.headRegExp; + if (this.head && this.headRegExp) { + throw new Error("Config can only contain one of: head, headRegExp"); + } + 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) { + 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 || 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; + } + 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 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 25198b5b5..2440dc22e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -21,10 +21,11 @@ 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* () { - console.log('getConfig context', context); + // console.log('getConfig context', context); try { const configFile = { owner: context.repo.owner, @@ -43,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 []; } @@ -60,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({ 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."); @@ -69,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/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..1ae486422 100644 --- a/lib/main.js +++ b/lib/main.js @@ -14,68 +14,47 @@ 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; 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.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; - }, []); - 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; -function isMatch(ref, patterns) { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher_1.default.isMatch(ref, pattern)) - : matcher_1.default.isMatch(ref, patterns); +try { + run(); +} +catch (error) { + core.error(`ERROR! ${JSON.stringify(error)}`); + core.setFailed(error.message); + throw error; } -run(); diff --git a/src/ConfigEntry.ts b/src/ConfigEntry.ts index 07373a20f..894721a39 100644 --- a/src/ConfigEntry.ts +++ b/src/ConfigEntry.ts @@ -1,5 +1,136 @@ -export interface ConfigEntry { +import * as core from "@actions/core"; +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 (this.head && this.headRegExp) { + throw new Error("Config can only contain one of: head, headRegExp"); + } + + 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 { + 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 || 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; + } + + 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]; + } + + 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 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 ea741ac51..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({ 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 e604a5f37..6a50cfb37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,69 +1,53 @@ 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. 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.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; - }, []); - - 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; } -} -function isMatch(ref: string, patterns: string | string[]): boolean { - return Array.isArray(patterns) - ? patterns.some(pattern => matcher.isMatch(ref, pattern)) - : matcher.isMatch(ref, patterns); } -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",