From 3aed3ee9a95c54b20a61a4830981ba8ef2057df0 Mon Sep 17 00:00:00 2001 From: Michael Heap Date: Sun, 11 Feb 2024 10:28:11 +0000 Subject: [PATCH] Add Regex support --- README.md | 14 ++++++++- action.yml | 5 ++- index.js | 38 +++++++++++++++++----- index.test.js | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 130ccb7..55fadd7 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ This action has three required inputs; `labels`, `mode` and `count` | Name | Description | Required | Default | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------- | -| `labels` | Comma or new line separated list of labels to match | true | +| `labels` | New line separated list of labels to match | true | | `mode` | The mode of comparison to use. One of: exactly, minimum, maximum | true | | `count` | The required number of labels to match | true | | `token` | The GitHub token to use when calling the API | false | ${{ github.token }} | | `message` | The message to log and to add to the PR (if add_comment is true). See the README for available placeholders | false | | `add_comment` | Add a comment to the PR if required labels are missing. If a comment already exists, it will be updated. When the action passes, the comment will be deleted | false | false | | `exit_type` | The exit type of the action. One of: failure, success | false | +| `use_regex` | Evaluate the values in `labels` as regular expressions | false | This action calls the GitHub API to fetch labels for a PR rather than reading `event.json`. This allows the action to run as intended when an earlier step adds a label. It will use `github.token` by default, and you can set the `token` input to provide alternative authentication. @@ -104,6 +105,17 @@ The following tokens are available for use in custom messages: labels: "community-reviewed, team-reviewed, codeowner-reviewed" ``` +### Use regular expressions + +```yaml +- uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + labels: "semver:.*" + use_regex: true +``` + ### Preventing comment collisions This action uses a combination of the workflow name/path, job ID, and step ID to add an invisible "match token" to the beginning of any comments it creates. That way, it can later know which comments it owns when modifying them, while supporting multiple "instances" of this action to be run at the same time within a repo. diff --git a/action.yml b/action.yml index f0e10d2..b44291c 100644 --- a/action.yml +++ b/action.yml @@ -15,7 +15,7 @@ inputs: default: ${{ github.token }} required: false labels: - description: "Comma separated list of labels to match" + description: "Comma or new line separated list of labels to match" required: true mode: description: "The mode of comparison to use. One of: exactly, minimum, maximum" @@ -34,3 +34,6 @@ inputs: exit_type: description: "The exit type of the action. One of: failure, success" required: false + use_regex: + description: Evaluate the values in `labels` as regular expressions + default: "false" diff --git a/index.js b/index.js index 351558e..0e24eea 100644 --- a/index.js +++ b/index.js @@ -20,16 +20,29 @@ async function action() { // Process inputs for use later const mode = core.getInput("mode", { required: true }); const count = parseInt(core.getInput("count", { required: true }), 10); - const providedLabels = core - .getInput("labels", { required: true }) - .split("\n") - .join(",") - .split(",") - .map((l) => l.trim()) - .filter((r) => r); const exitType = core.getInput("exit_type") || "failure"; const shouldAddComment = core.getInput("add_comment") == "true"; + const labelsAreRegex = core.getInput("use_regex") == "true"; + + let providedLabels = core.getInput("labels", { required: true }); + + providedLabels = core.getInput("labels", { required: true }); + if (labelsAreRegex) { + // If labels are regex they must be provided as new line delimited + providedLabels = providedLabels.split("\n"); + } else { + // Comma separated are allowed for exact string matches + // This may be removed in the next major version + providedLabels = providedLabels + .split("\n") + .join(",") + .split(",") + .map((l) => l.trim()); + } + + // Remove any empty labels + providedLabels = providedLabels.filter((r) => r); const allowedModes = ["exactly", "minimum", "maximum"]; if (!allowedModes.includes(mode)) { @@ -70,7 +83,16 @@ async function action() { const appliedLabels = labels.map((label) => label.name); // How many labels overlap? - let intersection = providedLabels.filter((x) => appliedLabels.includes(x)); + let intersection = []; + if (labelsAreRegex) { + intersection = appliedLabels.filter((appliedLabel) => + providedLabels.some((providedLabel) => + new RegExp(providedLabel).test(appliedLabel) + ) + ); + } else { + intersection = providedLabels.filter((x) => appliedLabels.includes(x)); + } // Is there an error? let errorMode; diff --git a/index.test.js b/index.test.js index ebc7913..0a05d5e 100644 --- a/index.test.js +++ b/index.test.js @@ -172,6 +172,55 @@ describe("Required Labels", () => { expect(core.setOutput).toBeCalledWith("status", "success"); expect(core.setOutput).toBeCalledWith("labels", "enhancement,bug"); }); + + it("exactly (regex)", async () => { + restoreTest = mockPr({ + INPUT_LABELS: "enhance.*", + INPUT_MODE: "exactly", + INPUT_COUNT: "1", + INPUT_USE_REGEX: "true", + }); + mockLabels(["enhancement"]); + + await action(); + + expect(core.setOutput).toBeCalledTimes(2); + expect(core.setOutput).toBeCalledWith("status", "success"); + expect(core.setOutput).toBeCalledWith("labels", "enhancement"); + }); + + it("at least X (regex)", async () => { + restoreTest = mockPr({ + INPUT_LABELS: "enhance.*\nbug\ntriage", + INPUT_MODE: "minimum", + INPUT_COUNT: "2", + INPUT_USE_REGEX: "true", + }); + mockLabels(["enhancement", "bug"]); + + await action(); + + expect(core.setOutput).toBeCalledTimes(2); + expect(core.setOutput).toBeCalledWith("status", "success"); + expect(core.setOutput).toBeCalledWith("labels", "enhancement,bug"); + }); + + it("at most X (regex)", async () => { + restoreTest = mockPr({ + INPUT_LABELS: "enhance.*\nbug\ntriage", + INPUT_MODE: "maximum", + INPUT_COUNT: "2", + INPUT_USE_REGEX: "true", + }); + + mockLabels(["enhancement", "bug"]); + + await action(); + + expect(core.setOutput).toBeCalledTimes(2); + expect(core.setOutput).toBeCalledWith("status", "success"); + expect(core.setOutput).toBeCalledWith("labels", "enhancement,bug"); + }); }); describe("failure", () => { @@ -193,6 +242,44 @@ describe("Required Labels", () => { ); }); + it("exact count (regex)", async () => { + restoreTest = mockPr({ + INPUT_LABELS: "enhance.*\nbug", + INPUT_MODE: "exactly", + INPUT_COUNT: "1", + INPUT_USE_REGEX: "true", + }); + mockLabels(["enhancement", "bug"]); + + await action(); + + expect(core.setOutput).toBeCalledTimes(1); + expect(core.setOutput).toBeCalledWith("status", "failure"); + expect(core.setFailed).toBeCalledTimes(1); + expect(core.setFailed).toBeCalledWith( + "Label error. Requires exactly 1 of: enhance.*, bug. Found: enhancement, bug" + ); + }); + + it("fails when regex are provided as comma delimited", async () => { + restoreTest = mockPr({ + INPUT_LABELS: "enhance.*, bug", + INPUT_MODE: "exactly", + INPUT_COUNT: "1", + INPUT_USE_REGEX: "true", + }); + mockLabels(["enhancement", "bug"]); + + await action(); + + expect(core.setOutput).toBeCalledTimes(1); + expect(core.setOutput).toBeCalledWith("status", "failure"); + expect(core.setFailed).toBeCalledTimes(1); + expect(core.setFailed).toBeCalledWith( + "Label error. Requires exactly 1 of: enhance.*, bug. Found: enhancement, bug" + ); + }); + it("at least X", async () => { restoreTest = mockPr({ INPUT_LABELS: "enhancement,bug,triage",