├── .github ├── workflows │ ├── ci.yml │ └── release.yml └── dependabot.yml ├── package.json ├── action.yml ├── Dockerfile ├── LICENSE ├── .gitignore ├── README.md ├── index.js └── index.test.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CI: true 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 22 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | - run: npm ci 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | production-dependencies: 9 | dependency-type: "production" 10 | update-types: 11 | - "minor" 12 | - "patch" 13 | development-dependencies: 14 | dependency-type: "development" 15 | update-types: 16 | - "minor" 17 | - "patch" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | ref: ${{ github.event.release.tag_name }} 15 | - name: Install deps and build 16 | run: npm ci && npm run build 17 | - uses: JasonEtco/build-and-tag-action@v2 18 | env: 19 | GITHUB_TOKEN: ${{ github.token }} 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "require-checklist-action", 3 | "private": true, 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "start": "node ./index.js", 7 | "test": "jest", 8 | "build": "npx @vercel/ncc build && npx convert-action" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "~1", 12 | "@actions/github": "~6" 13 | }, 14 | "devDependencies": { 15 | "@vercel/ncc": "~0.38", 16 | "convert-action": "~0.4", 17 | "express": "^5.1.0", 18 | "jest": "~29", 19 | "mocked-env": "~1" 20 | }, 21 | "license": "MIT" 22 | } -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Require Checklist 2 | description: Ensure that any checklists in an issue/pull request are completed 3 | runs: 4 | using: docker 5 | image: Dockerfile 6 | branding: 7 | icon: check-square 8 | color: gray-dark 9 | inputs: 10 | token: 11 | description: The GitHub API token to use 12 | default: ${{ github.token }} 13 | required: false 14 | requireChecklist: 15 | description: Require a checklist to exist 16 | required: false 17 | default: "false" 18 | skipComments: 19 | description: Do not look for checklists in comments 20 | required: false 21 | default: "false" 22 | skipDescriptionRegex: 23 | description: A regex pattern of descriptions that will be skipped if matched 24 | required: false 25 | default: undefined 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the latest version of Node.js 2 | # 3 | # You may prefer the full image: 4 | # FROM node 5 | # 6 | # or even an alpine image (a smaller, faster, less-feature-complete image): 7 | # FROM node:alpine 8 | # 9 | # You can specify a version: 10 | # FROM node:10-slim 11 | FROM node:slim 12 | 13 | # Labels for GitHub to read your action 14 | LABEL "com.github.actions.name"="Require Checklist" 15 | LABEL "com.github.actions.description"="Ensure that any checklists in an issue/pull request are completed" 16 | # Here are all of the available icons: https://feathericons.com/ 17 | LABEL "com.github.actions.icon"="check-square" 18 | # And all of the available colors: https://developer.github.com/actions/creating-github-actions/creating-a-docker-container/#label 19 | LABEL "com.github.actions.color"="gray-dark" 20 | 21 | # Copy the package.json and package-lock.json 22 | COPY package*.json ./ 23 | 24 | # Install dependencies 25 | RUN npm ci 26 | 27 | # Copy the rest of your action's code 28 | COPY . . 29 | 30 | # Run `node /index.js` 31 | ENTRYPOINT ["node", "/index.js"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2020 Michael Heap 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Require Checklist 2 | 3 | A GitHub Action that fails a pull request if there are any incomplete checklists in the issue body and/or comments. The action is triggered when a pull request is opened or its first comment (the main pull request message) is edited. 4 | 5 | ## Usage 6 | 7 | Create a file named `.github/workflows/require-checklist.yaml` (or any name in that directory), this file will contain the body of your GitHub Action. 8 | 9 | ### Use with a `pull_request` or `issue` event 10 | 11 | This action will default to using the `pull_request` or `issue` number when used inside a workflow triggered by one of those events. Below is an example of how to use it. 12 | 13 | ```yaml 14 | name: Require Checklist 15 | 16 | on: 17 | pull_request: 18 | types: [opened, edited, synchronize] 19 | issues: 20 | types: [opened, edited, deleted] 21 | 22 | jobs: 23 | job1: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: mheap/require-checklist-action@v2 27 | with: 28 | requireChecklist: false # If this is true and there are no checklists detected, the action will fail 29 | ``` 30 | 31 | ### Use with a `workflow_run` event 32 | 33 | If you would like to use this action outside of a `pull_request` or `issue` trigger. You can pass in the issue number manually. Note that "issue number" is used even in the context of pull requests. 34 | 35 | ```yaml 36 | name: Require Checklist 37 | 38 | on: 39 | workflow_run: 40 | workflows: ["Other Workflow"] 41 | types: 42 | - completed 43 | 44 | jobs: 45 | job1: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: mheap/require-checklist-action@v2 49 | with: 50 | requireChecklist: false # If this is true and there are no checklists detected, the action will fail 51 | issueNumber: ${{ github.event.workflow_run.pull_requests[0].number }} 52 | ``` 53 | 54 | ### Optional checkboxes 55 | 56 | Optional checkboxes can be applied with the `skipDescriptionRegex` and `skipDescriptionRegexFlags` arguments, which correspond to the first and second constructor arguments of [Javascript's RegExp class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp). 57 | 58 | Here is an example of skipping any description with including "(Optional)". (case-insensitive). 59 | 60 | ```yaml 61 | # ... 62 | 63 | jobs: 64 | job1: 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: mheap/require-checklist-action@v2 68 | with: 69 | skipDescriptionRegex: .*\(optional\).* 70 | skipDescriptionRegexFlags: i 71 | ``` 72 | 73 | ### Inapplicable checklist items 74 | 75 | In case there are some items that are not applicable in given checklist they can be ~stroked through~ and this action will ignore them. For example: 76 | 77 | - [X] Applicable item 78 | - [ ] ~Inapplicable item~ 79 | 80 | ### Radio groups 81 | 82 | In case some checkboxes should not be selected at the same time, mark them with `TaskRadio ` html comment. 83 | 84 | E.g.: 85 | 86 | ```markdown 87 | - [ ] Identify the cat 88 | - [ ] Pet the cat 89 | - [ ] Flee the cat 90 | ``` 91 | 92 | Will make require only "Pet the cat" or "Flee the cat" to be selected, but not both. 93 | 94 | Multiple groups can be present: 95 | 96 | ```markdown 97 | - [ ] Identify the cat 98 | - [ ] Pet the cat 99 | - [ ] Flee the cat 100 | - [ ] Report the incident 101 | - [ ] Hide in shame 102 | ``` 103 | 104 | Item can belong to multiple groups: 105 | 106 | ```markdown 107 | - [ ] Identify the cat 108 | - [ ] Pet the cat 109 | - [ ] Flee the cat 110 | - [ ] Report the incident 111 | - [ ] Hide in shame 112 | ``` 113 | 114 | Existence of a valid combination remains a responsibility of the user. 115 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const core = require("@actions/core"); 4 | const github = require("@actions/github"); 5 | 6 | const TASK_LIST_ITEM = /(?:^|\n)\s*-\s+\[(?[ xX])\]\s+(?(?!~).*)/g; 7 | const COMMENT_START = ""; 9 | const RADIO_TAG_ITEM = //g 10 | 11 | async function action() { 12 | const bodyList = []; 13 | 14 | const token = core.getInput("token"); 15 | const octokit = github.getOctokit(token); 16 | const skipRegexPattern = core.getInput("skipDescriptionRegex"); 17 | const skipRegexFlags = core.getInput("skipDescriptionRegexFlags"); 18 | const skipDescriptionRegex = !!skipRegexPattern ? new RegExp(skipRegexPattern, skipRegexFlags) : false; 19 | 20 | const issueNumber = 21 | parseInt(core.getInput("issueNumber")) || github.context.issue?.number; 22 | 23 | core.debug(`issue number: ${issueNumber}`); 24 | 25 | if (!issueNumber) { 26 | core.setFailed("Could not determine issue number"); 27 | return; 28 | } 29 | 30 | const { data: issue } = await octokit.rest.issues.get({ 31 | ...github.context.repo, 32 | issue_number: issueNumber, 33 | }); 34 | 35 | if (issue.body) { 36 | bodyList.push(issue.body); 37 | } 38 | 39 | if (core.getInput("skipComments") != "true") { 40 | const { data: comments } = await octokit.rest.issues.listComments({ 41 | ...github.context.repo, 42 | issue_number: issueNumber, 43 | }); 44 | 45 | for (let comment of comments) { 46 | bodyList.push(comment.body); 47 | } 48 | } 49 | 50 | /** 51 | * @typedef {Object} ChecklistItem 52 | * @property {string} text 53 | * @property {string[]} radioGroups 54 | * @property {boolean} isComplete 55 | */ 56 | 57 | /** @type ChecklistItem[][] */ 58 | let checklistBodies = [] 59 | 60 | // Collect check list items 61 | for (let body of bodyList) { 62 | // Check each comment for a checklist 63 | let multilineComment = false; 64 | 65 | if (typeof body === "undefined") continue 66 | 67 | /** @type ChecklistItem[] */ 68 | let checklistItems = [] 69 | 70 | // Break into lines to do comment detection 71 | for (let line of body.split("\n")) { 72 | // NOTE: Assume we never start nor end a multiline comment in the middle of a line... for now 73 | if (line.lastIndexOf(COMMENT_START) > line.lastIndexOf(COMMENT_END)) { 74 | multilineComment = true; 75 | } 76 | if (line.lastIndexOf(COMMENT_START) < line.lastIndexOf(COMMENT_END)) { 77 | multilineComment = false; 78 | } 79 | 80 | if (!multilineComment) { 81 | /** 82 | * @typedef {Object} TaskItem 83 | * @property {string} checkMark 84 | * @property {string} text 85 | */ 86 | for (let match of line.matchAll(TASK_LIST_ITEM)) { 87 | if (typeof match.groups === "undefined") continue 88 | 89 | /** @type TaskItem */ 90 | let item = (({ checkMark, text }) => ({ checkMark: checkMark || "", text: text || "" }))(match.groups) 91 | let is_complete = ["x", "X"].includes(item.checkMark); 92 | 93 | if (skipRegexPattern && skipDescriptionRegex && skipDescriptionRegex.test(item.text)) { 94 | console.log("Skipping task list item: " + item.text); 95 | continue; 96 | } 97 | 98 | if (is_complete) { 99 | console.log("Completed task list item: " + item.text); 100 | } else { 101 | console.log("Incomplete task list item: " + item.text); 102 | } 103 | 104 | checklistItems.push({ 105 | text: item.text, 106 | radioGroups: [...item.text.matchAll(RADIO_TAG_ITEM)].map((radioMatch) => radioMatch.groups && radioMatch.groups.radioTag || "").filter((tag) => tag), 107 | isComplete: is_complete 108 | }) 109 | } 110 | } 111 | } 112 | 113 | if (checklistItems.length > 0) checklistBodies.push(checklistItems) 114 | } 115 | 116 | /** 117 | * @typedef {Object.} ChecklistRadioGroup 118 | */ 119 | 120 | /** @type ChecklistItem[] */ 121 | let incompleteItems = [] 122 | /** @type ChecklistItem[][] */ 123 | let radioConflictItems = [] 124 | 125 | for (let items of checklistBodies) { 126 | /** @type ChecklistRadioGroup */ 127 | let radioGroupedItems = {} 128 | 129 | for (let item of items) { 130 | if (item.radioGroups.length == 0 && !item.isComplete) { 131 | incompleteItems.push(item) 132 | continue 133 | } 134 | 135 | for (let radioGroup of item.radioGroups) { 136 | if (typeof radioGroupedItems[radioGroup] === "undefined") radioGroupedItems[radioGroup] = [] 137 | radioGroupedItems[radioGroup].push(item) 138 | } 139 | } 140 | 141 | for (let group in radioGroupedItems) { 142 | let completedItems = radioGroupedItems[group].filter((item) => item.isComplete) 143 | 144 | if (completedItems.length == 0) incompleteItems.push(...radioGroupedItems[group]) 145 | if (completedItems.length > 1) radioConflictItems.push(completedItems) 146 | } 147 | } 148 | 149 | if (incompleteItems.length > 0) { 150 | core.setFailed( 151 | "The following items are not marked as completed: " + 152 | incompleteItems.map((item) => item.text).join(", ") 153 | ); 154 | } 155 | 156 | if (radioConflictItems.length > 0) { 157 | for (let items of radioConflictItems) { 158 | core.setFailed( 159 | "The following items cannot be marked as completed simultaneously: " + 160 | items.map((item) => item.text).join(", ") 161 | ) 162 | } 163 | return 164 | } 165 | 166 | const requireChecklist = core.getInput("requireChecklist"); 167 | if (requireChecklist != "false" && checklistBodies.length == 0) { 168 | core.setFailed( 169 | "No task list was present and requireChecklist is turned on" 170 | ); 171 | return; 172 | } 173 | 174 | console.log("There are no incomplete task list items"); 175 | } 176 | 177 | if (require.main === module) { 178 | action(); 179 | } 180 | 181 | module.exports = action; 182 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | const http = require("node:http"); 2 | const express = require("express"); 3 | const fakeApiApp = express() 4 | const fakeApiServer = http.createServer(fakeApiApp); 5 | const mockEnv = require("mocked-env"); 6 | 7 | process.env.GITHUB_API = "demo-workflow"; 8 | process.env.GITHUB_WORKFLOW = "demo-workflow"; 9 | process.env.GITHUB_ACTION = "require-checklist-action"; 10 | process.env.GITHUB_ACTOR = "YOUR_USERNAME"; 11 | process.env.GITHUB_REPOSITORY = "YOUR_USERNAME/action-test"; 12 | process.env.GITHUB_WORKSPACE = "/tmp/github/workspace"; 13 | process.env.GITHUB_SHA = "fake-sha-a1c85481edd2ea7d19052874ea3743caa8f1bdf6"; 14 | process.env.INPUT_TOKEN = "FAKE_GITHUB_TOKEN"; 15 | 16 | // Variables to store references to what we need to reset 17 | let restore; 18 | let restoreTest; 19 | 20 | describe("Require Checklist", () => { 21 | let action, core, github; 22 | let issueNumber = 42; 23 | 24 | const makeTools = (num = issueNumber) => { 25 | return mockEvent("pull_request", { 26 | action: "opened", 27 | pull_request: { number: num }, 28 | }) 29 | }; 30 | 31 | const mockIssueBody = (body, num = issueNumber) => { 32 | fakeApiApp.get( 33 | `/repos/YOUR_USERNAME/action-test/issues/${num}`, 34 | (_req, res) => res.json({ body }) 35 | ); 36 | } 37 | 38 | const mockIssueComments = (comments, num = issueNumber) => { 39 | fakeApiApp.get( 40 | `/repos/YOUR_USERNAME/action-test/issues/${num}/comments`, 41 | (_req, res) => res.json(comments.map((c) => { 42 | return { body: c }; 43 | })) 44 | ); 45 | } 46 | 47 | const mockEvent = (name, mockPayload) => { 48 | github.context.payload = mockPayload; 49 | 50 | restore = mockEnv({ 51 | GITHUB_EVENT_NAME: name, 52 | GITHUB_EVENT_PATH: "/github/workspace/event.json", 53 | }); 54 | } 55 | 56 | beforeAll(async () => { 57 | // Wait for server listening event (or fail on error) 58 | await new Promise((resolve, reject) => { 59 | fakeApiServer.on('error', (e) => reject(e)); 60 | fakeApiServer.on('listening', () => resolve()); 61 | fakeApiServer.listen(0, '127.0.0.1'); 62 | }) 63 | 64 | const fakeApiAddress = fakeApiServer.address(); 65 | process.env.GITHUB_API_URL = `http://${fakeApiAddress.address}:${fakeApiAddress.port}`; 66 | 67 | // We need to require late - actions libraries initialize defaults way too early 68 | action = require("."); 69 | core = require("@actions/core"); 70 | github = require("@actions/github"); 71 | }) 72 | 73 | afterAll(() => fakeApiServer.close()) 74 | 75 | beforeEach(() => { 76 | jest.resetModules(); 77 | 78 | tools = makeTools(); 79 | 80 | restoreTest = () => { }; 81 | }); 82 | 83 | afterEach(() => { 84 | restore(); 85 | restoreTest(); 86 | issueNumber++; 87 | }); 88 | 89 | it("handles issues with no checklist, requireChecklist disabled", async () => { 90 | restoreTest = mockEnv({ 91 | INPUT_REQUIRECHECKLIST: "false", 92 | }); 93 | 94 | mockIssueBody("No checklist in the body"); 95 | mockIssueComments(["Or in the comments"]); 96 | 97 | console.log = jest.fn(); 98 | await action(tools); 99 | expect(console.log).toBeCalledWith( 100 | "There are no incomplete task list items" 101 | ); 102 | }); 103 | 104 | it("handles issues with completed checklist", async () => { 105 | restoreTest = mockEnv({ 106 | INPUT_REQUIRECHECKLIST: "true", 107 | }); 108 | 109 | mockIssueBody("Demo\r\n\r\n- [x] One\r\n- [x] Two\n- [x] Three"); 110 | mockIssueComments(["- [x] Comment done"]); 111 | 112 | console.log = jest.fn(); 113 | 114 | await action(tools); 115 | 116 | expect(console.log).toBeCalledWith("Completed task list item: One"); 117 | expect(console.log).toBeCalledWith("Completed task list item: Two"); 118 | expect(console.log).toBeCalledWith("Completed task list item: Three"); 119 | expect(console.log).toBeCalledWith("Completed task list item: Comment done"); 120 | expect(console.log).toBeCalledWith("There are no incomplete task list items"); 121 | }); 122 | 123 | it("handles issues with no checklist, requireChecklist enabled", async () => { 124 | restoreTest = mockEnv({ 125 | INPUT_REQUIRECHECKLIST: "true", 126 | }); 127 | 128 | mockIssueBody("No checklist in the body"); 129 | mockIssueComments(["Or in the comments"]); 130 | 131 | core.setFailed = jest.fn(); 132 | await action(tools); 133 | expect(core.setFailed).toBeCalledWith( 134 | "No task list was present and requireChecklist is turned on" 135 | ); 136 | }); 137 | 138 | it("handles incomplete checklist in body", async () => { 139 | mockIssueBody("Demo\r\n\r\n- [x] One\r\n- [ ] Two\n- [ ] Three"); 140 | mockIssueComments(["No checklist in comment"]); 141 | 142 | console.log = jest.fn(); 143 | core.setFailed = jest.fn(); 144 | await action(tools); 145 | 146 | expect(console.log).toBeCalledWith("Completed task list item: One"); 147 | expect(console.log).toBeCalledWith("Incomplete task list item: Two"); 148 | expect(console.log).toBeCalledWith("Incomplete task list item: Three"); 149 | 150 | expect(core.setFailed).toBeCalledWith( 151 | "The following items are not marked as completed: Two, Three" 152 | ); 153 | }); 154 | 155 | it("handles checklist with inapplicable items in body", async () => { 156 | mockIssueBody("Demo\r\n\r\n- [x] One\r\n- [ ] ~Two~"); 157 | mockIssueComments(["No checklist in comment"]); 158 | 159 | console.log = jest.fn(); 160 | await action(tools); 161 | 162 | expect(console.log).toBeCalledWith("Completed task list item: One"); 163 | 164 | expect(console.log).toBeCalledWith( 165 | "There are no incomplete task list items" 166 | ); 167 | }); 168 | 169 | it("handles checklist with commented out items", async () => { 170 | mockIssueBody( 171 | "Demo\r\n\r\n- [x] One\r\n" 172 | ); 173 | mockIssueComments(["No checklist in comment"]); 174 | 175 | console.log = jest.fn(); 176 | await action(tools); 177 | 178 | expect(console.log).toBeCalledWith("Completed task list item: One"); 179 | 180 | expect(console.log).toBeCalledWith( 181 | "There are no incomplete task list items" 182 | ); 183 | }); 184 | 185 | it("handles checklist with commented out items on a single line", async () => { 186 | mockIssueBody("Demo\r\n\r\n- [x] One\r\n"); 187 | mockIssueComments(["No checklist in comment"]); 188 | 189 | console.log = jest.fn(); 190 | await action(tools); 191 | 192 | expect(console.log).toBeCalledWith("Completed task list item: One"); 193 | 194 | expect(console.log).toBeCalledWith( 195 | "There are no incomplete task list items" 196 | ); 197 | }); 198 | 199 | it("handles incomplete checklist in comments", async () => { 200 | mockIssueBody("Nothing in the body"); 201 | mockIssueComments(["Demo\r\n\r\n- [x] One\r\n- [ ] Two\n- [ ] Three"]); 202 | 203 | console.log = jest.fn(); 204 | core.setFailed = jest.fn(); 205 | await action(tools); 206 | 207 | expect(console.log).toBeCalledWith("Completed task list item: One"); 208 | expect(console.log).toBeCalledWith("Incomplete task list item: Two"); 209 | expect(console.log).toBeCalledWith("Incomplete task list item: Three"); 210 | 211 | expect(core.setFailed).toBeCalledWith( 212 | "The following items are not marked as completed: Two, Three" 213 | ); 214 | }); 215 | 216 | it("handles checklist with inapplicable items in comments", async () => { 217 | mockIssueBody("Nothing in the body"); 218 | mockIssueComments(["Demo\r\n\r\n- [x] One\r\n- [ ] ~Two~"]); 219 | 220 | console.log = jest.fn(); 221 | await action(tools); 222 | 223 | expect(console.log).toBeCalledWith("Completed task list item: One"); 224 | 225 | expect(console.log).toBeCalledWith( 226 | "There are no incomplete task list items" 227 | ); 228 | }); 229 | 230 | it("handles issues with empty body, requireChecklist disabled", async () => { 231 | restoreTest = mockEnv({ 232 | INPUT_REQUIRECHECKLIST: "false" 233 | }); 234 | 235 | mockIssueBody(null); 236 | mockIssueComments(["No checklist in comment"]); 237 | 238 | await action(tools); 239 | expect(console.log).toBeCalledWith( 240 | "There are no incomplete task list items" 241 | ); 242 | }); 243 | 244 | it("handles issues with empty body, requireChecklist enabled", async () => { 245 | restoreTest = mockEnv({ 246 | INPUT_REQUIRECHECKLIST: "true" 247 | }); 248 | 249 | mockIssueBody(null); 250 | mockIssueComments(["No checklist in comment"]); 251 | 252 | core.setFailed = jest.fn(); 253 | await action(tools); 254 | 255 | expect(core.setFailed).toBeCalledWith( 256 | "No task list was present and requireChecklist is turned on" 257 | ); 258 | }); 259 | 260 | it("handles using issue number input with completed checklist", async () => { 261 | restoreTest = mockEnv({ 262 | INPUT_REQUIRECHECKLIST: "true", 263 | INPUT_ISSUENUMBER: `${issueNumber}` 264 | }); 265 | 266 | const runTools = mockEvent("workflow_run", {}); 267 | 268 | mockIssueBody( 269 | "Demo\r\n\r\n- [x] One\r\n- [x] Two\n- [x] Three", 270 | process.env.INPUT_ISSUENUMBER 271 | ); 272 | mockIssueComments(["- [x] Comment done"], process.env.INPUT_ISSUENUMBER); 273 | 274 | console.log = jest.fn(); 275 | await action(runTools); 276 | 277 | expect(console.log).toBeCalledWith("Completed task list item: One"); 278 | expect(console.log).toBeCalledWith("Completed task list item: Two"); 279 | expect(console.log).toBeCalledWith("Completed task list item: Three"); 280 | expect(console.log).toBeCalledWith( 281 | "Completed task list item: Comment done" 282 | ); 283 | 284 | expect(console.log).toBeCalledWith( 285 | "There are no incomplete task list items" 286 | ); 287 | }); 288 | 289 | it("handles using issue number input with incomplete checklist in comments", async () => { 290 | restoreTest = mockEnv({ 291 | INPUT_ISSUENUMBER: `${issueNumber}` 292 | }); 293 | 294 | const runTools = mockEvent("workflow_run", {}); 295 | 296 | mockIssueBody("Nothing in the body", process.env.INPUT_ISSUENUMBER); 297 | mockIssueComments( 298 | ["Demo\r\n\r\n- [x] One\r\n- [ ] Two\n- [ ] Three"], 299 | process.env.INPUT_ISSUENUMBER 300 | ); 301 | 302 | console.log = jest.fn(); 303 | core.setFailed = jest.fn(); 304 | await action(runTools); 305 | 306 | expect(console.log).toBeCalledWith("Completed task list item: One"); 307 | expect(console.log).toBeCalledWith("Incomplete task list item: Two"); 308 | expect(console.log).toBeCalledWith("Incomplete task list item: Three"); 309 | 310 | expect(core.setFailed).toBeCalledWith( 311 | "The following items are not marked as completed: Two, Three" 312 | ); 313 | }); 314 | 315 | it("handles missing issue number", async () => { 316 | const runTools = mockEvent("workflow_run", {}); 317 | 318 | core.setFailed = jest.fn(); 319 | await action(runTools); 320 | 321 | expect(core.setFailed).toBeCalledWith("Could not determine issue number"); 322 | }); 323 | 324 | it("defaults to using the input issue number on pull_request event", async () => { 325 | restoreTest = mockEnv({ 326 | INPUT_ISSUENUMBER: `${issueNumber}` 327 | }); 328 | 329 | mockIssueBody("Nothing in the body", process.env.INPUT_ISSUENUMBER); 330 | mockIssueComments( 331 | ["Demo\r\n\r\n- [x] One\r\n- [ ] Two\n- [ ] Three"], 332 | process.env.INPUT_ISSUENUMBER 333 | ); 334 | 335 | console.log = jest.fn(); 336 | core.setFailed = jest.fn(); 337 | await action(tools); 338 | 339 | expect(console.log).toBeCalledWith("Completed task list item: One"); 340 | expect(console.log).toBeCalledWith("Incomplete task list item: Two"); 341 | expect(console.log).toBeCalledWith("Incomplete task list item: Three"); 342 | 343 | expect(core.setFailed).toBeCalledWith( 344 | "The following items are not marked as completed: Two, Three" 345 | ); 346 | }); 347 | 348 | it("ignores checklists in comments when skipComments is enabled", async () => { 349 | restoreTest = mockEnv({ 350 | INPUT_REQUIRECHECKLIST: "true", 351 | INPUT_SKIPCOMMENTS: "true" 352 | }); 353 | mockIssueBody("Nothing in the body"); 354 | 355 | core.setFailed = jest.fn(); 356 | await action(tools); 357 | 358 | expect(core.setFailed).toBeCalledWith( 359 | "No task list was present and requireChecklist is turned on" 360 | ); 361 | }); 362 | 363 | it("ignores items that match the skipDescriptionRegex + skipDescriptionRegexFlags args", async () => { 364 | restoreTest = mockEnv({ 365 | INPUT_REQUIRECHECKLIST: "true", 366 | INPUT_SKIPDESCRIPTIONREGEX: ".*\(optional\).*", 367 | INPUT_SKIPDESCRIPTIONREGEXFLAGS: "i", 368 | INPUT_SKIPCOMMENTS: "true" 369 | }); 370 | 371 | mockIssueBody("Demo\r\n\r\n- [x] One\r\n- [x] Two\n- [x] This is (Optional) skipped"); 372 | 373 | console.log = jest.fn(); 374 | 375 | await action(tools); 376 | 377 | expect(console.log).toBeCalledWith("Completed task list item: One"); 378 | expect(console.log).toBeCalledWith("Completed task list item: Two"); 379 | expect(console.log).toBeCalledWith("Skipping task list item: This is (Optional) skipped"); 380 | 381 | expect(console.log).toBeCalledWith( 382 | "There are no incomplete task list items" 383 | ); 384 | }); 385 | 386 | describe("Pseudo radio-button checklists", () => { 387 | it("handles issues with acceptably completed checklist", async () => { 388 | mockIssueBody("Demo\r\n- [x] Identify the cat\r\n- [x] Pet the cat \r\n- [ ] Flee the cat \r\n- [ ] Report the incident \r\n- [x] Hide in shame "); 389 | mockIssueComments(["- [x] Comment done \r\n - [ ] Uncomment done "]); 390 | 391 | console.log = jest.fn(); 392 | core.setFailed = jest.fn(); 393 | 394 | await action(tools); 395 | 396 | expect(core.setFailed).not.toHaveBeenCalled() 397 | 398 | expect(console.log).toBeCalledWith("Completed task list item: Identify the cat"); 399 | expect(console.log).toBeCalledWith("Completed task list item: Pet the cat "); 400 | expect(console.log).toBeCalledWith("Incomplete task list item: Flee the cat "); 401 | expect(console.log).toBeCalledWith("Incomplete task list item: Report the incident "); 402 | expect(console.log).toBeCalledWith("Completed task list item: Hide in shame "); 403 | 404 | expect(console.log).toBeCalledWith("Completed task list item: Comment done "); 405 | expect(console.log).toBeCalledWith("Incomplete task list item: Uncomment done "); 406 | 407 | expect(console.log).toBeCalledWith( 408 | "There are no incomplete task list items" 409 | ); 410 | }); 411 | 412 | it("handles issues with unacceptable multi-select", async () => { 413 | mockIssueBody("Demo\r\n- [x] Identify the cat\r\n- [x] Pet the cat \r\n- [ ] Flee the cat \r\n- [ ] Report the incident \r\n- [x] Hide in shame "); 414 | mockIssueComments(["- [x] Comment done \r\n - [ ] Uncomment done "]); 415 | 416 | console.log = jest.fn(); 417 | core.setFailed = jest.fn(); 418 | 419 | await action(tools); 420 | 421 | expect(console.log).toBeCalledWith("Completed task list item: Identify the cat"); 422 | expect(console.log).toBeCalledWith("Completed task list item: Pet the cat "); 423 | expect(console.log).toBeCalledWith("Incomplete task list item: Flee the cat "); 424 | expect(console.log).toBeCalledWith("Incomplete task list item: Report the incident "); 425 | expect(console.log).toBeCalledWith("Completed task list item: Hide in shame "); 426 | 427 | expect(console.log).toBeCalledWith("Completed task list item: Comment done "); 428 | expect(console.log).toBeCalledWith("Incomplete task list item: Uncomment done "); 429 | 430 | expect(core.setFailed).toBeCalledWith( 431 | "The following items cannot be marked as completed simultaneously: Pet the cat , Hide in shame " 432 | ); 433 | }); 434 | }) 435 | }); 436 | --------------------------------------------------------------------------------