├── .dockerignore ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── codecov.yml ├── package-lock.json ├── package.json ├── src ├── action.ts ├── helpers.ts └── index.ts ├── tests ├── __snapshots__ │ └── index.test.ts.snap ├── fixtures │ ├── .github │ │ ├── ISSUE_TEMPLATE.md │ │ ├── context-repo-template.md │ │ ├── different-template.md │ │ ├── invalid-frontmatter.md │ │ ├── kitchen-sink.md │ │ ├── quotes-in-title.md │ │ ├── split-strings.md │ │ └── variables.md │ └── event.json ├── index.test.ts └── setup.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js 20.x 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 20.x 19 | - run: npm ci 20 | - run: npm test 21 | - name: codecov 22 | run: npx codecov 23 | env: 24 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.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@v3 13 | with: 14 | ref: ${{ github.event.release.tag_name }} 15 | - run: npm ci 16 | - run: npm run build 17 | - uses: JasonEtco/build-and-tag-action@v2 18 | env: 19 | GITHUB_TOKEN: ${{ github.token }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jason Etcovitch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Create an Issue Action

2 |

A GitHub Action that creates a new issue using a template file.

3 |

GitHub Actions status Codecov

4 | 5 | ## Usage 6 | 7 | This GitHub Action creates a new issue based on an issue template file. Here's an example workflow that creates a new issue any time you push a commit: 8 | 9 | ```yaml 10 | # .github/workflows/issue-on-push.yml 11 | on: [push] 12 | name: Create an issue on push 13 | permissions: 14 | contents: read 15 | issues: write 16 | jobs: 17 | stuff: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: JasonEtco/create-an-issue@v2 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | ``` 25 | 26 | This reads from the `.github/ISSUE_TEMPLATE.md` file. This file should have front matter to help construct the new issue: 27 | 28 | ```markdown 29 | --- 30 | title: Someone just pushed 31 | assignees: JasonEtco, matchai 32 | labels: bug, enhancement 33 | --- 34 | Someone just pushed, oh no! Here's who did it: {{ payload.sender.login }}. 35 | ``` 36 | 37 | You'll notice that the above example has some `{{ mustache }}` variables. Your issue templates have access to several things about the event that triggered the action. Besides `issue` and `pullRequest`, you have access to all the template variables [on this list](https://github.com/JasonEtco/actions-toolkit#toolscontext). You can also use environment variables: 38 | 39 | ```yaml 40 | - uses: JasonEtco/create-an-issue@v2 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | ADJECTIVE: great 44 | ``` 45 | 46 | ```markdown 47 | Environment variables are pretty {{ env.ADJECTIVE }}, right? 48 | ``` 49 | 50 | Note that you can only assign people matching given [conditions](https://help.github.com/en/github/managing-your-work-on-github/assigning-issues-and-pull-requests-to-other-github-users). 51 | 52 | ### Dates 53 | 54 | Additionally, you can use the `date` filter and variable to show some information about when this issue was created: 55 | 56 | ```markdown 57 | --- 58 | title: Weekly Radar {{ date | date('dddd, MMMM Do') }} 59 | --- 60 | What's everyone up to this week? 61 | ``` 62 | 63 | This example will create a new issue with a title like **Weekly Radar Saturday, November 10th**. You can pass any valid [Moment.js formatting string](https://momentjs.com/docs/#/displaying/) to the filter. 64 | 65 | ### Custom templates 66 | 67 | Don't want to use `.github/ISSUE_TEMPLATE.md`? You can pass an input pointing the action to a different template: 68 | 69 | ```yaml 70 | steps: 71 | - uses: actions/checkout@v3 72 | - uses: JasonEtco/create-an-issue@v2 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | filename: .github/some-other-template.md 77 | ``` 78 | 79 | ### Inputs 80 | 81 | Want to use Action logic to determine who to assign the issue to, to assign a milestone or to update an existing issue with the same title? You can pass an input containing the following options: 82 | 83 | ```yaml 84 | steps: 85 | - uses: actions/checkout@v3 86 | - uses: JasonEtco/create-an-issue@v2 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | with: 90 | assignees: JasonEtco, octocat 91 | milestone: 1 92 | update_existing: true 93 | search_existing: all 94 | ``` 95 | 96 | * The `assignees` and `milestone` speak for themselves. 97 | * The `update_existing` param can be passed and set to `true` when you want to update an open issue with the **exact same title** when it exists and `false` if you don't want to create a new issue, but skip updating an existing one. 98 | * The `search_existing` param lets you specify whether to search `open`, `closed`, or `all` existing issues for duplicates (default is `open`). 99 | 100 | ### Outputs 101 | 102 | If you need the number or URL of the issue that was created for another Action, you can use the `number` or `url` outputs, respectively. For example: 103 | 104 | ```yaml 105 | steps: 106 | - uses: JasonEtco/create-an-issue@v2 107 | env: 108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 109 | id: create-issue 110 | - run: 'echo Created issue number ${{ steps.create-issue.outputs.number }}' 111 | - run: 'echo Created ${{ steps.create-issue.outputs.url }}' 112 | ``` 113 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Create an issue 2 | description: Creates a new issue using a template with front matter. 3 | runs: 4 | using: node20 5 | main: dist/index.js 6 | branding: 7 | icon: alert-circle 8 | color: gray-dark 9 | inputs: 10 | assignees: 11 | description: GitHub handle of the user(s) to assign the issue (comma-separated) 12 | required: false 13 | milestone: 14 | description: Number of the milestone to assign the issue to 15 | required: false 16 | filename: 17 | description: The name of the file to use as the issue template 18 | default: .github/ISSUE_TEMPLATE.md 19 | required: false 20 | update_existing: 21 | description: Update an open existing issue with the same title if it exists 22 | required: false 23 | search_existing: 24 | description: Existing types of issues to search for (comma-separated) 25 | required: false 26 | default: open 27 | outputs: 28 | number: 29 | description: Number of the issue that was created 30 | url: 31 | description: URL of the issue that was created 32 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # Fail the status if coverage drops by >= 3% 6 | threshold: 3 7 | patch: 8 | default: 9 | threshold: 3 10 | 11 | comment: false 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-an-issue", 3 | "private": true, 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "start": "npx ncc run ./src/index.ts", 7 | "test": "jest --coverage", 8 | "build": "npx ncc build ./src/index.ts" 9 | }, 10 | "author": "Jason Etcovitch ", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@actions/core": "^1.10.0", 14 | "actions-toolkit": "^6.0.1", 15 | "front-matter": "^4.0.2", 16 | "js-yaml": "^4.1.0", 17 | "nunjucks": "^3.2.3", 18 | "nunjucks-date-filter": "^0.1.1", 19 | "zod": "^3.20.2" 20 | }, 21 | "devDependencies": { 22 | "@tsconfig/recommended": "^1.0.3", 23 | "@types/jest": "^29.1.2", 24 | "@types/nunjucks": "^3.2.1", 25 | "@vercel/ncc": "^0.34.0", 26 | "jest": "^29.1.2", 27 | "nock": "^13.2.9", 28 | "ts-jest": "^29.0.3", 29 | "typescript": "^4.8.4" 30 | }, 31 | "jest": { 32 | "testEnvironment": "node", 33 | "setupFiles": [ 34 | "/tests/setup.ts" 35 | ], 36 | "moduleFileExtensions": [ 37 | "ts", 38 | "js", 39 | "json" 40 | ], 41 | "transform": { 42 | ".+\\.tsx?$": [ 43 | "ts-jest", 44 | { 45 | "babelConfig": false 46 | } 47 | ] 48 | }, 49 | "testMatch": [ 50 | "/tests/**/*.test.(ts|js)" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { Toolkit } from "actions-toolkit"; 3 | import fm from "front-matter"; 4 | import nunjucks from "nunjucks"; 5 | // @ts-expect-error 6 | import dateFilter from "nunjucks-date-filter"; 7 | import { ZodError } from "zod"; 8 | import { 9 | FrontMatterAttributes, 10 | frontmatterSchema, 11 | listToArray, 12 | setOutputs, 13 | } from "./helpers"; 14 | 15 | function logError( 16 | tools: Toolkit, 17 | template: string, 18 | action: "creating" | "updating" | "parsing", 19 | err: any 20 | ) { 21 | // Log the error message 22 | const errorMessage = `An error occurred while ${action} the issue. This might be caused by a malformed issue title, or a typo in the labels or assignees. Check ${template}!`; 23 | tools.log.error(errorMessage); 24 | tools.log.error(err); 25 | 26 | // The error might have more details 27 | if (err.errors) tools.log.error(err.errors); 28 | 29 | // Exit with a failing status 30 | core.setFailed(errorMessage + "\n\n" + err.message); 31 | return tools.exit.failure(); 32 | } 33 | 34 | export async function createAnIssue(tools: Toolkit) { 35 | const template = tools.inputs.filename || ".github/ISSUE_TEMPLATE.md"; 36 | const assignees = tools.inputs.assignees; 37 | 38 | let updateExisting: Boolean | null = null; 39 | if (tools.inputs.update_existing) { 40 | if (tools.inputs.update_existing === "true") { 41 | updateExisting = true; 42 | } else if (tools.inputs.update_existing === "false") { 43 | updateExisting = false; 44 | } else { 45 | tools.exit.failure( 46 | `Invalid value update_existing=${tools.inputs.update_existing}, must be one of true or false` 47 | ); 48 | } 49 | } 50 | 51 | const env = nunjucks.configure({ autoescape: false }); 52 | env.addFilter("date", dateFilter); 53 | 54 | const templateVariables = { 55 | ...tools.context, 56 | repo: tools.context.repo, 57 | env: process.env, 58 | date: Date.now(), 59 | }; 60 | 61 | // Get the file 62 | tools.log.debug("Reading from file", template); 63 | const file = (await tools.readFile(template)) as string; 64 | 65 | // Grab the front matter as JSON 66 | const { attributes: rawAttributes, body } = fm(file); 67 | 68 | let attributes: FrontMatterAttributes; 69 | try { 70 | attributes = await frontmatterSchema.parseAsync(rawAttributes); 71 | } catch (err) { 72 | if (err instanceof ZodError) { 73 | const formatted = err.format(); 74 | return logError(tools, template, "parsing", formatted); 75 | } 76 | throw err; 77 | } 78 | 79 | tools.log(`Front matter for ${template} is`, attributes); 80 | 81 | const templated = { 82 | body: env.renderString(body, templateVariables), 83 | title: env.renderString(attributes.title, templateVariables), 84 | }; 85 | tools.log.debug("Templates compiled", templated); 86 | 87 | if (updateExisting !== null) { 88 | tools.log.info(`Fetching issues with title "${templated.title}"`); 89 | 90 | let query = `is:issue repo:${ 91 | process.env.GITHUB_REPOSITORY 92 | } in:title "${templated.title.replace(/['"]/g, "\\$&")}"`; 93 | 94 | const searchExistingType = tools.inputs.search_existing || "open"; 95 | const allowedStates = ["open", "closed"]; 96 | if (allowedStates.includes(searchExistingType)) { 97 | query += ` is:${searchExistingType}`; 98 | } 99 | 100 | const existingIssues = await tools.github.search.issuesAndPullRequests({ 101 | q: query, 102 | }); 103 | const existingIssue = existingIssues.data.items.find( 104 | (issue) => issue.title === templated.title 105 | ); 106 | if (existingIssue) { 107 | if (updateExisting === false) { 108 | tools.exit.success( 109 | `Existing issue ${existingIssue.title}#${existingIssue.number}: ${existingIssue.html_url} found but not updated` 110 | ); 111 | } else { 112 | try { 113 | tools.log.info( 114 | `Updating existing issue ${existingIssue.title}#${existingIssue.number}: ${existingIssue.html_url}` 115 | ); 116 | const issue = await tools.github.issues.update({ 117 | ...tools.context.repo, 118 | issue_number: existingIssue.number, 119 | body: templated.body, 120 | }); 121 | setOutputs(tools, issue.data); 122 | tools.exit.success( 123 | `Updated issue ${existingIssue.title}#${existingIssue.number}: ${existingIssue.html_url}` 124 | ); 125 | } catch (err: any) { 126 | return logError(tools, template, "updating", err); 127 | } 128 | } 129 | } else { 130 | tools.log.info("No existing issue found to update"); 131 | } 132 | } 133 | 134 | // Create the new issue 135 | tools.log.info(`Creating new issue ${templated.title}`); 136 | try { 137 | const issue = await tools.github.issues.create({ 138 | ...tools.context.repo, 139 | ...templated, 140 | assignees: assignees 141 | ? listToArray(assignees) 142 | : listToArray(attributes.assignees), 143 | labels: listToArray(attributes.labels), 144 | milestone: 145 | Number(tools.inputs.milestone || attributes.milestone) || undefined, 146 | }); 147 | 148 | setOutputs(tools, issue.data); 149 | tools.log.success( 150 | `Created issue ${issue.data.title}#${issue.data.number}: ${issue.data.html_url}` 151 | ); 152 | } catch (err: any) { 153 | return logError(tools, template, "creating", err); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Toolkit } from "actions-toolkit"; 2 | import { z } from "zod"; 3 | 4 | export const frontmatterSchema = z 5 | .object({ 6 | title: z.string(), 7 | assignees: z.union([z.array(z.string()), z.string()]).optional(), 8 | labels: z.union([z.array(z.string()), z.string()]).optional(), 9 | milestone: z.union([z.string(), z.number()]).optional(), 10 | name: z.string().optional(), 11 | about: z.string().optional(), 12 | }) 13 | .strict(); 14 | 15 | export type FrontMatterAttributes = z.infer; 16 | 17 | export function setOutputs( 18 | tools: Toolkit, 19 | issue: { number: number; html_url: string } 20 | ) { 21 | tools.outputs.number = String(issue.number); 22 | tools.outputs.url = issue.html_url; 23 | } 24 | 25 | export function listToArray(list?: string[] | string) { 26 | if (!list) return []; 27 | return Array.isArray(list) ? list : list.split(", "); 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toolkit } from "actions-toolkit"; 2 | import { createAnIssue } from "./action"; 3 | 4 | Toolkit.run(createAnIssue, { 5 | secrets: ["GITHUB_TOKEN"], 6 | }); 7 | -------------------------------------------------------------------------------- /tests/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`create-an-issue checks the value of update_existing 1`] = ` 4 | { 5 | "assignees": [ 6 | "octocat", 7 | "JasonEtco", 8 | ], 9 | "body": "Goodbye!", 10 | "labels": [], 11 | "milestone": 1, 12 | "title": "Hello!", 13 | } 14 | `; 15 | 16 | exports[`create-an-issue creates a new issue 1`] = ` 17 | { 18 | "assignees": [], 19 | "body": "Goodbye!", 20 | "labels": [], 21 | "title": "Hello!", 22 | } 23 | `; 24 | 25 | exports[`create-an-issue creates a new issue 2`] = ` 26 | [ 27 | [ 28 | "Created issue Hello!#1: www", 29 | ], 30 | ] 31 | `; 32 | 33 | exports[`create-an-issue creates a new issue from a different template 1`] = ` 34 | { 35 | "assignees": [], 36 | "body": "Goodbye!", 37 | "labels": [], 38 | "title": "Different file", 39 | } 40 | `; 41 | 42 | exports[`create-an-issue creates a new issue from a different template 2`] = ` 43 | [ 44 | [ 45 | "Created issue Different file#1: www", 46 | ], 47 | ] 48 | `; 49 | 50 | exports[`create-an-issue creates a new issue when updating existing issues is enabled but no issues with the same title exist 1`] = ` 51 | { 52 | "assignees": [ 53 | "octocat", 54 | "JasonEtco", 55 | ], 56 | "body": "Goodbye!", 57 | "labels": [], 58 | "milestone": 1, 59 | "title": "Hello!", 60 | } 61 | `; 62 | 63 | exports[`create-an-issue creates a new issue with a milestone passed by input 1`] = ` 64 | { 65 | "assignees": [ 66 | "octocat", 67 | "JasonEtco", 68 | ], 69 | "body": "Goodbye!", 70 | "labels": [], 71 | "milestone": 1, 72 | "title": "Hello!", 73 | } 74 | `; 75 | 76 | exports[`create-an-issue creates a new issue with an assignee passed by input 1`] = ` 77 | { 78 | "assignees": [ 79 | "octocat", 80 | ], 81 | "body": "Goodbye!", 82 | "labels": [], 83 | "title": "Hello!", 84 | } 85 | `; 86 | 87 | exports[`create-an-issue creates a new issue with an assignee passed by input 2`] = ` 88 | [ 89 | [ 90 | "Created issue Hello!#1: www", 91 | ], 92 | ] 93 | `; 94 | 95 | exports[`create-an-issue creates a new issue with assignees and labels as comma-delimited strings 1`] = ` 96 | { 97 | "assignees": [ 98 | "JasonEtco", 99 | "matchai", 100 | ], 101 | "body": "The action create-an-issue is the best action.", 102 | "labels": [ 103 | "bug", 104 | "enhancement", 105 | ], 106 | "title": "DO EVERYTHING", 107 | } 108 | `; 109 | 110 | exports[`create-an-issue creates a new issue with assignees and labels as comma-delimited strings 2`] = ` 111 | [ 112 | [ 113 | "Created issue DO EVERYTHING#1: www", 114 | ], 115 | ] 116 | `; 117 | 118 | exports[`create-an-issue creates a new issue with assignees, labels and a milestone 1`] = ` 119 | { 120 | "assignees": [ 121 | "JasonEtco", 122 | ], 123 | "body": "The action create-an-issue is the best action.", 124 | "labels": [ 125 | "bugs", 126 | ], 127 | "milestone": 2, 128 | "title": "DO EVERYTHING", 129 | } 130 | `; 131 | 132 | exports[`create-an-issue creates a new issue with assignees, labels and a milestone 2`] = ` 133 | [ 134 | [ 135 | "Created issue DO EVERYTHING#1: www", 136 | ], 137 | ] 138 | `; 139 | 140 | exports[`create-an-issue creates a new issue with multiple assignees passed by input 1`] = ` 141 | { 142 | "assignees": [ 143 | "octocat", 144 | "JasonEtco", 145 | ], 146 | "body": "Goodbye!", 147 | "labels": [], 148 | "title": "Hello!", 149 | } 150 | `; 151 | 152 | exports[`create-an-issue creates a new issue with multiple assignees passed by input 2`] = ` 153 | [ 154 | [ 155 | "Created issue Hello!#1: www", 156 | ], 157 | ] 158 | `; 159 | 160 | exports[`create-an-issue creates a new issue with some template variables 1`] = ` 161 | { 162 | "assignees": [], 163 | "body": "The action create-an-issue is the best action. 164 | 165 | Environment variable foo is great. 166 | ", 167 | "labels": [], 168 | "title": "Hello create-an-issue", 169 | } 170 | `; 171 | 172 | exports[`create-an-issue creates a new issue with some template variables 2`] = ` 173 | [ 174 | [ 175 | "Created issue Hello create-an-issue#1: www", 176 | ], 177 | ] 178 | `; 179 | 180 | exports[`create-an-issue creates a new issue with the context.repo template variables 1`] = ` 181 | { 182 | "assignees": [], 183 | "body": "Are you looking for results on our gh-pages branch? 184 | 185 | Just click this link! https://JasonEtco.github.io/waddup/path/to/my/pageindex.html 186 | ", 187 | "labels": [], 188 | "title": "This Issue has a generalizable GitHub Pages Link", 189 | } 190 | `; 191 | 192 | exports[`create-an-issue creates a new issue with the context.repo template variables 2`] = ` 193 | [ 194 | [ 195 | "Created issue This Issue has a generalizable GitHub Pages Link#1: www", 196 | ], 197 | ] 198 | `; 199 | 200 | exports[`create-an-issue finds, but does not update an existing issue with the same title 1`] = ` 201 | { 202 | "assignees": [ 203 | "octocat", 204 | "JasonEtco", 205 | ], 206 | "body": "Goodbye!", 207 | "labels": [], 208 | "milestone": 1, 209 | "title": "Hello!", 210 | } 211 | `; 212 | 213 | exports[`create-an-issue logs a helpful error if creating an issue throws an error 1`] = ` 214 | [ 215 | [ 216 | "An error occurred while creating the issue. This might be caused by a malformed issue title, or a typo in the labels or assignees. Check .github/ISSUE_TEMPLATE.md!", 217 | ], 218 | [ 219 | [HttpError: Validation error], 220 | ], 221 | ] 222 | `; 223 | 224 | exports[`create-an-issue logs a helpful error if creating an issue throws an error with more errors 1`] = ` 225 | [ 226 | [ 227 | "An error occurred while creating the issue. This might be caused by a malformed issue title, or a typo in the labels or assignees. Check .github/ISSUE_TEMPLATE.md!", 228 | ], 229 | [ 230 | [HttpError: Validation error: {"foo":true}], 231 | ], 232 | ] 233 | `; 234 | 235 | exports[`create-an-issue logs a helpful error if the frontmatter is invalid 1`] = ` 236 | [ 237 | [ 238 | "An error occurred while parsing the issue. This might be caused by a malformed issue title, or a typo in the labels or assignees. Check .github/invalid-frontmatter.md!", 239 | ], 240 | [ 241 | { 242 | "_errors": [ 243 | "Unrecognized key(s) in object: 'not_a_thing'", 244 | ], 245 | "labels": { 246 | "_errors": [ 247 | "Expected array, received number", 248 | "Expected string, received number", 249 | ], 250 | }, 251 | "title": { 252 | "_errors": [ 253 | "Required", 254 | ], 255 | }, 256 | }, 257 | ], 258 | ] 259 | `; 260 | 261 | exports[`create-an-issue logs a helpful error if updating an issue throws an error with more errors 1`] = ` 262 | [ 263 | [ 264 | "An error occurred while updating the issue. This might be caused by a malformed issue title, or a typo in the labels or assignees. Check .github/ISSUE_TEMPLATE.md!", 265 | ], 266 | [ 267 | [HttpError: Validation error: {"foo":true}], 268 | ], 269 | ] 270 | `; 271 | 272 | exports[`create-an-issue updates an existing open issue with the same title 1`] = ` 273 | { 274 | "assignees": [ 275 | "octocat", 276 | "JasonEtco", 277 | ], 278 | "body": "Goodbye!", 279 | "labels": [], 280 | "milestone": 1, 281 | "title": "Hello!", 282 | } 283 | `; 284 | -------------------------------------------------------------------------------- /tests/fixtures/.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello! 3 | --- 4 | Goodbye! -------------------------------------------------------------------------------- /tests/fixtures/.github/context-repo-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: This Issue has a generalizable GitHub Pages Link 3 | --- 4 | Are you looking for results on our gh-pages branch? 5 | 6 | Just click this link! https://{{ repo.owner }}.github.io/{{ repo.repo }}/path/to/my/pageindex.html 7 | -------------------------------------------------------------------------------- /tests/fixtures/.github/different-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Different file 3 | --- 4 | Goodbye! -------------------------------------------------------------------------------- /tests/fixtures/.github/invalid-frontmatter.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Not a title" 3 | labels: 123 4 | not_a_thing: "testing" 5 | --- 6 | Hi! -------------------------------------------------------------------------------- /tests/fixtures/.github/kitchen-sink.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: DO EVERYTHING 3 | assignees: 4 | - JasonEtco 5 | labels: 6 | - bugs 7 | milestone: 2 8 | --- 9 | The action {{ action }} is the best action. -------------------------------------------------------------------------------- /tests/fixtures/.github/quotes-in-title.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: This title "has quotes" 3 | --- 4 | Goodbye! -------------------------------------------------------------------------------- /tests/fixtures/.github/split-strings.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: DO EVERYTHING 3 | assignees: JasonEtco, matchai 4 | labels: bug, enhancement 5 | --- 6 | The action {{ action }} is the best action. -------------------------------------------------------------------------------- /tests/fixtures/.github/variables.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello {{ action }} 3 | --- 4 | The action {{ action }} is the best action. 5 | 6 | Environment variable {{ env.EXAMPLE }} is great. 7 | -------------------------------------------------------------------------------- /tests/fixtures/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "owner": { 4 | "login": "JasonEtco" 5 | }, 6 | "name": "waddup" 7 | } 8 | } -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import nock from "nock"; 2 | import * as core from "@actions/core"; 3 | import { Toolkit } from "actions-toolkit"; 4 | import { Signale } from "signale"; 5 | import { createAnIssue } from "../src/action"; 6 | 7 | function generateToolkit() { 8 | const tools = new Toolkit({ 9 | logger: new Signale({ disabled: true }), 10 | }); 11 | 12 | jest.spyOn(tools.log, "info"); 13 | jest.spyOn(tools.log, "error"); 14 | jest.spyOn(tools.log, "success"); 15 | 16 | // Turn core.setOutput into a mocked noop 17 | jest.spyOn(core, "setOutput").mockImplementation(() => {}); 18 | 19 | // Turn core.setFailed into a mocked noop 20 | jest.spyOn(core, "setFailed").mockImplementation(() => {}); 21 | 22 | tools.exit.success = jest.fn() as any; 23 | tools.exit.failure = jest.fn() as any; 24 | 25 | return tools; 26 | } 27 | 28 | describe("create-an-issue", () => { 29 | let tools: Toolkit; 30 | let params: any; 31 | 32 | beforeEach(() => { 33 | nock("https://api.github.com") 34 | .post(/\/repos\/.*\/.*\/issues/) 35 | .reply(200, (_, body: any) => { 36 | params = body; 37 | return { 38 | title: body.title, 39 | number: 1, 40 | html_url: "www", 41 | }; 42 | }); 43 | 44 | tools = generateToolkit(); 45 | 46 | // Ensure that the filename input isn't set at the start of a test 47 | delete process.env.INPUT_FILENAME; 48 | 49 | // Simulate an environment variable added for the action 50 | process.env.EXAMPLE = "foo"; 51 | }); 52 | 53 | it("creates a new issue", async () => { 54 | await createAnIssue(tools); 55 | expect(params).toMatchSnapshot(); 56 | expect(tools.log.success).toHaveBeenCalled(); 57 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 58 | 59 | // Verify that the outputs were set 60 | expect(core.setOutput).toHaveBeenCalledTimes(2); 61 | expect(core.setOutput).toHaveBeenCalledWith("url", "www"); 62 | expect(core.setOutput).toHaveBeenCalledWith("number", "1"); 63 | }); 64 | 65 | it("creates a new issue from a different template", async () => { 66 | process.env.INPUT_FILENAME = ".github/different-template.md"; 67 | tools.context.payload = { 68 | repository: { owner: { login: "JasonEtco" }, name: "waddup" }, 69 | }; 70 | await createAnIssue(tools); 71 | expect(params).toMatchSnapshot(); 72 | expect(tools.log.success).toHaveBeenCalled(); 73 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 74 | }); 75 | 76 | it("creates a new issue with some template variables", async () => { 77 | process.env.INPUT_FILENAME = ".github/variables.md"; 78 | await createAnIssue(tools); 79 | expect(params).toMatchSnapshot(); 80 | expect(tools.log.success).toHaveBeenCalled(); 81 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 82 | }); 83 | 84 | it("creates a new issue with the context.repo template variables", async () => { 85 | process.env.INPUT_FILENAME = ".github/context-repo-template.md"; 86 | await createAnIssue(tools); 87 | expect(params).toMatchSnapshot(); 88 | expect(tools.log.success).toHaveBeenCalled(); 89 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 90 | }); 91 | 92 | it("creates a new issue with assignees, labels and a milestone", async () => { 93 | process.env.INPUT_FILENAME = ".github/kitchen-sink.md"; 94 | await createAnIssue(tools); 95 | expect(params).toMatchSnapshot(); 96 | expect(tools.log.success).toHaveBeenCalled(); 97 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 98 | }); 99 | 100 | it("creates a new issue with assignees and labels as comma-delimited strings", async () => { 101 | process.env.INPUT_FILENAME = ".github/split-strings.md"; 102 | await createAnIssue(tools); 103 | expect(params).toMatchSnapshot(); 104 | expect(tools.log.success).toHaveBeenCalled(); 105 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 106 | }); 107 | 108 | it("creates a new issue with an assignee passed by input", async () => { 109 | process.env.INPUT_ASSIGNEES = "octocat"; 110 | await createAnIssue(tools); 111 | expect(params).toMatchSnapshot(); 112 | expect(tools.log.success).toHaveBeenCalled(); 113 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 114 | }); 115 | 116 | it("creates a new issue with multiple assignees passed by input", async () => { 117 | process.env.INPUT_ASSIGNEES = "octocat, JasonEtco"; 118 | await createAnIssue(tools); 119 | expect(params).toMatchSnapshot(); 120 | expect(tools.log.success).toHaveBeenCalled(); 121 | expect((tools.log.success as any).mock.calls).toMatchSnapshot(); 122 | }); 123 | 124 | it("creates a new issue with a milestone passed by input", async () => { 125 | process.env.INPUT_MILESTONE = "1"; 126 | await createAnIssue(tools); 127 | expect(params).toMatchSnapshot(); 128 | expect(params.milestone).toBe(1); 129 | expect(tools.log.success).toHaveBeenCalled(); 130 | }); 131 | 132 | it("creates a new issue when updating existing issues is enabled but no issues with the same title exist", async () => { 133 | nock.cleanAll(); 134 | nock("https://api.github.com") 135 | .get(/\/search\/issues.*/) 136 | .reply(200, { 137 | items: [], 138 | }) 139 | .post(/\/repos\/.*\/.*\/issues/) 140 | .reply(200, (_, body: any) => { 141 | params = body; 142 | return { 143 | title: body.title, 144 | number: 1, 145 | html_url: "www", 146 | }; 147 | }); 148 | 149 | process.env.INPUT_UPDATE_EXISTING = "true"; 150 | 151 | await createAnIssue(tools); 152 | expect(params).toMatchSnapshot(); 153 | expect(tools.log.info).toHaveBeenCalledWith( 154 | "No existing issue found to update" 155 | ); 156 | expect(tools.log.success).toHaveBeenCalled(); 157 | }); 158 | 159 | it("updates an existing open issue with the same title", async () => { 160 | nock.cleanAll(); 161 | nock("https://api.github.com") 162 | .get(/\/search\/issues.*/) 163 | .query((parsedQuery) => { 164 | const q = parsedQuery["q"]; 165 | if (typeof q === "string") { 166 | const args = q.split(" "); 167 | return ( 168 | (args.includes("is:open") || args.includes("is:closed")) && 169 | args.includes("is:issue") 170 | ); 171 | } else { 172 | return false; 173 | } 174 | }) 175 | .reply(200, { 176 | items: [{ number: 1, title: "Hello!" }], 177 | }) 178 | .patch(/\/repos\/.*\/.*\/issues\/.*/) 179 | .reply(200, {}); 180 | 181 | process.env.INPUT_UPDATE_EXISTING = "true"; 182 | 183 | await createAnIssue(tools); 184 | expect(params).toMatchSnapshot(); 185 | expect(tools.exit.success).toHaveBeenCalled(); 186 | }); 187 | 188 | it("escapes quotes in the search query", async () => { 189 | process.env.INPUT_FILENAME = ".github/quotes-in-title.md"; 190 | 191 | nock.cleanAll(); 192 | nock("https://api.github.com") 193 | .get(/\/search\/issues.*/) 194 | .query((parsedQuery) => { 195 | const q = parsedQuery["q"] as string; 196 | return q.includes('"This title \\"has quotes\\""'); 197 | }) 198 | .reply(200, { 199 | items: [{ number: 1, title: "Hello!" }], 200 | }) 201 | .post(/\/repos\/.*\/.*\/issues/) 202 | .reply(200, {}); 203 | 204 | await createAnIssue(tools); 205 | expect(tools.log.success).toHaveBeenCalled(); 206 | }); 207 | 208 | it("checks the value of update_existing", async () => { 209 | process.env.INPUT_UPDATE_EXISTING = "invalid"; 210 | 211 | await createAnIssue(tools); 212 | expect(params).toMatchSnapshot(); 213 | expect(tools.exit.failure).toHaveBeenCalledWith( 214 | "Invalid value update_existing=invalid, must be one of true or false" 215 | ); 216 | }); 217 | 218 | it("updates an existing closed issue with the same title", async () => { 219 | nock.cleanAll(); 220 | nock("https://api.github.com") 221 | .get(/\/search\/issues.*/) 222 | .query((parsedQuery) => { 223 | const q = parsedQuery["q"]; 224 | if (typeof q === "string") { 225 | const args = q.split(" "); 226 | return !args.includes("is:all") && args.includes("is:issue"); 227 | } else { 228 | return false; 229 | } 230 | }) 231 | .reply(200, { 232 | items: [{ number: 1, title: "Hello!", html_url: "/issues/1" }], 233 | }) 234 | .patch(/\/repos\/.*\/.*\/issues\/.*/) 235 | .reply(200, {}); 236 | 237 | process.env.INPUT_UPDATE_EXISTING = "true"; 238 | process.env.INPUT_SEARCH_EXISTING = "all"; 239 | 240 | await createAnIssue(tools); 241 | expect(tools.exit.success).toHaveBeenCalledWith( 242 | "Updated issue Hello!#1: /issues/1" 243 | ); 244 | }); 245 | 246 | it("finds, but does not update an existing issue with the same title", async () => { 247 | nock.cleanAll(); 248 | nock("https://api.github.com") 249 | .get(/\/search\/issues.*/) 250 | .reply(200, { 251 | items: [{ number: 1, title: "Hello!", html_url: "/issues/1" }], 252 | }); 253 | process.env.INPUT_UPDATE_EXISTING = "false"; 254 | 255 | await createAnIssue(tools); 256 | expect(params).toMatchSnapshot(); 257 | expect(tools.exit.success).toHaveBeenCalledWith( 258 | "Existing issue Hello!#1: /issues/1 found but not updated" 259 | ); 260 | }); 261 | 262 | it("exits when updating an issue fails", async () => { 263 | nock.cleanAll(); 264 | nock("https://api.github.com") 265 | .get(/\/search\/issues.*/) 266 | .reply(200, { 267 | items: [{ number: 1, title: "Hello!", html_url: "/issues/1" }], 268 | }) 269 | .patch(/\/repos\/.*\/.*\/issues\/.*/) 270 | .reply(500, { 271 | message: "Updating issue failed", 272 | }); 273 | 274 | await createAnIssue(tools); 275 | expect(tools.exit.failure).toHaveBeenCalled(); 276 | }); 277 | 278 | it("logs a helpful error if creating an issue throws an error", async () => { 279 | nock.cleanAll(); 280 | nock("https://api.github.com") 281 | .get(/\/search\/issues.*/) 282 | .reply(200, { items: [] }) 283 | .post(/\/repos\/.*\/.*\/issues/) 284 | .reply(500, { 285 | message: "Validation error", 286 | }); 287 | 288 | await createAnIssue(tools); 289 | expect(tools.log.error).toHaveBeenCalled(); 290 | expect((tools.log.error as any).mock.calls).toMatchSnapshot(); 291 | expect(tools.exit.failure).toHaveBeenCalled(); 292 | }); 293 | 294 | it("logs a helpful error if creating an issue throws an error with more errors", async () => { 295 | nock.cleanAll(); 296 | nock("https://api.github.com") 297 | .get(/\/search\/issues.*/) 298 | .reply(200, { items: [] }) 299 | .post(/\/repos\/.*\/.*\/issues/) 300 | .reply(500, { 301 | message: "Validation error", 302 | errors: [{ foo: true }], 303 | }); 304 | 305 | await createAnIssue(tools); 306 | expect(tools.log.error).toHaveBeenCalled(); 307 | expect((tools.log.error as any).mock.calls).toMatchSnapshot(); 308 | expect(tools.exit.failure).toHaveBeenCalled(); 309 | }); 310 | 311 | it("logs a helpful error if updating an issue throws an error with more errors", async () => { 312 | nock.cleanAll(); 313 | nock("https://api.github.com") 314 | .get(/\/search\/issues.*/) 315 | .reply(200, { items: [{ number: 1, title: "Hello!" }] }) 316 | .patch(/\/repos\/.*\/.*\/issues\/.*/) 317 | .reply(500, { 318 | message: "Validation error", 319 | errors: [{ foo: true }], 320 | }); 321 | 322 | process.env.INPUT_UPDATE_EXISTING = "true"; 323 | 324 | await createAnIssue(tools); 325 | expect(tools.log.error).toHaveBeenCalled(); 326 | expect((tools.log.error as any).mock.calls).toMatchSnapshot(); 327 | expect(tools.exit.failure).toHaveBeenCalled(); 328 | }); 329 | 330 | it("logs a helpful error if the frontmatter is invalid", async () => { 331 | process.env.INPUT_FILENAME = ".github/invalid-frontmatter.md"; 332 | 333 | await createAnIssue(tools); 334 | expect(tools.log.error).toHaveBeenCalled(); 335 | expect((tools.log.error as any).mock.calls).toMatchSnapshot(); 336 | expect(tools.exit.failure).toHaveBeenCalled(); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | Object.assign(process.env, { 4 | GITHUB_REPOSITORY: "JasonEtco/waddup", 5 | GITHUB_ACTION: "create-an-issue", 6 | GITHUB_EVENT_PATH: path.join(__dirname, "fixtures", "event.json"), 7 | GITHUB_WORKSPACE: path.join(__dirname, "fixtures"), 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------