├── .all-contributorsrc ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── pr-branch-checks.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── __tests__ └── utils.test.ts ├── action.yml ├── jest.config.js ├── lib └── index.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── scripts ├── pre-commit.sh └── pre-push.sh ├── src ├── constants.ts ├── main.ts ├── types.ts └── utils.ts ├── tsconfig.eslint.json └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "rajanand02", 10 | "name": "Raj Anand", 11 | "avatar_url": "https://avatars3.githubusercontent.com/u/4851763?v=4", 12 | "profile": "http://hacktivist.in", 13 | "contributions": [ 14 | "code", 15 | "review", 16 | "ideas" 17 | ] 18 | }, 19 | { 20 | "login": "rheaditi", 21 | "name": "Aditi Mohanty", 22 | "avatar_url": "https://avatars3.githubusercontent.com/u/6426069?v=4", 23 | "profile": "https://aditimohanty.com/?utm_source=github&utm_medium=documentation-allcontributors&utm_content=jira-lint", 24 | "contributions": [ 25 | "code", 26 | "doc", 27 | "infra" 28 | ] 29 | }, 30 | { 31 | "login": "dustman9000", 32 | "name": "Dustin Row", 33 | "avatar_url": "https://avatars0.githubusercontent.com/u/3944352?v=4", 34 | "profile": "https://github.com/dustman9000", 35 | "contributions": [ 36 | "review" 37 | ] 38 | }, 39 | { 40 | "login": "richardlhao", 41 | "name": "richardlhao", 42 | "avatar_url": "https://avatars1.githubusercontent.com/u/60636550?v=4", 43 | "profile": "https://github.com/richardlhao", 44 | "contributions": [ 45 | "code" 46 | ] 47 | }, 48 | { 49 | "login": "nimeshjm", 50 | "name": "Nimesh Manmohanlal", 51 | "avatar_url": "https://avatars3.githubusercontent.com/u/2178497?v=4", 52 | "profile": "https://www.nimeshjm.com/", 53 | "contributions": [ 54 | "doc" 55 | ] 56 | }, 57 | { 58 | "login": "lwaddicor", 59 | "name": "Lewis Waddicor", 60 | "avatar_url": "https://avatars2.githubusercontent.com/u/10589338?v=4", 61 | "profile": "https://github.com/lwaddicor", 62 | "contributions": [ 63 | "code" 64 | ] 65 | } 66 | ], 67 | "contributorsPerLine": 7, 68 | "projectName": "jira-lint", 69 | "projectOwner": "ClearTax", 70 | "repoType": "github", 71 | "repoHost": "https://github.com", 72 | "skipCi": true, 73 | "commitConvention": "none" 74 | } 75 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | !.*.js 5 | !*.config.js 6 | 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['jest', '@typescript-eslint'], 3 | extends: ['plugin:github/es6'], 4 | parser: '@typescript-eslint/parser', 5 | parserOptions: { 6 | ecmaVersion: 9, 7 | sourceType: 'module', 8 | project: './tsconfig.eslint.json', 9 | }, 10 | rules: { 11 | 'eslint-comments/no-use': 'off', 12 | 'import/no-namespace': 'off', 13 | 'no-unused-vars': 'off', 14 | '@typescript-eslint/no-unused-vars': 'error', 15 | '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }], 16 | '@typescript-eslint/no-require-imports': 'error', 17 | '@typescript-eslint/array-type': 'error', 18 | '@typescript-eslint/await-thenable': 'error', 19 | '@typescript-eslint/ban-ts-ignore': 'error', 20 | 'camelcase': 'off', 21 | '@typescript-eslint/camelcase': 'error', 22 | '@typescript-eslint/class-name-casing': 'error', 23 | '@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: true }], 24 | '@typescript-eslint/func-call-spacing': ['error', 'never'], 25 | '@typescript-eslint/generic-type-naming': ['error', '^[A-Z][A-Za-z]*$'], 26 | '@typescript-eslint/no-array-constructor': 'error', 27 | '@typescript-eslint/no-empty-interface': 'error', 28 | '@typescript-eslint/no-explicit-any': 'error', 29 | '@typescript-eslint/no-extraneous-class': 'error', 30 | '@typescript-eslint/no-for-in-array': 'error', 31 | '@typescript-eslint/no-inferrable-types': 'error', 32 | '@typescript-eslint/no-misused-new': 'error', 33 | '@typescript-eslint/no-non-null-assertion': 'warn', 34 | '@typescript-eslint/no-unnecessary-qualifier': 'error', 35 | '@typescript-eslint/no-unnecessary-type-assertion': 'error', 36 | '@typescript-eslint/no-useless-constructor': 'error', 37 | '@typescript-eslint/no-var-requires': 'error', 38 | '@typescript-eslint/prefer-for-of': 'warn', 39 | '@typescript-eslint/prefer-function-type': 'warn', 40 | '@typescript-eslint/prefer-includes': 'error', 41 | '@typescript-eslint/prefer-string-starts-ends-with': 'error', 42 | '@typescript-eslint/promise-function-async': 'error', 43 | '@typescript-eslint/require-array-sort-compare': 'error', 44 | '@typescript-eslint/restrict-plus-operands': 'error', 45 | 'semi': 'off', 46 | '@typescript-eslint/semi': ['error', 'always'], 47 | '@typescript-eslint/type-annotation-spacing': 'error', 48 | '@typescript-eslint/unbound-method': 'error', 49 | '@typescript-eslint/no-namespace': 'off', 50 | 'no-console': 'off', 51 | 'jsdoc/require-param-type': 'off', 52 | 'jsdoc/require-param': 'off', 53 | 'jsdoc/require-returns': 'off', 54 | 'jsdoc/check-examples': 'off', 55 | }, 56 | env: { 57 | 'node': true, 58 | 'es6': true, 59 | 'jest/globals': true, 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /.github/workflows/pr-branch-checks.yml: -------------------------------------------------------------------------------- 1 | name: "lint, build & test" 2 | on: # rebuild any PRs and main branch changes (mirror few typescript-action conventions) 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/*' 8 | jobs: 9 | build-test: 10 | runs-on: ubuntu-16.04 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | name: setup node 15 | with: 16 | node-version: '13.x' 17 | 18 | - name: "install" 19 | run: npm ci 20 | 21 | - name: "lint" 22 | run: npm run lint 23 | 24 | - name: "build" 25 | run: npm run build 26 | 27 | - name: "check for uncommitted changes" 28 | # Ensure no diff when built on ci 29 | # ignore node_modules since dev/fresh ci deps installed. 30 | run: | 31 | git diff --exit-code --stat -- . ':!node_modules' \ 32 | || (echo "##[error] found changed files after build. please 'npm run build'" \ 33 | "and check in all changes" \ 34 | && exit 1) 35 | 36 | - name: "archive lib when diff" 37 | uses: actions/upload-artifact@v1 38 | if: failure() 39 | with: 40 | name: ci-built-lib 41 | path: lib/index.js 42 | 43 | - name: "test" 44 | run: npm run test 45 | 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | 3 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | node_modules 12 | 13 | # local file for testing 14 | src/localTest.ts 15 | 16 | # vs code custom settings 17 | .vscode 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | jspm_packages/ 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # next.js build output 82 | .next 83 | 84 | # nuxt.js build output 85 | .nuxt 86 | 87 | # vuepress build output 88 | .vuepress/dist 89 | 90 | # Serverless directories 91 | .serverless/ 92 | 93 | # FuseBox cache 94 | .fusebox/ 95 | 96 | # DynamoDB Local files 97 | .dynamodb/ 98 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 13 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | singleQuote: true, 5 | tabWidth: 2, 6 | trailingComma: 'es5', 7 | quoteProps: 'consistent', 8 | jsxSingleQuote: true, 9 | bracketSpacing: true, 10 | endOfLine: 'lf', 11 | }; 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jira-lint 🧹 2 | 3 | > A light-weight lint workflow when using GitHub along with [JIRA][jira] for project management. 4 | > Ported from [pivotal-lint](https://github.com/ClearTax/pivotal-lint/) for similar usage with Atlassian's Jira Software. 5 | 6 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/cleartax/jira-lint?style=flat-square) 7 | [![GitHub](https://img.shields.io/github/license/cleartax/jira-lint?style=flat-square)](https://github.com/cleartax/jira-lint/blob/master/LICENSE.md) 8 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors) 9 | ![build & test](https://github.com/ClearTax/jira-lint/workflows/lint,%20build%20&%20test/badge.svg) 10 | 11 | --- 12 | 13 | 14 | 15 | - [Installation](#installation) 16 | - [Semantic Versions](#semantic-versions) 17 | - [Features](#features) 18 | - [PR Status Checks](#pr-status-checks) 19 | - [PR Description & Labels](#pr-description--labels) 20 | - [Description](#description) 21 | - [Labels](#labels) 22 | - [Soft-validations via comments](#soft-validations-via-comments) 23 | - [Options](#options) 24 | - [`jira-token`](#jira-token) 25 | - [Skipping branches](#skipping-branches) 26 | - [Contributing](#contributing) 27 | - [FAQ](#faq) 28 | - [Contributors](#contributors) 29 | 30 | 31 | 32 | ## Installation 33 | 34 | To make `jira-lint` a part of your workflow, just add a `jira-lint.yml` file in your `.github/workflows/` directory in your GitHub repository. 35 | 36 | ```yml 37 | name: jira-lint 38 | on: [pull_request] 39 | 40 | jobs: 41 | jira-lint: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: cleartax/jira-lint@master 45 | name: jira-lint 46 | with: 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | jira-token: ${{ secrets.JIRA_TOKEN }} 49 | jira-base-url: https://your-domain.atlassian.net 50 | skip-branches: '^(production-release|master|release\/v\d+)$' 51 | skip-comments: true 52 | pr-threshold: 1000 53 | ``` 54 | 55 | It can also be used as part of an existing workflow by adding it as a step. More information about the [options here](#options). 56 | 57 | ### Semantic Versions 58 | 59 | If you want more stability in versions of `jira-lint` than `@master` you can also use the [semantic releases for jira-lint](https://github.com/cleartax/jira-lint/releases). 60 | 61 | Example: 62 | 63 | ```yaml 64 | # ... 65 | steps: 66 | - uses: cleartax/jira-lint@v0.0.1 67 | name: jira-lint 68 | # ... 69 | ``` 70 | 71 | ## Features 72 | 73 | ### PR Status Checks 74 | 75 | `jira-lint` adds a status check which helps you avoid merging PRs which are missing a valid Jira Issue Key in the branch name. It will use the [Jira API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) to validate a given key. 76 | 77 | ### PR Description & Labels 78 | 79 | #### Description 80 | 81 | When a PR passes the above check, `jira-lint` will also add the issue details to the top of the PR description. It will pick details such as the Issue summary, type, estimation points, status and labels and add them to the PR description. 82 | 83 | #### Labels 84 | 85 | `jira-lint` will automatically label PRs with: 86 | 87 | - A label based on the Jira Project name (the project the issue belongs to). For example, if your project name is `Escher` then it will add `escher` as a label. 88 | - `HOTFIX-PROD` - if the PR is raised against `production-release`. 89 | - `HOTFIX-PRE-PROD` - if the PR is raised against `release/v*`. 90 | - Jira issue type ([based on your project](https://confluence.atlassian.com/adminjiracloud/issue-types-844500742.html)). 91 | 92 |
93 | Issue details and labels added to a PR 94 |
95 | Issue details and labels added to a PR. 96 |
97 |
98 | 99 | #### Issue Status Validation 100 | Issue status is shown in the [Description](#description). 101 | **Why validate issue status?** 102 | In some cases, one may be pushing changes for a story that is set to `Done`/`Completed` or it may not have been pulled into working backlog or current sprint. 103 | 104 | This option allows discouraging pushing to branches for stories that are set to statuses other than the ones allowed in the project; for example - you may want to only allow PRs for stories that are in `To Do`/`Planning`/`In Progress` states. 105 | 106 | The following flags can be used to validate issue status: 107 | - `validate_issue_status` 108 | - If set to `true`, `jira-lint` will validate the issue status based on `allowed_issue_statuses` 109 | - `allowed_issue_statuses` 110 | - This will only be used when `validate_issue_status` is `true`. This should be a comma separated list of statuses. If the detected issue's status is not in one of the `allowed_issue_statuses` then `jira-lint` will fail the status check. 111 | 112 | **Example of invalid status** 113 |

:broken_heart: The detected issue is not in one of the allowed statuses :broken_heart:

114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
Detected Status${issueStatus}:x:
Allowed Statuses${allowedStatuses}:heavy_check_mark:
126 |

Please ensure your jira story is in one of the allowed statuses

127 | 128 | #### Soft-validations via comments 129 | 130 | `jira-lint` will add comments to a PR to encourage better PR practices: 131 | 132 | **A good PR title** 133 | 134 |
135 | 136 |
When the title of the PR matches the summary/title of the issue well.
137 |
138 | 139 | --- 140 | 141 |
142 | 143 |
When the title of the PR is slightly different compared to the summary/title of the issue
144 |
145 | 146 | --- 147 | 148 |
149 | 150 |
When the title of the PR is very different compared to the summary/title of the issue
151 |
152 | 153 | --- 154 | 155 | **A comment discouraging PRs which are too large (based on number of lines of code changed).** 156 | 157 |
158 | 159 |
Batman says no large PRs 🦇
160 |
161 | 162 | ### Options 163 | 164 | | key | description | required | default | 165 | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | 166 | | `github-token` | Token used to update PR description. `GITHUB_TOKEN` is already available [when you use GitHub actions](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token#about-the-github_token-secret), so all that is required is to pass it as a param here. | true | null | 167 | | `jira-token` | Token used to fetch Jira Issue information. Check [below](#jira-token) for more details on how to generate the token. | true | null | 168 | | `jira-base-url` | The subdomain of JIRA cloud that you use to access it. Ex: "https://your-domain.atlassian.net". | true | null | 169 | | `skip-branches` | A regex to ignore running `jira-lint` on certain branches, like production etc. | false | ' ' | 170 | | `skip-comments` | A `Boolean` if set to `true` then `jira-lint` will skip adding lint comments for PR title. | false | false | 171 | | `pr-threshold` | An `Integer` based on which `jira-lint` will add a comment discouraging huge PRs. | false | 800 | 172 | | `validate_issue_status` | A `Boolean` based on which `jira-lint` will validate the status of the detected jira issue | false | false | 173 | | `allowed_issue_statuses` | A comma separated list of allowed statuses. The detected jira issue's status will be compared against this list and if a match is not found then the status check will fail. *Note*: Requires `validate_issue_status` to be set to `true`. | false | `"In Progress"` | 174 | 175 | 176 | ### `jira-token` 177 | 178 | Since tokens are private, we suggest adding them as [GitHub secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets). 179 | 180 | The Jira token is used to fetch issue information via the Jira REST API. To get the token:- 181 | 1. Generate an [API token via JIRA](https://confluence.atlassian.com/cloud/api-tokens-938839638.html) 182 | 2. Create the encoded token in the format of `base64Encode(:)`. 183 | For example, if the username is `ci@example.com` and the token is `954c38744be9407ab6fb`, then `ci@example.com:954c38744be9407ab6fb` needs to be base64 encoded to form `Y2lAZXhhbXBsZS5jb206OTU0YzM4NzQ0YmU5NDA3YWI2ZmI=` 184 | 3. The above value (in this example `Y2lAZXhhbXBsZS5jb206OTU0YzM4NzQ0YmU5NDA3YWI2ZmI=`) needs to be added as the `JIRA_TOKEN` secret in your GitHub project. 185 | 186 | Note: The user should have the [required permissions (mentioned under GET Issue)](https://developer.atlassian.com/cloud/jira/platform/rest/v3/?utm_source=%2Fcloud%2Fjira%2Fplatform%2Frest%2F&utm_medium=302#api-rest-api-3-issue-issueIdOrKey-get). 187 | 188 | ### Skipping branches 189 | 190 | Since GitHub actions take string inputs, `skip-branches` must be a regex which will work for all sets of branches you want to ignore. This is useful for merging protected/default branches into other branches. Check out some [examples in the tests](https://github.com/ClearTax/jira-lint/blob/08a47ab7a6e2bc235c9e34da1d14eacf9d810bd1/__tests__/utils.test.ts#L33-L44). 191 | 192 | `jira-lint` already skips PRs which are filed by bots (for eg. [dependabot](https://github.com/marketplace/dependabot-preview)). You can add more bots to [this list](https://github.com/ClearTax/jira-lint/blob/08a47ab7a6e2bc235c9e34da1d14eacf9d810bd1/src/constants.ts#L4), or add the branch-format followed by the bot PRs to the `skip-branches` option. 193 | 194 | ## Contributing 195 | 196 | Follow the instructions [here](https://help.github.com/en/articles/creating-a-javascript-action#commit-and-push-your-action-to-github) to know more about GitHub actions. 197 | 198 | ## FAQ 199 | 200 |
201 | Why is a Jira key required in the branch names? 202 | 203 | The key is required in order to: 204 | 205 | - Automate change-logs and release notes ⚙️. 206 | - Automate alerts to QA/Product teams and other external stake-holders 🔊. 207 | - Help us retrospect the sprint progress 📈. 208 | 209 |
210 | 211 |
212 | Is there a way to get around this? 213 | Nope 🙅 214 | 215 |
216 | 217 | [jira]: https://www.atlassian.com/software/jira 218 | 219 | ## Contributors 220 | 221 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |

Raj Anand

💻 👀 🤔

Aditi Mohanty

💻 📖 🚇

Dustin Row

👀

richardlhao

💻

Nimesh Manmohanlal

📖

Lewis Waddicor

💻
236 | 237 | 238 | 239 | 240 | 241 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 242 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getHotfixLabel, 3 | getHugePrComment, 4 | getJIRAIssueKeys, 5 | getLabelsForDisplay, 6 | getNoIdComment, 7 | getPRDescription, 8 | isHumongousPR, 9 | LABELS, 10 | shouldSkipBranchLint, 11 | shouldUpdatePRDescription, 12 | getJIRAClient, 13 | getInvalidIssueStatusComment, 14 | isIssueStatusValid, 15 | } from '../src/utils'; 16 | import { HIDDEN_MARKER } from '../src/constants'; 17 | import { JIRADetails } from '../src/types'; 18 | 19 | jest.spyOn(console, 'log').mockImplementation(); // avoid actual console.log in test output 20 | 21 | describe('shouldSkipBranchLint()', () => { 22 | it('should recognize bot PRs', () => { 23 | expect(shouldSkipBranchLint('dependabot')).toBe(true); 24 | expect(shouldSkipBranchLint('dependabot/npm_and_yarn/types/react-dom-16.9.6')).toBe(true); 25 | expect(shouldSkipBranchLint('feature/add-dependabot-config')).toBe(false); 26 | expect(shouldSkipBranchLint('feature/add-dependabot-config-OSS-101')).toBe(false); 27 | 28 | expect(shouldSkipBranchLint('all-contributors')).toBe(true); 29 | expect(shouldSkipBranchLint('all-contributors/add-ghost')).toBe(true); 30 | expect(shouldSkipBranchLint('chore/add-all-contributors')).toBe(false); 31 | expect(shouldSkipBranchLint('chore/add-all-contributors-OSS-102')).toBe(false); 32 | }); 33 | 34 | it('should handle custom ignore patterns', () => { 35 | expect(shouldSkipBranchLint('bar', '^bar')).toBeTruthy(); 36 | expect(shouldSkipBranchLint('foobar', '^bar')).toBeFalsy(); 37 | 38 | expect(shouldSkipBranchLint('bar', '[0-9]{2}')).toBeFalsy(); 39 | expect(shouldSkipBranchLint('bar', '')).toBeFalsy(); 40 | expect(shouldSkipBranchLint('foo', '[0-9]{2}')).toBeFalsy(); 41 | expect(shouldSkipBranchLint('f00', '[0-9]{2}')).toBeTruthy(); 42 | 43 | const customBranchRegex = '^(production-release|master|release/v\\d+)$'; 44 | expect(shouldSkipBranchLint('production-release', customBranchRegex)).toBeTruthy(); 45 | expect(shouldSkipBranchLint('master', customBranchRegex)).toBeTruthy(); 46 | expect(shouldSkipBranchLint('release/v77', customBranchRegex)).toBeTruthy(); 47 | 48 | expect(shouldSkipBranchLint('release/very-important-feature', customBranchRegex)).toBeFalsy(); 49 | expect(shouldSkipBranchLint('masterful', customBranchRegex)).toBeFalsy(); 50 | expect(shouldSkipBranchLint('productionish', customBranchRegex)).toBeFalsy(); 51 | expect(shouldSkipBranchLint('fix/production-issue', customBranchRegex)).toBeFalsy(); 52 | expect(shouldSkipBranchLint('chore/rebase-with-master', customBranchRegex)).toBeFalsy(); 53 | expect(shouldSkipBranchLint('chore/rebase-with-release', customBranchRegex)).toBeFalsy(); 54 | expect(shouldSkipBranchLint('chore/rebase-with-release/v77', customBranchRegex)).toBeFalsy(); 55 | }); 56 | 57 | it('should return false with empty input', () => { 58 | expect(shouldSkipBranchLint('')).toBeFalsy(); 59 | }); 60 | 61 | it('should return false for other branches', () => { 62 | expect(shouldSkipBranchLint('feature/awesomeNewFeature')).toBeFalsy(); 63 | }); 64 | }); 65 | 66 | describe('getHotFixLabel()', () => { 67 | it('should return empty string for master branch', () => { 68 | expect(getHotfixLabel('master')).toEqual(''); 69 | }); 70 | 71 | it('should return HOTFIX-PROD for production branch', () => { 72 | expect(getHotfixLabel('production-release')).toEqual(LABELS.HOTFIX_PROD); 73 | }); 74 | 75 | it('should return HOTFIX-PRE-PROD for release branch', () => { 76 | expect(getHotfixLabel('release/v')).toEqual(LABELS.HOTFIX_PRE_PROD); 77 | }); 78 | 79 | it('should return empty string with no input', () => { 80 | expect(getHotfixLabel('')).toEqual(''); 81 | }); 82 | }); 83 | 84 | describe('getJIRAIssueKeys()', () => { 85 | it('gets multiple keys from a string', () => { 86 | expect( 87 | getJIRAIssueKeys( 88 | 'BF-18 abc-123 X-88 ABCDEFGHIJKL-999 abc XY-Z-333 abcDEF-33 ABCDEF-33 abcdef-33 ABC-1 PB2-1 pb2-1 P2P-1 p2p-1' 89 | ) 90 | ).toEqual([ 91 | 'BF-18', 92 | 'ABC-123', 93 | 'X-88', 94 | 'CDEFGHIJKL-999', 95 | 'Z-333', 96 | 'ABCDEF-33', 97 | 'ABCDEF-33', 98 | 'ABCDEF-33', 99 | 'ABC-1', 100 | 'PB2-1', 101 | 'PB2-1', 102 | 'P2P-1', 103 | 'P2P-1', 104 | ]); 105 | }); 106 | 107 | it('gets jira key from different branch names', () => { 108 | expect(getJIRAIssueKeys('fix/login-protocol-es-43')).toEqual(['ES-43']); 109 | expect(getJIRAIssueKeys('fix/login-protocol-ES-43')).toEqual(['ES-43']); 110 | expect(getJIRAIssueKeys('feature/newFeature_esch-100')).toEqual(['ESCH-100']); 111 | expect(getJIRAIssueKeys('feature/newFeature_ESCH-101')).toEqual(['ESCH-101']); 112 | expect(getJIRAIssueKeys('feature/newFeature--mojo-5611')).toEqual(['MOJO-5611']); 113 | expect(getJIRAIssueKeys('feature/newFeature--MOJO-6789')).toEqual(['MOJO-6789']); 114 | 115 | expect(getJIRAIssueKeys('chore/task-with-dashes--MOJO-6789')).toEqual(['MOJO-6789']); 116 | expect(getJIRAIssueKeys('chore/task_with_underscores--MOJO-6789')).toEqual(['MOJO-6789']); 117 | expect(getJIRAIssueKeys('chore/MOJO-6789-task_with_underscores')).toEqual(['MOJO-6789']); 118 | expect(getJIRAIssueKeys('MOJO-6789/task_with_underscores')).toEqual(['MOJO-6789']); 119 | 120 | expect(getJIRAIssueKeys('MOJO-6789/task_with_underscores-ES-43')).toEqual(['MOJO-6789', 'ES-43']); 121 | expect(getJIRAIssueKeys('nudge-live-chat-users-Es-172')).toEqual(['ES-172']); 122 | 123 | expect(getJIRAIssueKeys('feature/missingKey')).toEqual([]); 124 | expect(getJIRAIssueKeys('')).toEqual([]); 125 | }); 126 | }); 127 | 128 | describe('shouldUpdatePRDescription()', () => { 129 | it('should return false when the hidden marker is present', () => { 130 | expect(shouldUpdatePRDescription(HIDDEN_MARKER)).toBeFalsy(); 131 | expect( 132 | shouldUpdatePRDescription(` 133 |
134 | ESCH-10 135 |
136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 |
Typefeature
Points2
Labelsfe tech goodness, gst 2.0
150 |
151 | 155 | 156 | some actual content' 157 | `) 158 | ).toBeFalsy(); 159 | }); 160 | 161 | it('should return true when the hidden marker is NOT present', () => { 162 | expect(shouldUpdatePRDescription('')).toBeTruthy(); 163 | expect(shouldUpdatePRDescription('added_by')).toBeTruthy(); 164 | expect(shouldUpdatePRDescription('added_by_something_else')).toBeTruthy(); 165 | expect( 166 | shouldUpdatePRDescription(` 167 | ## Checklist 168 | 169 | - [ ] PR is up-to-date with a description of changes and screenshots (if applicable). 170 | - [ ] All files are lint-free. 171 | - [ ] Added tests for the core-changes (as applicable). 172 | - [ ] Tested locally for regressions & all test cases are passing. 173 | `) 174 | ).toBeTruthy(); 175 | }); 176 | }); 177 | 178 | describe('getPRDescription()', () => { 179 | it('should include the hidden marker when getting PR description', () => { 180 | const issue: JIRADetails = { 181 | key: 'ABC-123', 182 | url: 'url', 183 | type: { name: 'feature', icon: 'feature-icon-url' }, 184 | estimate: 1, 185 | labels: [{ name: 'frontend', url: 'frontend-url' }], 186 | summary: 'Story title or summary', 187 | project: { name: 'project', url: 'project-url', key: 'abc' }, 188 | status: 'In Progress', 189 | }; 190 | const description = getPRDescription('some_body', issue); 191 | 192 | expect(shouldUpdatePRDescription(description)).toBeFalsy(); 193 | expect(description).toContain(issue.key); 194 | expect(description).toContain(issue.estimate); 195 | expect(description).toContain(issue.status); 196 | expect(description).toContain(issue.labels[0].name); 197 | }); 198 | }); 199 | 200 | describe('isHumongousPR()', () => { 201 | it('should return true if additions are greater than the threshold', () => { 202 | expect(isHumongousPR(2000, 500)).toBeTruthy(); 203 | }); 204 | 205 | it('should return false if additions are less than the threshold', () => { 206 | expect(isHumongousPR(200, 500)).toBeFalsy(); 207 | }); 208 | 209 | it('should return false with erroneous inputs', () => { 210 | expect(isHumongousPR(NaN, NaN)).toBeFalsy(); 211 | }); 212 | }); 213 | 214 | describe('getNoIdComment()', () => { 215 | it('should return the comment content with the branch name', () => { 216 | expect(getNoIdComment('test_new_feature')).toContain('test_new_feature'); 217 | }); 218 | }); 219 | 220 | describe('getHugePrComment()', () => { 221 | it('should return the comment content with additions and threshold', () => { 222 | expect(getHugePrComment(1000, 800)).toContain(1000); 223 | expect(getHugePrComment(1000, 800)).toContain(800); 224 | }); 225 | }); 226 | 227 | describe('getLabelsForDisplay()', () => { 228 | it('generates label markup without spaces', () => { 229 | expect( 230 | getLabelsForDisplay([ 231 | { name: 'one', url: 'url-one' }, 232 | { name: 'two', url: 'url-two' }, 233 | ]) 234 | ).toBe(`one, two`); 235 | }); 236 | }); 237 | 238 | describe('JIRA Client', () => { 239 | // use this to test if the token is correct 240 | it.skip('should be able to access the issue', async () => { 241 | const client = getJIRAClient('https://cleartaxtech.atlassian.net/', ''); 242 | const details = await client.getTicketDetails('ES-10'); 243 | console.log({ details }); 244 | expect(details).not.toBeNull(); 245 | }); 246 | }); 247 | 248 | describe('isIssueStatusValid()', () => { 249 | const issue: JIRADetails = { 250 | key: 'ABC-123', 251 | url: 'url', 252 | type: { name: 'feature', icon: 'feature-icon-url' }, 253 | estimate: 1, 254 | labels: [{ name: 'frontend', url: 'frontend-url' }], 255 | summary: 'Story title or summary', 256 | project: { name: 'project', url: 'project-url', key: 'abc' }, 257 | status: 'Assessment', 258 | }; 259 | 260 | it('should return false if issue validation was enabled but invalid issue status', () => { 261 | const expectedStatuses = ['In Test', 'In Progress']; 262 | expect(isIssueStatusValid(true, expectedStatuses, issue)).toBeFalsy(); 263 | }); 264 | 265 | it('should return true if issue validation was enabled but issue has a valid status', () => { 266 | const expectedStatuses = ['In Test', 'In Progress']; 267 | issue.status = 'In Progress'; 268 | expect(isIssueStatusValid(true, expectedStatuses, issue)).toBeTruthy(); 269 | }); 270 | 271 | it('should return true if issue status validation is not enabled', () => { 272 | const expectedStatuses = ['In Test', 'In Progress']; 273 | expect(isIssueStatusValid(false, expectedStatuses, issue)).toBeTruthy(); 274 | }); 275 | }); 276 | 277 | describe('getInvalidIssueStatusComment()', () => { 278 | it('should return content with the passed in issue status and allowed statses', () => { 279 | expect(getInvalidIssueStatusComment('Assessment', 'In Progress')).toContain('Assessment'); 280 | expect(getInvalidIssueStatusComment('Assessment', 'In Progress')).toContain('In Progress'); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'jira-lint' 2 | description: 'Add JIRA issue details to your GitHub pull request.' 3 | author: 'cleartax' 4 | inputs: 5 | github-token: 6 | description: 'Token used to update PR description and add labels. Can be passed in using {{ secrets.GITHUB_TOKEN }}' 7 | required: true 8 | jira-token: 9 | description: 'API Token used to access the JIRA REST API. Must have read access to your JIRA Projects & Issues.' 10 | required: true 11 | jira-base-url: 12 | description: 'The subdomain of JIRA cloud that you use to access it. Ex: "https://your-domain.atlassian.net"' 13 | required: true 14 | skip-branches: 15 | description: 'A regex to ignore running on certain branches, like production etc.' 16 | required: false 17 | default: '' 18 | skip-comments: 19 | description: 'A boolean if set to true, will skip adding lint comments for PR title.' 20 | required: false 21 | default: false 22 | pr-threshold: 23 | description: 'An `Integer` based on which jira-lint add a comment discouraging huge PRs. Is disabled by `skip-comments`' 24 | required: false 25 | default: 800 26 | validate_issue_status: 27 | description: 'Set this to true if you want jira-lint to validate the status of the detected jira issues' 28 | required: false 29 | default: false 30 | allowed_issue_statuses: 31 | description: | 32 | A comma separated list of acceptable Jira issue statuses. You must provide a value for this if validate_issue_status is set to true 33 | Requires validate_issue_status to be set to true. 34 | required: false 35 | default: "In Progress" 36 | 37 | runs: 38 | using: 'node12' 39 | main: 'lib/index.js' 40 | branding: 41 | icon: 'check-square' 42 | color: 'blue' 43 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest', 9 | }, 10 | verbose: true, 11 | collectCoverage: true, 12 | }; 13 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,ts}': ['prettier --write', 'eslint --fix'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-lint", 3 | "version": "0.0.1", 4 | "description": "Add JIRA issue details to your GitHub pull request", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "prebuild": "engines-ok && rm -rf lib", 8 | "build": "ncc build src/main.ts -o lib -m", 9 | "test": "jest", 10 | "test:watch": "jest --watch", 11 | "lint": "prettier --check src/**/*.ts && eslint src/**/*.ts", 12 | "docs:toc": "markdown-toc -i --bullets='-' README.md" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/cleartax/jira-lint" 17 | }, 18 | "engines": { 19 | "node": ">= 13", 20 | "npm": ">= 6" 21 | }, 22 | "keywords": [ 23 | "actions", 24 | "node", 25 | "setup", 26 | "github", 27 | "pr-description", 28 | "labels", 29 | "jira", 30 | "jira-issue" 31 | ], 32 | "author": "cleartax", 33 | "license": "MIT", 34 | "husky": { 35 | "hooks": { 36 | "pre-push": "./scripts/pre-push.sh", 37 | "pre-commit": "lint-staged && ./scripts/pre-commit.sh" 38 | } 39 | }, 40 | "dependencies": { 41 | "@actions/core": "^1.2.6", 42 | "@actions/github": "^1.1.0", 43 | "string-similarity": "^4.0.1" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^25.2.1", 47 | "@types/lodash": "^4.14.150", 48 | "@types/node": "^13.13.0", 49 | "@types/string-similarity": "^3.0.0", 50 | "@typescript-eslint/parser": "^2.28.0", 51 | "@zeit/ncc": "^0.22.1", 52 | "axios": "^0.19.2", 53 | "engines-ok": "^1.2.0", 54 | "eslint-plugin-github": "^3.4.1", 55 | "eslint-plugin-jest": "^23.8.2", 56 | "eslint": "^6.8.0", 57 | "husky": "^4.2.5", 58 | "jest-circus": "^25.3.0", 59 | "jest": "^25.3.0", 60 | "lint-staged": "^10.1.5", 61 | "lodash": "^4.17.19", 62 | "markdown-toc": "^1.2.0", 63 | "prettier": "^2.0.4", 64 | "ts-jest": "^25.4.0", 65 | "typescript": "^3.8.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #/usr/bin/env bash 2 | 3 | # install 4 | npm ci 5 | 6 | # run build 7 | npm run build 8 | 9 | # commit the lib file(s) 10 | git add lib 11 | -------------------------------------------------------------------------------- /scripts/pre-push.sh: -------------------------------------------------------------------------------- 1 | #/usr/bin/env bash 2 | 3 | # lint 4 | npm run lint 5 | 6 | # run build 7 | echo "Running npm build.." 8 | npm run build 9 | 10 | git diff --exit-code --stat -- lib ':!node_modules' \ 11 | || (echo "##[error] found changed files after build. please 'npm run build'" \ 12 | "and check in all changes" \ 13 | && exit 1) 14 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** Hidden marker to add to PR description. */ 2 | export const HIDDEN_MARKER = 'added_by_jira_lint'; 3 | 4 | /** Regex to check for the hidden marker in PR description to avoid adding jira-lint PR details 5 | * multiple times. */ 6 | export const MARKER_REGEX = new RegExp(HIDDEN_MARKER); 7 | 8 | /** 9 | * Bot branch patters to avoid running jira-lint on. 10 | */ 11 | export const BOT_BRANCH_PATTERNS: RegExp[] = [/^dependabot/, /^all-contributors/]; 12 | 13 | /** 14 | * Default branch patterms to skip CI. Skip jira-lint when the HEAD ref matches one of these. 15 | */ 16 | export const DEFAULT_BRANCH_PATTERNS: RegExp[] = [/^master$/, /^production$/, /^gh-pages$/]; 17 | 18 | /** 19 | * Regex to match JIRA issue keys. 20 | */ 21 | export const JIRA_REGEX_MATCHER = /\d+-(([A-Z0-9]{1,10})|[a-z0-9]{1,10})/g; 22 | 23 | /** 24 | * Default total maximum number of additions after which jira-lint will discourage the PR as it is 25 | * considered "too huge to review". 26 | */ 27 | export const DEFAULT_PR_ADDITIONS_THRESHOLD = 800; 28 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as github from '@actions/github'; 3 | import { PullsUpdateParams, IssuesCreateCommentParams } from '@octokit/rest'; 4 | 5 | import { 6 | addComment, 7 | addLabels, 8 | getHotfixLabel, 9 | getHugePrComment, 10 | getJIRAClient, 11 | getJIRAIssueKeys, 12 | getNoIdComment, 13 | getPRDescription, 14 | getPRTitleComment, 15 | isHumongousPR, 16 | isNotBlank, 17 | shouldSkipBranchLint, 18 | shouldUpdatePRDescription, 19 | updatePrDetails, 20 | isIssueStatusValid, 21 | getInvalidIssueStatusComment, 22 | } from './utils'; 23 | import { PullRequestParams, JIRADetails, JIRALintActionInputs } from './types'; 24 | import { DEFAULT_PR_ADDITIONS_THRESHOLD } from './constants'; 25 | 26 | const getInputs = (): JIRALintActionInputs => { 27 | const JIRA_TOKEN: string = core.getInput('jira-token', { required: true }); 28 | const JIRA_BASE_URL: string = core.getInput('jira-base-url', { required: true }); 29 | const GITHUB_TOKEN: string = core.getInput('github-token', { required: true }); 30 | const BRANCH_IGNORE_PATTERN: string = core.getInput('skip-branches', { required: false }) || ''; 31 | const SKIP_COMMENTS: boolean = core.getInput('skip-comments', { required: false }) === 'true'; 32 | const PR_THRESHOLD = parseInt(core.getInput('pr-threshold', { required: false }), 10); 33 | const VALIDATE_ISSUE_STATUS: boolean = core.getInput('validate_issue_status', { required: false }) === 'true'; 34 | const ALLOWED_ISSUE_STATUSES: string = core.getInput('allowed_issue_statuses'); 35 | 36 | return { 37 | JIRA_TOKEN, 38 | GITHUB_TOKEN, 39 | BRANCH_IGNORE_PATTERN, 40 | SKIP_COMMENTS, 41 | PR_THRESHOLD: isNaN(PR_THRESHOLD) ? DEFAULT_PR_ADDITIONS_THRESHOLD : PR_THRESHOLD, 42 | JIRA_BASE_URL: JIRA_BASE_URL.endsWith('/') ? JIRA_BASE_URL.replace(/\/$/, '') : JIRA_BASE_URL, 43 | VALIDATE_ISSUE_STATUS, 44 | ALLOWED_ISSUE_STATUSES, 45 | }; 46 | }; 47 | 48 | async function run(): Promise { 49 | try { 50 | const { 51 | JIRA_TOKEN, 52 | JIRA_BASE_URL, 53 | GITHUB_TOKEN, 54 | BRANCH_IGNORE_PATTERN, 55 | SKIP_COMMENTS, 56 | PR_THRESHOLD, 57 | VALIDATE_ISSUE_STATUS, 58 | ALLOWED_ISSUE_STATUSES, 59 | } = getInputs(); 60 | 61 | const defaultAdditionsCount = 800; 62 | const prThreshold: number = PR_THRESHOLD ? Number(PR_THRESHOLD) : defaultAdditionsCount; 63 | 64 | const { 65 | payload: { 66 | repository, 67 | organization: { login: owner }, 68 | pull_request: pullRequest, 69 | }, 70 | } = github.context; 71 | 72 | if (typeof repository === 'undefined') { 73 | throw new Error(`Missing 'repository' from github action context.`); 74 | } 75 | 76 | const { name: repo } = repository; 77 | 78 | const { 79 | base: { ref: baseBranch }, 80 | head: { ref: headBranch }, 81 | number: prNumber = 0, 82 | body: prBody = '', 83 | additions = 0, 84 | title = '', 85 | } = pullRequest as PullRequestParams; 86 | 87 | // common fields for both issue and comment 88 | const commonPayload = { 89 | owner, 90 | repo, 91 | // eslint-disable-next-line @typescript-eslint/camelcase 92 | issue_number: prNumber, 93 | }; 94 | 95 | // github client with given token 96 | const client: github.GitHub = new github.GitHub(GITHUB_TOKEN); 97 | 98 | if (!headBranch && !baseBranch) { 99 | const commentBody = 'jira-lint is unable to determine the head and base branch'; 100 | const comment: IssuesCreateCommentParams = { 101 | ...commonPayload, 102 | body: commentBody, 103 | }; 104 | await addComment(client, comment); 105 | 106 | core.setFailed('Unable to get the head and base branch'); 107 | process.exit(1); 108 | } 109 | 110 | console.log('Base branch -> ', baseBranch); 111 | console.log('Head branch -> ', headBranch); 112 | 113 | if (shouldSkipBranchLint(headBranch, BRANCH_IGNORE_PATTERN)) { 114 | process.exit(0); 115 | } 116 | 117 | const issueKeys = getJIRAIssueKeys(headBranch); 118 | if (!issueKeys.length) { 119 | const comment: IssuesCreateCommentParams = { 120 | ...commonPayload, 121 | body: getNoIdComment(headBranch), 122 | }; 123 | await addComment(client, comment); 124 | 125 | core.setFailed('JIRA issue id is missing in your branch.'); 126 | process.exit(1); 127 | } 128 | 129 | // use the last match (end of the branch name) 130 | const issueKey = issueKeys[issueKeys.length - 1]; 131 | console.log(`JIRA key -> ${issueKey}`); 132 | 133 | const { getTicketDetails } = getJIRAClient(JIRA_BASE_URL, JIRA_TOKEN); 134 | const details: JIRADetails = await getTicketDetails(issueKey); 135 | if (details.key) { 136 | const podLabel = details?.project?.name || ''; 137 | const hotfixLabel: string = getHotfixLabel(baseBranch); 138 | const typeLabel: string = details?.type?.name || ''; 139 | const labels: string[] = [podLabel, hotfixLabel, typeLabel].filter(isNotBlank); 140 | console.log('Adding lables -> ', labels); 141 | 142 | await addLabels(client, { 143 | ...commonPayload, 144 | labels, 145 | }); 146 | 147 | if (!isIssueStatusValid(VALIDATE_ISSUE_STATUS, ALLOWED_ISSUE_STATUSES.split(','), details)) { 148 | const invalidIssueStatusComment: IssuesCreateCommentParams = { 149 | ...commonPayload, 150 | body: getInvalidIssueStatusComment(details.status, ALLOWED_ISSUE_STATUSES), 151 | }; 152 | console.log('Adding comment for invalid issue status'); 153 | await addComment(client, invalidIssueStatusComment); 154 | 155 | core.setFailed('The found jira issue does is not in acceptable statuses'); 156 | process.exit(1); 157 | } 158 | 159 | if (shouldUpdatePRDescription(prBody)) { 160 | const prData: PullsUpdateParams = { 161 | owner, 162 | repo, 163 | // eslint-disable-next-line @typescript-eslint/camelcase 164 | pull_number: prNumber, 165 | body: getPRDescription(prBody, details), 166 | }; 167 | await updatePrDetails(client, prData); 168 | 169 | // add comment for PR title 170 | if (!SKIP_COMMENTS) { 171 | const prTitleComment: IssuesCreateCommentParams = { 172 | ...commonPayload, 173 | body: getPRTitleComment(details.summary, title), 174 | }; 175 | console.log('Adding comment for the PR title'); 176 | addComment(client, prTitleComment); 177 | 178 | // add a comment if the PR is huge 179 | if (isHumongousPR(additions, prThreshold)) { 180 | const hugePrComment: IssuesCreateCommentParams = { 181 | ...commonPayload, 182 | body: getHugePrComment(additions, prThreshold), 183 | }; 184 | console.log('Adding comment for huge PR'); 185 | addComment(client, hugePrComment); 186 | } 187 | } 188 | } 189 | } else { 190 | const comment: IssuesCreateCommentParams = { 191 | ...commonPayload, 192 | body: getNoIdComment(headBranch), 193 | }; 194 | await addComment(client, comment); 195 | 196 | core.setFailed('Invalid JIRA key. Please create a branch with a valid JIRA issue key.'); 197 | process.exit(1); 198 | } 199 | } catch (error) { 200 | console.log({ error }); 201 | core.setFailed(error.message); 202 | process.exit(1); 203 | } 204 | } 205 | 206 | run(); 207 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | 3 | export interface PullRequestParams { 4 | number: number; 5 | html_url?: string; 6 | body?: string; 7 | base: { 8 | ref: string; 9 | }; 10 | head: { 11 | ref: string; 12 | }; 13 | changed_files?: number; 14 | additions?: number; 15 | title?: string; 16 | [key: string]: unknown; 17 | } 18 | 19 | export enum StoryType { 20 | Feature = 'feature', 21 | Bug = 'bug', 22 | Chore = 'chore', 23 | Release = 'release', 24 | } 25 | 26 | export interface Label { 27 | name: string; 28 | } 29 | 30 | export const enum StoryState { 31 | Accepted = 'accepted', 32 | Delivered = 'delivered', 33 | Finished = 'finished', 34 | Planned = 'planned', 35 | Rejected = 'rejected', 36 | Started = 'started', 37 | Unscheduled = 'unscheduled', 38 | Unstarted = 'unstarted', 39 | } 40 | 41 | export namespace JIRA { 42 | export interface IssueStatus { 43 | self: string; 44 | description: string; 45 | iconUrl: string; 46 | name: string; 47 | id: string; 48 | statusCategory: { 49 | self: string; 50 | id: number; 51 | key: string; 52 | colorName: string; 53 | name: string; 54 | }; 55 | } 56 | 57 | export interface IssuePriority { 58 | self: string; 59 | iconUrl: string; 60 | name: string; 61 | id: string; 62 | } 63 | 64 | export interface IssueType { 65 | self: string; 66 | id: string; 67 | description: string; 68 | iconUrl: string; 69 | name: string; 70 | subtask: boolean; 71 | avatarId: number; 72 | } 73 | 74 | export interface IssueProject { 75 | self: string; 76 | key: string; 77 | name: string; 78 | } 79 | 80 | export interface Issue { 81 | id: string; 82 | key: string; 83 | self: string; 84 | status: string; 85 | fields: { 86 | summary: string; 87 | status: IssueStatus; 88 | priority: IssuePriority; 89 | issuetype: IssueType; 90 | project: IssueProject; 91 | labels: string[]; 92 | [k: string]: unknown; 93 | }; 94 | } 95 | } 96 | 97 | export interface JIRADetails { 98 | key: string; 99 | summary: string; 100 | url: string; 101 | status: string; 102 | type: { 103 | name: string; 104 | icon: string; 105 | }; 106 | project: { 107 | name: string; 108 | url: string; 109 | key: string; 110 | }; 111 | estimate: string | number; 112 | labels: readonly { name: string; url: string }[]; 113 | } 114 | 115 | export interface JIRALintActionInputs { 116 | JIRA_TOKEN: string; 117 | JIRA_BASE_URL: string; 118 | GITHUB_TOKEN: string; 119 | BRANCH_IGNORE_PATTERN: string; 120 | SKIP_COMMENTS: boolean; 121 | PR_THRESHOLD: number; 122 | VALIDATE_ISSUE_STATUS: boolean; 123 | ALLOWED_ISSUE_STATUSES: string; 124 | } 125 | 126 | export interface JIRAClient { 127 | client: AxiosInstance; 128 | /** Get complete JIRA Issue details. */ 129 | getIssue: (key: string) => Promise; 130 | /** Get required details to display in PR. */ 131 | getTicketDetails: (key: string) => Promise; 132 | } 133 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as core from '@actions/core'; 3 | import * as github from '@actions/github'; 4 | import similarity from 'string-similarity'; 5 | import { IssuesAddLabelsParams, PullsUpdateParams, IssuesCreateCommentParams } from '@octokit/rest'; 6 | import { 7 | MARKER_REGEX, 8 | BOT_BRANCH_PATTERNS, 9 | DEFAULT_BRANCH_PATTERNS, 10 | JIRA_REGEX_MATCHER, 11 | HIDDEN_MARKER, 12 | } from './constants'; 13 | import { JIRA, JIRADetails, JIRAClient } from './types'; 14 | 15 | export const isBlank = (input: string): boolean => input.trim().length === 0; 16 | export const isNotBlank = (input: string): boolean => !isBlank(input); 17 | 18 | /** Reverse a string. */ 19 | export const reverseString = (input: string): string => input.split('').reverse().join(''); 20 | 21 | /** Extract JIRA issue keys from a string. */ 22 | export const getJIRAIssueKeys = (input: string): string[] => { 23 | const matches = reverseString(input).toUpperCase().match(JIRA_REGEX_MATCHER); 24 | if (matches?.length) { 25 | return matches.map(reverseString).reverse(); 26 | } else return []; 27 | }; 28 | 29 | export const LABELS = { 30 | HOTFIX_PRE_PROD: 'HOTFIX-PRE-PROD', 31 | HOTFIX_PROD: 'HOTFIX-PROD', 32 | }; 33 | 34 | /** Return a hotfix label based on base branch type. */ 35 | export const getHotfixLabel = (baseBranch: string): string => { 36 | if (baseBranch.startsWith('release/v')) return LABELS.HOTFIX_PRE_PROD; 37 | if (baseBranch.startsWith('production')) return LABELS.HOTFIX_PROD; 38 | return ''; 39 | }; 40 | 41 | export const getJIRAClient = (baseURL: string, token: string): JIRAClient => { 42 | const client = axios.create({ 43 | baseURL: `${baseURL}/rest/api/3`, 44 | timeout: 2000, 45 | headers: { Authorization: `Basic ${token}` }, 46 | }); 47 | 48 | const getIssue = async (id: string): Promise => { 49 | try { 50 | const response = await client.get( 51 | `/issue/${id}?fields=project,summary,issuetype,labels,status,customfield_10016` 52 | ); 53 | return response.data; 54 | } catch (e) { 55 | throw e; 56 | } 57 | }; 58 | 59 | const getTicketDetails = async (key: string): Promise => { 60 | try { 61 | const issue: JIRA.Issue = await getIssue(key); 62 | const { 63 | fields: { 64 | issuetype: type, 65 | project, 66 | summary, 67 | customfield_10016: estimate, 68 | labels: rawLabels, 69 | status: issueStatus, 70 | }, 71 | } = issue; 72 | 73 | const labels = rawLabels.map((label) => ({ 74 | name: label, 75 | url: `${baseURL}/issues?jql=${encodeURIComponent( 76 | `project = ${project.key} AND labels = ${label} ORDER BY created DESC` 77 | )}`, 78 | })); 79 | 80 | return { 81 | key, 82 | summary, 83 | url: `${baseURL}/browse/${key}`, 84 | status: issueStatus.name, 85 | type: { 86 | name: type.name, 87 | icon: type.iconUrl, 88 | }, 89 | project: { 90 | name: project.name, 91 | url: `${baseURL}/browse/${project.key}`, 92 | key: project.key, 93 | }, 94 | estimate: typeof estimate === 'string' || typeof estimate === 'number' ? estimate : 'N/A', 95 | labels, 96 | }; 97 | } catch (e) { 98 | throw e; 99 | } 100 | }; 101 | 102 | return { 103 | client, 104 | getTicketDetails, 105 | getIssue, 106 | }; 107 | }; 108 | 109 | /** Add the specified label to the PR. */ 110 | export const addLabels = async (client: github.GitHub, labelData: IssuesAddLabelsParams): Promise => { 111 | try { 112 | await client.issues.addLabels(labelData); 113 | } catch (error) { 114 | core.setFailed(error.message); 115 | process.exit(1); 116 | } 117 | }; 118 | 119 | /** Update a PR details. */ 120 | export const updatePrDetails = async (client: github.GitHub, prData: PullsUpdateParams): Promise => { 121 | try { 122 | await client.pulls.update(prData); 123 | } catch (error) { 124 | core.setFailed(error.message); 125 | process.exit(1); 126 | } 127 | }; 128 | 129 | /** Add a comment to a PR. */ 130 | export const addComment = async (client: github.GitHub, comment: IssuesCreateCommentParams): Promise => { 131 | try { 132 | await client.issues.createComment(comment); 133 | } catch (error) { 134 | core.setFailed(error.message); 135 | } 136 | }; 137 | 138 | /** Get a comment based on story title and PR title similarity. */ 139 | export const getPRTitleComment = (storyTitle: string, prTitle: string): string => { 140 | const matchRange: number = similarity.compareTwoStrings(storyTitle, prTitle); 141 | if (matchRange < 0.2) { 142 | return `

143 | Knock Knock! 🔍 144 |

145 |

146 | Just thought I'd let you know that your PR title and story title look quite different. PR titles 147 | that closely resemble the story title make it easier for reviewers to understand the context of the PR. 148 |

149 |
150 | An easy-to-understand PR title a day makes the reviewer review away! 😛⚡️ 151 |
152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 |
Story Title${storyTitle}
PR Title${prTitle}
162 |

163 | Check out this guide to learn more about PR best-practices. 164 |

165 | `; 166 | } else if (matchRange >= 0.2 && matchRange <= 0.4) { 167 | return `

168 | Let's make that PR title a 💯 shall we? 💪 169 |

170 |

171 | Your PR title and story title look slightly different. Just checking in to know if it was intentional! 172 |

173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 |
Story Title${storyTitle}
PR Title${prTitle}
183 |

184 | Check out this guide to learn more about PR best-practices. 185 |

186 | `; 187 | } 188 | return `

I'm a bot and I 👍 this PR title. 🤖

189 | 190 | `; 191 | }; 192 | 193 | /** 194 | * Check if the PR is an automated one created by a bot or one matching ignore patterns supplied 195 | * via action metadata. 196 | * 197 | * @example shouldSkipBranchLint('dependabot') -> true 198 | * @example shouldSkipBranchLint('feature/update_123456789') -> false 199 | */ 200 | export const shouldSkipBranchLint = (branch: string, additionalIgnorePattern?: string): boolean => { 201 | if (BOT_BRANCH_PATTERNS.some((pattern) => pattern.test(branch))) { 202 | console.log(`You look like a bot 🤖 so we're letting you off the hook!`); 203 | return true; 204 | } 205 | 206 | if (DEFAULT_BRANCH_PATTERNS.some((pattern) => pattern.test(branch))) { 207 | console.log(`Ignoring check for default branch ${branch}`); 208 | return true; 209 | } 210 | 211 | const ignorePattern = new RegExp(additionalIgnorePattern || ''); 212 | if (!!additionalIgnorePattern && ignorePattern.test(branch)) { 213 | console.log( 214 | `branch '${branch}' ignored as it matches the ignore pattern '${additionalIgnorePattern}' provided in skip-branches` 215 | ); 216 | return true; 217 | } 218 | 219 | console.log(`branch '${branch}' does not match ignore pattern provided in 'skip-branches' option:`, ignorePattern); 220 | return false; 221 | }; 222 | 223 | /** 224 | * Returns true if the body contains the hidden marker. Used to avoid adding 225 | * story details to the PR multiple times. 226 | * 227 | * @example shouldUpdatePRDescription('--\nadded_by_pr_lint\n') -> true 228 | * @example shouldUpdatePRDescription('# some description') -> false 229 | */ 230 | export const shouldUpdatePRDescription = ( 231 | /** The PR description/body as a string. */ 232 | body?: string 233 | ): boolean => typeof body === 'string' && !MARKER_REGEX.test(body); 234 | 235 | /** 236 | * Get links to labels & remove spacing so the table works. 237 | */ 238 | export const getLabelsForDisplay = (labels: JIRADetails['labels']): string => { 239 | if (!labels || !labels.length) { 240 | return '-'; 241 | } 242 | const markUp = labels.map((label) => `${label.name}`).join(', '); 243 | return markUp.replace(/\s+/, ' '); 244 | }; 245 | 246 | /** Get PR description with story/issue details. */ 247 | export const getPRDescription = (body = '', details: JIRADetails): string => { 248 | const displayKey = details.key.toUpperCase(); 249 | 250 | return ` 251 |
252 | ${displayKey} 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 |
Summary${details.summary}
Type 262 | ${details.type.name} 263 | ${details.type.name} 264 |
Status${details.status}
Points${details.estimate || 'N/A'}
Labels${getLabelsForDisplay(details.labels)}
279 |
280 | 284 | 285 | --- 286 | 287 | ${body}`; 288 | }; 289 | 290 | /** Check if a PR is considered "huge". */ 291 | export const isHumongousPR = (additions: number, threshold: number): boolean => 292 | typeof additions === 'number' && additions > threshold; 293 | 294 | /** Get the comment body for very huge PR. */ 295 | export const getHugePrComment = ( 296 | /** Number of additions. */ 297 | additions: number, 298 | /** Threshold of additions allowed. */ 299 | threshold: number 300 | ): string => 301 | `

This PR is too huge for one to review :broken_heart:

302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 |
Additions${additions} :no_good_woman:
Expected:arrow_down: ${threshold}
313 |

314 | Consider breaking it down into multiple small PRs. 315 |

316 |

317 | Check out this guide to learn more about PR best-practices. 318 |

319 | `; 320 | 321 | /** Get the comment body for pr with no JIRA id in the branch name. */ 322 | export const getNoIdComment = (branch: string): string => { 323 | return `

A JIRA Issue ID is missing from your branch name! 🦄

324 |

Your branch: ${branch}

325 |

If this is your first time contributing to this repository - welcome!

326 |
327 |

Please refer to jira-lint to get started. 328 |

Without the JIRA Issue ID in your branch name you would lose out on automatic updates to JIRA via SCM; some GitHub status checks might fail.

329 | Valid sample branch names: 330 | 331 | ‣ feature/shiny-new-feature--mojo-10' 332 | ‣ 'chore/changelogUpdate_mojo-123' 333 | ‣ 'bugfix/fix-some-strange-bug_GAL-2345' 334 | `; 335 | }; 336 | 337 | /** Check if jira issue status validation is enabled then compare the issue status will the allowed statuses. */ 338 | export const isIssueStatusValid = ( 339 | shouldValidate: boolean, 340 | allowedIssueStatuses: string[], 341 | details: JIRADetails 342 | ): boolean => { 343 | if (!shouldValidate) { 344 | core.info('Skipping Jira issue status validation as shouldValidate is false'); 345 | return true; 346 | } 347 | 348 | return allowedIssueStatuses.includes(details.status); 349 | }; 350 | 351 | /** Get the comment body for very huge PR. */ 352 | export const getInvalidIssueStatusComment = ( 353 | /** Number of additions. */ 354 | issueStatus: string, 355 | /** Threshold of additions allowed. */ 356 | allowedStatuses: string 357 | ): string => 358 | `

:broken_heart: The detected issue is not in one of the allowed statuses :broken_heart:

359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 |
Detected Status${issueStatus}:x:
Allowed Statuses${allowedStatuses}:heavy_check_mark:
371 |

Please ensure your jira story is in one of the allowed statuses

372 | `; 373 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules"], 4 | "include": [ 5 | "src/**/*.ts", 6 | ".eslintrc.js", 7 | ".prettierrc.js", 8 | "jest.config.js", 9 | "lint-staged.config.js", 10 | "**/*.test.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | /* Strict Type-Checking Options */ 8 | "strict": true, /* Enable all strict type-checking options. */ 9 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 10 | "strictNullChecks": true, /* Enable strict null checks. */ 11 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 12 | 13 | /* Additional Checks */ 14 | "noUnusedLocals": true, /* Report errors on unused locals. */ 15 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 17 | 18 | /* Module Resolution Options */ 19 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 20 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 21 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 22 | }, 23 | "exclude": ["node_modules", "**/*.test.ts"] 24 | } 25 | --------------------------------------------------------------------------------