├── .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 |

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 |
--------------------------------------------------------------------------------