├── .github └── workflows │ ├── pr-branch-checks.yml │ └── pr-test.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .release-it.js ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ ├── github-connector.test.ts └── utils.test.ts ├── action.yml ├── coc.md ├── illustration.png ├── jest.config.js ├── lib └── index.js ├── package-lock.json ├── package.json ├── src ├── action-inputs.ts ├── constants.ts ├── github-connector.ts ├── jira-connector.ts ├── main.ts ├── types.ts └── utils.ts └── tsconfig.json /.github/workflows/pr-branch-checks.yml: -------------------------------------------------------------------------------- 1 | name: "test -> build -> update lib if needed" 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | - 'add-release' 7 | tags: 8 | - '!**' 9 | 10 | jobs: 11 | build-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version-file: '.nvmrc' 18 | cache: npm 19 | 20 | - name: "install" 21 | run: npm ci --production 22 | 23 | - name: "test" 24 | run: npm run test 25 | 26 | - name: "build" 27 | run: npm run build 28 | 29 | - name: "check if build has changed" 30 | if: success() 31 | id: has-changes 32 | run: | 33 | echo "LIB_DIFF=$(git diff --stat --name-only -- lib)" >> $GITHUB_ENV 34 | 35 | - name: "Commit files" 36 | if: ${{ env.LIB_DIFF }} 37 | run: | 38 | git config --local user.email "buildaction@bot.bot" 39 | git config --local user.name "Build action bot" 40 | git commit -m "chore: build action" -a 41 | 42 | - name: release 43 | run: npm run release 44 | -------------------------------------------------------------------------------- /.github/workflows/pr-test.yml: -------------------------------------------------------------------------------- 1 | name: "test -> build -> update lib if needed" 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | build-test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version-file: '.nvmrc' 13 | cache: npm 14 | 15 | - name: "install" 16 | run: npm ci --production 17 | 18 | - name: "test" 19 | run: npm run test 20 | 21 | - name: "build" 22 | run: npm run build 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | node_modules 11 | 12 | # IDE code custom settings 13 | .vscode 14 | .idea 15 | 16 | .npm 17 | 18 | coverage -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "tabWidth": 2, 4 | "parser": "typescript", 5 | "semi": true, 6 | "singleQuote": true, 7 | "bracketSpacing": true, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /.release-it.js: -------------------------------------------------------------------------------- 1 | module.exports ={ 2 | "git": { 3 | "commitMessage": "chore: release v${version}", 4 | "tagName": "${version}", 5 | "push": true 6 | }, 7 | "github": { 8 | "release": true 9 | }, 10 | "npm": { 11 | "publish": false 12 | }, 13 | "plugins": { 14 | '@release-it/conventional-changelog': { 15 | infile: 'CHANGELOG.md', 16 | gitRawCommitsOpts: { 17 | 'full-history': true, 18 | 'no-merges': true, 19 | } 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 2 | * update @actions/core package to 1.10.0 3 | 4 | ## 0.6.1 5 | * Fix adding outputs if action failed 6 | 7 | ## 0.6.0 8 | * Add `jira-issue-found` and `jira-issue-source` outputs https://github.com/cakeinpanic/jira-description-action/pull/45 9 | 10 | ## 0.5.1 11 | * Improve error logging if Jira request fails 12 | 13 | ## 0.5.0 14 | * Allow using action not only in organization https://github.com/cakeinpanic/jira-description-action/pull/36 15 | * Improve JIRA error output(get rid of circular dependency log) https://github.com/cakeinpanic/jira-description-action/pull/43 16 | 17 | ## 0.4.0 18 | * Use node 16+ https://github.com/cakeinpanic/jira-description-action/pull/27 19 | 20 | ## 0.3.1 21 | * Don't fail if PR body is empty fixing https://github.com/cakeinpanic/jira-description-action/issues/17 22 | 23 | ## 0.3.0 24 | * Add `fail-when-jira-issue-not-found` input 25 | 26 | ## 0.2.0 27 | * Replace `use-branch-name` with `use` 28 | 29 | ## 0.1.2 30 | * Add regexp groups support 31 | * Allow usage without project name 32 | 33 | ## 0.1.1 34 | * Don't make user to base64 encode his token manually 35 | * Remove auto-build of action itself for tags and fix target branch for non-master branches 36 | 37 | ## 0.1.0 38 | * Release first version 39 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @cakeinpanic 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution Guidelines 2 | * Ensure your code follows the repository's coding style. 3 | * Keep pull requests concise and focused. 4 | * Provide a clear description of changes. 5 | * Ensure that all tests pass before submitting a pull request. 6 | * Be respectful and supportive in your communications. 7 | 8 | ## Hacktoberfest 9 | This project is participating in Hacktoberfest! Contributions that align with the rules of Hacktoberfest will be eligible for participation. Make sure to follow [Hacktoberfest's guidelines](https://hacktoberfest.com/participation/#values). 10 | 11 | Please see [issues with hacktobersest topic](https://github.com/cakeinpanic/jira-description-action/labels/hacktoberfest) if you are looking for a challenge to pick! 12 | 13 | ## Code of Conduct 14 | This repo adheres to a [Code of Conduct](./coc.md). Please make sure to follow it during your participation. 15 | 16 | Happy coding! 🎉 17 | -------------------------------------------------------------------------------- /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-description-action 2 | 3 | > A lightweight solution to integrate GitHub with JIRA for project management. 🔎 4 | 5 | ![illustration](illustration.png) 6 | ## Installation 7 | 8 | To make `jira-description-action` a part of your workflow, just add a `jira-description-action.yml` file in your `.github/workflows/` directory in your GitHub repository. 9 | 10 | > **Note** 11 | > This action fetches PR description and does not take it form context. So if you are chaining a few actions which work with PR description, put this one as the last one 12 | 13 | ```yml 14 | name: jira-description-action 15 | on: 16 | pull_request: 17 | types: [opened, edited] 18 | jobs: 19 | add-jira-description: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: cakeinpanic/jira-description-action@master 23 | name: jira-description-action 24 | with: 25 | github-token: ${{ secrets.GITHUB_TOKEN }} 26 | jira-token: ${{ secrets.JIRA_TOKEN }} 27 | jira-base-url: https://your-domain.atlassian.net 28 | skip-branches: '^(production-release|main|master|release\/v\d+)$' #optional 29 | custom-issue-number-regexp: '^\d+' #optional 30 | jira-project-key: 'PRJ' #optional 31 | ``` 32 | ` 33 | 34 | ## Options 35 | 36 | | key | description | required | default | 37 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | 38 | | `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. The `GITHUB_TOKEN` needs to have read & write permissions over the repository. You can either set permissions in the workflow file, or in the repository configuration. (see [Control permissions for GITHUB_TOKEN](https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/#setting-the-default-permissions-for-the-organization-or-repository)) | true | null | 39 | | `jira-token` | Token used to fetch Jira Issue information. Check [below](#jira-token) for more details on how to generate the token. | true | null | 40 | | `jira-base-url` | The subdomain of JIRA cloud that you use to access it. Ex: "https://your-domain.atlassian.net". | true | null | 41 | | `skip-branches` | A regex to ignore running `jira-description-action` on certain branches, like production etc. | false | ' ' | 42 | | `use` | Enum: `branch \| pr-title \| both`, to search for issue number in branch name or in PR title | false | pr-title | 43 | | `jira-project-key` | Key of project in jira. First part of issue key | false | none | 44 | | `custom-issue-number-regexp` | Custom regexp to extract issue number from branch name. If not specified, default regexp would be used. | false | none | 45 | | `fail-when-jira-issue-not-found` | Should action fail if jira issue is not found in jira | false | false | 46 | 47 | ## Outputs 48 | 49 | | key | description 50 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 51 | | `jira-issue-key` | The JIRA issue key. If key is not found the value is an empty string |**** 52 | | `jira-issue-found` | Indication whether a jira issue was found or not | 53 | | `jira-issue-source` | Indication how the jira issue was found, by - `branch \| pr-title \| null` | 54 | 55 | Tokens are private, so it's suggested adding them as [GitHub secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets). 56 | 57 | ## Features 58 | * [Jira token](#jira-token) 59 | * [Skipping branches](#skipping-branches) 60 | * [Searching in branch name/PR title](#searching-in-branch-namepr-title) 61 | * [Using custom regex](#using-custom-regex) 62 | * [Custom label placement](#custom-label-placement) 63 | 64 | ### `jira-token` 65 | 66 | The Jira token is used to fetch issue information via the Jira REST API. To get the token:- 67 | 1. Generate an [API token via JIRA](https://confluence.atlassian.com/cloud/api-tokens-938839638.html) 68 | 2. Add value `:` to the `JIRA_TOKEN` secret in your GitHub project. 69 | For example, if the username is `ci@example.com` and the token is `954c38744be9407ab6fb`, then `ci@example.com:954c38744be9407ab6fb` needs to be added as a secret 70 | 71 | 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). 72 | 73 | ### Skipping branches 74 | 75 | `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 in thi repo 76 | 77 | `jira-description-action` already skips PRs which are filed by [dependabot](https://github.com/marketplace/dependabot-preview) 78 | 79 | ### Searching in branch name/PR title 80 | 81 | By default issue key is searched in PR title(which can easily be changed). `use` option can be set to `branch` if you want to get issue key from branch name. Or to `both` to look first in pr-title, then in branch name 82 | 83 | ### Using custom regex 84 | If `custom-issue-number-regexp` is not provided, full key of issue is searched using regexp `/([a-zA-Z0-9]{1,10}-\d+)/g;`. 85 | For example 86 | ``` 87 | bugfix/prj-15-click -> PRJ-15 88 | prj-15-bugfix-17 -> PRJ-17 89 | 15-bugfix -> nothing found 90 | ``` 91 | Custom regexp would work like that(check for more [in tests](__tests__/utils.test.ts#106)): 92 | ``` 93 | custom-issue-number-regexp: 'MYPROJ-\d+' 94 | ``` 95 | ``` 96 | bugfix/MYPROJ-15-click -> MYPROJ-15 97 | prj-15-myproj-17 -> MYPROJ-15 // it is insensitive by design 98 | 15-bugfix -> null 99 | ``` 100 | 101 | If you don't use full keys in branch names, you can specify optional parameters to compute issue keys. 102 | It would be appended to found key as `${jira-project-key}-{regexp-match}`: 103 | ``` 104 | jira-project-key: 'MYPROJ' 105 | custom-issue-number-regexp: '\d+' 106 | ``` 107 | ``` 108 | bugfix/prj-15-click -> MYPROJ-15 109 | prj-15-bugfix-17 -> MYPROJ-15 110 | 15-bugfix -> MYPROJ-15 111 | ``` 112 | Groups in regexp can also be used(last group in a match would be taken): 113 | ``` 114 | jira-project-key: 'MYPROJ' 115 | custom-issue-number-regexp: '-(\d+)' 116 | ``` 117 | ``` 118 | bugfix/prj15-239-click -> MYPROJ-239 119 | 15-bugfix -> null 120 | ``` 121 | 122 | ### Custom label placement 123 | By default label would be prepended to the PR body. If you want it to be placed elsewhere in the description, you can add markers in the [pull request template](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository#adding-a-pull-request-template) for the repository. 124 | 125 | Label would be inserted between these marker lines: 126 | ``` 127 | 128 | 129 | 130 | ``` 131 | -------------------------------------------------------------------------------- /__tests__/github-connector.test.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import { GithubConnector } from '../src/github-connector'; 3 | import { ESource, IActionInputs } from '../src/types'; 4 | import { describe } from 'jest-circus'; 5 | import { getJIRAIssueKeyByDefaultRegexp, getJIRAIssueKeysByCustomRegexp } from '../src/utils'; 6 | import { getInputs } from '../src/action-inputs'; 7 | 8 | const MOCK_INPUT: Partial = { 9 | GITHUB_TOKEN: 'GITHUB_TOKEN', 10 | }; 11 | 12 | const BRANCH_NAME = 'branchName'; 13 | const PR_TITLE = 'prTitle'; 14 | 15 | jest.mock('@actions/github', () => { 16 | const MOCK_CONTEXT = { 17 | eventName: 'eventName', 18 | payload: { 19 | repository: 'repository', 20 | organization: { login: { owner: 'owner' } }, 21 | pull_request: { title: 'prTitle', head: { ref: 'branchName' } }, 22 | }, 23 | }; 24 | return { 25 | getOctokit: jest.fn(), 26 | context: MOCK_CONTEXT, 27 | }; 28 | }); 29 | 30 | jest.mock('../src/action-inputs'); 31 | jest.mock('../src/utils'); 32 | 33 | describe('Github connector()', () => { 34 | let connector: GithubConnector; 35 | 36 | it('initializes correctly', () => { 37 | (getInputs as any).mockImplementation(() => MOCK_INPUT); 38 | connector = new GithubConnector(); 39 | expect(getOctokit).toHaveBeenCalledWith(MOCK_INPUT.GITHUB_TOKEN); 40 | }); 41 | 42 | describe('getIssueKeyFromTitle()', () => { 43 | describe('if some CUSTOM_ISSUE_NUMBER_REGEXP is empty', () => { 44 | const INPUTS_MOCK = { 45 | ...MOCK_INPUT, 46 | ...{ 47 | CUSTOM_ISSUE_NUMBER_REGEXP: '', 48 | }, 49 | }; 50 | const getJIRAIssueKeyReturnValue = 'getJIRAIssueKeyByDefaultRegexp'; 51 | beforeEach(() => { 52 | (getJIRAIssueKeyByDefaultRegexp as any).mockImplementation(() => getJIRAIssueKeyReturnValue); 53 | }); 54 | 55 | it('calls getJIRAIssueKeyByDefaultRegexp method with branch name if WHAT_TO_USE === branch', () => { 56 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.branch })); 57 | connector = new GithubConnector(); 58 | 59 | const jiraIssue = connector.getIssueKeyFromTitle(); 60 | expect(jiraIssue.key).toEqual(getJIRAIssueKeyReturnValue); 61 | expect(jiraIssue.source).toEqual(ESource.branch); 62 | expect(getJIRAIssueKeyByDefaultRegexp).toHaveBeenCalledWith(BRANCH_NAME); 63 | }); 64 | 65 | it('calls getJIRAIssueKeyByDefaultRegexp method with PR title if USE_BRANCH_NAME == pr-title', () => { 66 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.prTitle })); 67 | connector = new GithubConnector(); 68 | 69 | const jiraIssue = connector.getIssueKeyFromTitle(); 70 | expect(jiraIssue.key).toEqual(getJIRAIssueKeyReturnValue); 71 | expect(jiraIssue.source).toEqual(ESource.prTitle); 72 | expect(getJIRAIssueKeyByDefaultRegexp).toHaveBeenCalledWith(PR_TITLE); 73 | }); 74 | 75 | describe('calls getJIRAIssueKeyByDefaultRegexp method with PR title and branch name if USE_BRANCH_NAME == both', () => { 76 | it('and returns pr-title result with a priority', () => { 77 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.prTitle })); 78 | (getJIRAIssueKeyByDefaultRegexp as any).mockImplementation((str: string) => (str === PR_TITLE ? PR_TITLE : null)); 79 | connector = new GithubConnector(); 80 | 81 | const jiraIssue = connector.getIssueKeyFromTitle(); 82 | expect(jiraIssue.key).toEqual(PR_TITLE); 83 | expect(jiraIssue.source).toEqual(ESource.prTitle); 84 | expect(getJIRAIssueKeyByDefaultRegexp).toHaveBeenCalledWith(PR_TITLE); 85 | expect(getJIRAIssueKeyByDefaultRegexp).not.toHaveBeenCalledWith(BRANCH_NAME); 86 | }); 87 | 88 | it('and returns branch result only if pr-title result is null', () => { 89 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.both })); 90 | (getJIRAIssueKeyByDefaultRegexp as any).mockImplementation((str: string) => (str === PR_TITLE ? null : BRANCH_NAME)); 91 | connector = new GithubConnector(); 92 | 93 | const jiraIssue = connector.getIssueKeyFromTitle(); 94 | expect(jiraIssue.key).toEqual(BRANCH_NAME); 95 | expect(jiraIssue.source).toEqual(ESource.branch); 96 | expect(getJIRAIssueKeyByDefaultRegexp).toHaveBeenCalledWith(PR_TITLE); 97 | expect(getJIRAIssueKeyByDefaultRegexp).toHaveBeenCalledWith(BRANCH_NAME); 98 | }); 99 | }); 100 | }); 101 | 102 | describe('if both JIRA_PROJECT_KEY and CUSTOM_ISSUE_NUMBER_REGEXP are not empty', () => { 103 | const INPUTS_MOCK = { 104 | ...MOCK_INPUT, 105 | ...{ 106 | JIRA_PROJECT_KEY: 'JIRA_PROJECT_KEY', 107 | CUSTOM_ISSUE_NUMBER_REGEXP: 'CUSTOM_ISSUE_NUMBER_REGEXP', 108 | }, 109 | }; 110 | 111 | const getJIRAIssueKeysByCustomRegexpReturnValue = 'getJIRAIssueKeysByCustomRegexp'; 112 | 113 | beforeEach(() => { 114 | (getJIRAIssueKeysByCustomRegexp as any).mockImplementation(() => getJIRAIssueKeysByCustomRegexpReturnValue); 115 | }); 116 | 117 | it('calls getJIRAIssueKeysByCustomRegexp method with branch name if USE_BRANCH_NAME === branch', () => { 118 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.branch })); 119 | connector = new GithubConnector(); 120 | 121 | const jiraIssue = connector.getIssueKeyFromTitle(); 122 | expect(jiraIssue.key).toEqual(getJIRAIssueKeysByCustomRegexpReturnValue); 123 | expect(jiraIssue.source).toEqual(ESource.branch); 124 | expect(getJIRAIssueKeysByCustomRegexp).toHaveBeenCalledWith( 125 | BRANCH_NAME, 126 | INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, 127 | INPUTS_MOCK.JIRA_PROJECT_KEY 128 | ); 129 | }); 130 | 131 | it('calls getJIRAIssueKeysByCustomRegexp method with PR title if USE_BRANCH_NAME === pr-title', () => { 132 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.prTitle })); 133 | connector = new GithubConnector(); 134 | 135 | const jiraIssue = connector.getIssueKeyFromTitle(); 136 | expect(jiraIssue.key).toEqual(getJIRAIssueKeysByCustomRegexpReturnValue); 137 | expect(jiraIssue.source).toEqual(ESource.prTitle); 138 | expect(getJIRAIssueKeysByCustomRegexp).toHaveBeenCalledWith(PR_TITLE, INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, INPUTS_MOCK.JIRA_PROJECT_KEY); 139 | }); 140 | 141 | describe('calls getJIRAIssueKeysByCustomRegexp method with PR title and branch name if USE_BRANCH_NAME == both', () => { 142 | it('and returns pr-title result with a priority', () => { 143 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.prTitle })); 144 | (getJIRAIssueKeysByCustomRegexp as any).mockImplementation((str: string) => (str === PR_TITLE ? PR_TITLE : null)); 145 | connector = new GithubConnector(); 146 | 147 | const jiraIssue = connector.getIssueKeyFromTitle(); 148 | expect(jiraIssue.key).toEqual(PR_TITLE); 149 | expect(jiraIssue.source).toEqual(ESource.prTitle); 150 | expect(getJIRAIssueKeysByCustomRegexp).toHaveBeenCalledWith(PR_TITLE, INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, INPUTS_MOCK.JIRA_PROJECT_KEY); 151 | expect(getJIRAIssueKeysByCustomRegexp).not.toHaveBeenCalledWith( 152 | BRANCH_NAME, 153 | INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, 154 | INPUTS_MOCK.JIRA_PROJECT_KEY 155 | ); 156 | }); 157 | 158 | it('and returns branch result only if pr-title result is null', () => { 159 | (getInputs as any).mockImplementation(() => ({ ...INPUTS_MOCK, WHAT_TO_USE: ESource.both })); 160 | (getJIRAIssueKeysByCustomRegexp as any).mockImplementation((str: string) => (str === PR_TITLE ? null : BRANCH_NAME)); 161 | connector = new GithubConnector(); 162 | 163 | const jiraIssue = connector.getIssueKeyFromTitle(); 164 | expect(jiraIssue.key).toEqual(BRANCH_NAME); 165 | expect(jiraIssue.source).toEqual(ESource.branch); 166 | expect(getJIRAIssueKeysByCustomRegexp).toHaveBeenCalledWith(PR_TITLE, INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, INPUTS_MOCK.JIRA_PROJECT_KEY); 167 | expect(getJIRAIssueKeysByCustomRegexp).toHaveBeenCalledWith( 168 | BRANCH_NAME, 169 | INPUTS_MOCK.CUSTOM_ISSUE_NUMBER_REGEXP, 170 | INPUTS_MOCK.JIRA_PROJECT_KEY 171 | ); 172 | }); 173 | }); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { HIDDEN_MARKER_END, HIDDEN_MARKER_START, WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS } from '../src/constants'; 2 | import { JIRADetails } from '../src/types'; 3 | import { getJIRAIssueKeyByDefaultRegexp, getJIRAIssueKeysByCustomRegexp, getPRDescription, shouldSkipBranch, buildPRDescription } from '../src/utils'; 4 | 5 | jest.spyOn(console, 'log').mockImplementation(); // avoid actual console.log in test output 6 | 7 | describe('shouldSkipBranch()', () => { 8 | it('should recognize bot PRs', () => { 9 | expect(shouldSkipBranch('dependabot/npm_and_yarn/types/react-dom-16.9.6')).toBe(true); 10 | expect(shouldSkipBranch('feature/add-dependabot-config')).toBe(false); 11 | }); 12 | 13 | it('should handle custom ignore patterns', () => { 14 | expect(shouldSkipBranch('bar', '^bar')).toBeTruthy(); 15 | expect(shouldSkipBranch('foobar', '^bar')).toBeFalsy(); 16 | 17 | expect(shouldSkipBranch('bar', '[0-9]{2}')).toBeFalsy(); 18 | expect(shouldSkipBranch('bar', '')).toBeFalsy(); 19 | expect(shouldSkipBranch('f00', '[0-9]{2}')).toBeTruthy(); 20 | 21 | const customBranchRegex = '^(production-release|master|release/v\\d+)$'; 22 | 23 | expect(shouldSkipBranch('production-release', customBranchRegex)).toBeTruthy(); 24 | expect(shouldSkipBranch('master', customBranchRegex)).toBeTruthy(); 25 | expect(shouldSkipBranch('release/v77', customBranchRegex)).toBeTruthy(); 26 | 27 | expect(shouldSkipBranch('release/very-important-feature', customBranchRegex)).toBeFalsy(); 28 | expect(shouldSkipBranch('')).toBeFalsy(); 29 | }); 30 | }); 31 | 32 | describe('getJIRAIssueKeys()', () => { 33 | it('gets jira key from different strings', () => { 34 | expect(getJIRAIssueKeyByDefaultRegexp('fix/login-protocol-es-43')).toEqual('ES-43'); 35 | expect(getJIRAIssueKeyByDefaultRegexp('fix/login-protocol-ES-43')).toEqual('ES-43'); 36 | expect(getJIRAIssueKeyByDefaultRegexp('[ES-43, ES-15] Feature description')).toEqual('ES-43'); 37 | 38 | expect(getJIRAIssueKeyByDefaultRegexp('feature/missingKey')).toEqual(null); 39 | expect(getJIRAIssueKeyByDefaultRegexp('')).toEqual(null); 40 | }); 41 | }); 42 | 43 | describe('getJIRAIssueKeysByCustomRegexp() gets jira keys from different strings', () => { 44 | it('with project name', () => { 45 | expect(getJIRAIssueKeysByCustomRegexp('law-18,345', '^LAW-??(\\d+)', 'LAW')).toEqual('LAW-18'); 46 | //expect(getJIRAIssueKeysByCustomRegexp('fix/login-protocol-es-43', '^\\d+', 'QQ')).toEqual(null); 47 | //expect(getJIRAIssueKeysByCustomRegexp('43-login-protocol', '^\\d+', 'QQ')).toEqual('QQ-43'); 48 | }); 49 | 50 | it('without project name', () => { 51 | expect(getJIRAIssueKeysByCustomRegexp('18,345', '\\d+')).toEqual('18'); 52 | expect(getJIRAIssueKeysByCustomRegexp('fix/login-protocol-es-43', 'es-\\d+')).toEqual('ES-43'); 53 | }); 54 | 55 | it('with grouped value in regexp', () => { 56 | expect(getJIRAIssueKeysByCustomRegexp('fix/login-protocol-es-43', '(es-\\d+)$')).toEqual('ES-43'); 57 | expect(getJIRAIssueKeysByCustomRegexp('fix/login-20-in-14', '-(IN-\\d+)')).toEqual('IN-14'); 58 | expect(getJIRAIssueKeysByCustomRegexp('fix/login-20-in-14', 'in-(\\d+)', 'PRJ')).toEqual('PRJ-20'); 59 | }); 60 | }); 61 | 62 | describe('getPRDescription()', () => { 63 | it('should prepend issue info with hidden markers to old PR body', () => { 64 | const oldPRBody = 'old PR description body'; 65 | const issueInfo = 'new info about jira task'; 66 | const description = getPRDescription(oldPRBody, issueInfo); 67 | 68 | expect(description).toEqual(`${WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS} 69 | ${HIDDEN_MARKER_START} 70 | ${issueInfo} 71 | ${HIDDEN_MARKER_END} 72 | ${oldPRBody}`); 73 | }); 74 | 75 | it('should replace issue info', () => { 76 | const oldPRBodyInformation = 'old PR description body'; 77 | const oldPRBody = `${HIDDEN_MARKER_START}Here is some old issue information${HIDDEN_MARKER_END}${oldPRBodyInformation}`; 78 | const issueInfo = 'new info about jira task'; 79 | 80 | const description = getPRDescription(oldPRBody, issueInfo); 81 | 82 | expect(description).toEqual(`${WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS} 83 | ${HIDDEN_MARKER_START} 84 | ${issueInfo} 85 | ${HIDDEN_MARKER_END} 86 | ${oldPRBodyInformation}`); 87 | }); 88 | 89 | it('does not duplicate WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS in the body when run multiple times', () => { 90 | const oldPRBodyInformation = 'old PR description body'; 91 | const oldPRBody = `${HIDDEN_MARKER_START}Here is some old issue information${HIDDEN_MARKER_END}${oldPRBodyInformation}`; 92 | const issueInfo = 'new info about jira task'; 93 | 94 | const firstDescription = getPRDescription(oldPRBody, issueInfo); 95 | const secondDescription = getPRDescription(firstDescription, issueInfo); 96 | 97 | expect(secondDescription).toEqual(`${WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS} 98 | ${HIDDEN_MARKER_START} 99 | ${issueInfo} 100 | ${HIDDEN_MARKER_END} 101 | ${oldPRBodyInformation}`); 102 | }); 103 | 104 | it('respects the location of HIDDEN_MARKER_START and HIDDEN_MARKER_END when they already exist in the pull request body', () => { 105 | const issueInfo = 'new info about jira task'; 106 | const oldPRDescription = `this is text above the markers 107 | ${WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS} 108 | ${HIDDEN_MARKER_START} 109 | ${issueInfo} 110 | ${HIDDEN_MARKER_END} 111 | this is text below the markers`; 112 | const description = getPRDescription(oldPRDescription, issueInfo); 113 | expect(description).toEqual(oldPRDescription); 114 | }); 115 | }); 116 | 117 | describe('buildPRDescription()', () => { 118 | it('should return description HTML from the JIRA details', () => { 119 | const details: JIRADetails = { 120 | key: 'ABC-123', 121 | summary: 'Sample summary', 122 | url: 'example.com/ABC-123', 123 | type: { 124 | name: 'story', 125 | icon: 'icon.png', 126 | }, 127 | project: { 128 | name: 'name', 129 | url: 'url', 130 | key: 'key', 131 | }, 132 | }; 133 | 134 | expect(buildPRDescription(details)).toEqual(`
135 | story ABC-123 136 | Sample summary 137 |
`); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'jira-description' 2 | description: 'Add JIRA issue details to your GitHub pull request.' 3 | author: 'cakeinpanic' 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. Format: :' 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 | use: 15 | description: 'Where to look for issue key: branch | pr-title | both' 16 | required: false 17 | skip-branches: 18 | description: 'A regex to ignore running on certain branches, like production etc.' 19 | required: false 20 | default: '' 21 | jira-project-key: 22 | description: 'Key of project in jira. First part of issue key' 23 | required: false 24 | default: '' 25 | custom-issue-number-regexp: 26 | description: ' Custom regexp to extract issue number from branch name.' 27 | required: false 28 | default: '' 29 | fail-when-jira-issue-not-found: 30 | description: ' Mark the PR status check as failed when a jira issue is not found' 31 | required: false 32 | default: 'false' 33 | outputs: 34 | jira-issue-key: 35 | description: 'The JIRA issue key. If key is not found the value is an empty string' 36 | jira-issue-found: 37 | description: 'Indication whether a jira issue was found or not' 38 | jira-issue-source: 39 | description: 'Indication how the jira issue was found, by - branch | pr-title | null' 40 | runs: 41 | using: 'node20' 42 | main: 'lib/index.js' 43 | branding: 44 | icon: 'terminal' 45 | color: 'blue' 46 | -------------------------------------------------------------------------------- /coc.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of the level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at victorfelder at gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | 45 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 46 | version 1.3.0, available at https://contributor-covenant.org/version/1/3/0/ 47 | 48 | [homepage]: https://contributor-covenant.org 49 | -------------------------------------------------------------------------------- /illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cakeinpanic/jira-description-action/f24c12158ee2d350a6f8c3cbe90a55af7a859ff6/illustration.png -------------------------------------------------------------------------------- /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: false, 11 | collectCoverage: true, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-description-action", 3 | "version": "0.7.0", 4 | "description": "Add JIRA issue details to your GitHub pull request", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "engines-ok && ncc build src/main.ts -o lib -m", 8 | "test": "jest", 9 | "test:watch": "jest --watch", 10 | "prettier": "prettier --write '**/*.ts'", 11 | "release": "release-it" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cakeinpanic/jira-description-action" 16 | }, 17 | "engines": { 18 | "node": ">= 20" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "github-actions", 23 | "pr-title", 24 | "node", 25 | "setup", 26 | "github", 27 | "jira-summary", 28 | "jira", 29 | "jira-issue" 30 | ], 31 | "author": "cakeinpanic", 32 | "license": "MIT", 33 | "husky": { 34 | "hooks": { 35 | "pre-commit": "lint-staged" 36 | } 37 | }, 38 | "lint-staged": { 39 | "*.ts": [ 40 | "prettier --write" 41 | ] 42 | }, 43 | "dependencies": { 44 | "@actions/core": "^1.10.1", 45 | "@actions/github": "^5.1.1", 46 | "@types/jest": "^25.2.3", 47 | "@types/node": "^13.13.30", 48 | "@vercel/ncc": "^0.38.1", 49 | "axios": "^0.19.2", 50 | "engines-ok": "^1.2.0", 51 | "jest": "^25.5.4", 52 | "jest-circus": "^25.5.4", 53 | "lint-staged": "^10.5.1", 54 | "release-it": "^16.2.1", 55 | "ts-jest": "^25.5.1", 56 | "typescript": "^3.9.7", 57 | "@release-it/conventional-changelog": "^7.0.2" 58 | }, 59 | "devDependencies": { 60 | "husky": "^4.3.0", 61 | "prettier": "^2.1.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/action-inputs.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import { ESource, IActionInputs } from './types'; 3 | 4 | export const getInputs = (): IActionInputs => { 5 | const JIRA_TOKEN: string = core.getInput('jira-token', { required: true }); 6 | const JIRA_BASE_URL: string = core.getInput('jira-base-url', { required: true }); 7 | const GITHUB_TOKEN: string = core.getInput('github-token', { required: true }); 8 | const BRANCH_IGNORE_PATTERN: string = core.getInput('skip-branches', { required: false }) || ''; 9 | const CUSTOM_ISSUE_NUMBER_REGEXP = core.getInput('custom-issue-number-regexp', { required: false }); 10 | const JIRA_PROJECT_KEY = core.getInput('jira-project-key', { required: false }); 11 | const FAIL_WHEN_JIRA_ISSUE_NOT_FOUND = core.getInput('fail-when-jira-issue-not-found', { required: false }) === 'true' || false; 12 | const WHAT_TO_USE: ESource = (core.getInput('use', { required: false }) as ESource) || ESource.prTitle; 13 | return { 14 | JIRA_TOKEN, 15 | GITHUB_TOKEN, 16 | WHAT_TO_USE, 17 | BRANCH_IGNORE_PATTERN, 18 | JIRA_PROJECT_KEY, 19 | CUSTOM_ISSUE_NUMBER_REGEXP, 20 | FAIL_WHEN_JIRA_ISSUE_NOT_FOUND, 21 | JIRA_BASE_URL: JIRA_BASE_URL.endsWith('/') ? JIRA_BASE_URL.replace(/\/$/, '') : JIRA_BASE_URL, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const HIDDEN_MARKER_END = ''; 2 | export const HIDDEN_MARKER_START = ''; 3 | 4 | export const WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS = ''; 5 | 6 | export const BOT_BRANCH_PATTERNS: RegExp[] = [/^dependabot/]; 7 | 8 | export const DEFAULT_BRANCH_PATTERNS: RegExp[] = [/^master$/, /^production$/, /^gh-pages$/]; 9 | 10 | export const JIRA_REGEX_MATCHER = /([a-zA-Z0-9]{1,10}-\d+)/g; 11 | -------------------------------------------------------------------------------- /src/github-connector.ts: -------------------------------------------------------------------------------- 1 | import { context, getOctokit } from '@actions/github'; 2 | import { GitHub } from '@actions/github/lib/utils'; 3 | import { RestEndpointMethodTypes } from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/parameters-and-response-types'; 4 | import { getInputs } from './action-inputs'; 5 | import { ESource, IGithubData, JIRADetails, PullRequestParams } from './types'; 6 | import { buildPRDescription, getJIRAIssueKeyByDefaultRegexp, getJIRAIssueKeysByCustomRegexp, getPRDescription } from './utils'; 7 | 8 | export class GithubConnector { 9 | githubData: IGithubData = {} as IGithubData; 10 | octokit: InstanceType; 11 | 12 | constructor() { 13 | const { GITHUB_TOKEN } = getInputs(); 14 | 15 | this.octokit = getOctokit(GITHUB_TOKEN); 16 | 17 | this.githubData = this.getGithubData(); 18 | } 19 | 20 | get isPRAction(): boolean { 21 | return this.githubData.eventName === 'pull_request' || this.githubData.eventName === 'pull_request_target'; 22 | } 23 | 24 | get headBranch(): string { 25 | return this.githubData.pullRequest.head.ref; 26 | } 27 | 28 | getIssueKeyFromTitle(): { key: string; source: ESource } { 29 | const { WHAT_TO_USE } = getInputs(); 30 | 31 | const prTitle = this.githubData.pullRequest.title || ''; 32 | const branchName = this.headBranch; 33 | 34 | let keyFound: string | null = null; 35 | let source: ESource | null = null; 36 | 37 | switch (WHAT_TO_USE) { 38 | case ESource.branch: 39 | keyFound = this.getIssueKeyFromString(branchName); 40 | source = keyFound ? ESource.branch : null; 41 | break; 42 | case ESource.prTitle: 43 | keyFound = this.getIssueKeyFromString(prTitle); 44 | source = keyFound ? ESource.prTitle : null; 45 | break; 46 | case ESource.both: 47 | const keyByPRTitle = this.getIssueKeyFromString(prTitle); 48 | if (keyByPRTitle) { 49 | keyFound = keyByPRTitle; 50 | source = ESource.prTitle; 51 | } else { 52 | keyFound = this.getIssueKeyFromString(branchName); 53 | source = keyFound ? ESource.branch : null; 54 | } 55 | break; 56 | } 57 | 58 | if (!keyFound || !source) { 59 | throw new Error('JIRA key not found'); 60 | } 61 | console.log(`JIRA key found -> ${keyFound} from ${source}`); 62 | return { key: keyFound, source }; 63 | } 64 | 65 | private getIssueKeyFromString(stringToParse: string): string | null { 66 | const { JIRA_PROJECT_KEY, CUSTOM_ISSUE_NUMBER_REGEXP } = getInputs(); 67 | const shouldUseCustomRegexp = !!CUSTOM_ISSUE_NUMBER_REGEXP; 68 | 69 | console.log(`looking in: ${stringToParse}`); 70 | 71 | return shouldUseCustomRegexp 72 | ? getJIRAIssueKeysByCustomRegexp(stringToParse, CUSTOM_ISSUE_NUMBER_REGEXP, JIRA_PROJECT_KEY) 73 | : getJIRAIssueKeyByDefaultRegexp(stringToParse); 74 | } 75 | 76 | async updatePrDetails(details: JIRADetails) { 77 | const owner = this.githubData.owner; 78 | const repo = this.githubData.repository.name; 79 | console.log('Updating PR details'); 80 | const { number: prNumber = 0 } = this.githubData.pullRequest; 81 | const recentBody = await this.getLatestPRDescription({ repo, owner, number: this.githubData.pullRequest.number }); 82 | 83 | const prData: RestEndpointMethodTypes['pulls']['update']['parameters'] = { 84 | owner, 85 | repo, 86 | pull_number: prNumber, 87 | body: getPRDescription(recentBody, buildPRDescription(details)), 88 | }; 89 | 90 | return await this.octokit.rest.pulls.update(prData); 91 | } 92 | 93 | // PR description may have been updated by some other action in the same job, need to re-fetch it to get the latest 94 | async getLatestPRDescription({ owner, repo, number }: { owner: string; repo: string; number: number }): Promise { 95 | return this.octokit.rest.pulls 96 | .get({ 97 | owner, 98 | repo, 99 | pull_number: number, 100 | }) 101 | .then(({ data }: RestEndpointMethodTypes['pulls']['get']['response']) => { 102 | return data.body || ''; 103 | }); 104 | } 105 | 106 | private getGithubData(): IGithubData { 107 | const { 108 | eventName, 109 | payload: { repository, pull_request: pullRequest }, 110 | } = context; 111 | 112 | let owner: IGithubData['owner'] | undefined; 113 | 114 | if (context?.payload?.organization) { 115 | owner = context?.payload?.organization?.login; 116 | } else { 117 | console.log('Could not find organization, using repository owner instead.'); 118 | owner = context.payload.repository?.owner.login; 119 | } 120 | 121 | if (!owner) { 122 | throw new Error('Could not find owner.'); 123 | } 124 | 125 | return { 126 | eventName, 127 | repository, 128 | owner, 129 | pullRequest: pullRequest as PullRequestParams, 130 | }; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/jira-connector.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios'; 2 | import { getInputs } from './action-inputs'; 3 | import { JIRA, JIRADetails } from './types'; 4 | 5 | export class JiraConnector { 6 | client: AxiosInstance; 7 | JIRA_TOKEN: string; 8 | JIRA_BASE_URL: string; 9 | 10 | constructor() { 11 | const { JIRA_TOKEN, JIRA_BASE_URL } = getInputs(); 12 | 13 | this.JIRA_BASE_URL = JIRA_BASE_URL; 14 | this.JIRA_TOKEN = JIRA_TOKEN; 15 | 16 | const encodedToken = Buffer.from(JIRA_TOKEN).toString('base64'); 17 | 18 | this.client = axios.create({ 19 | baseURL: `${JIRA_BASE_URL}/rest/api/3`, 20 | timeout: 2000, 21 | headers: { Authorization: `Basic ${encodedToken}` }, 22 | }); 23 | } 24 | 25 | async getTicketDetails(key: string): Promise { 26 | console.log(`Fetching ${key} details from JIRA`); 27 | 28 | try { 29 | const issue: JIRA.Issue = await this.getIssue(key); 30 | const { 31 | fields: { issuetype: type, project, summary }, 32 | } = issue; 33 | 34 | return { 35 | key, 36 | summary, 37 | url: `${this.JIRA_BASE_URL}/browse/${key}`, 38 | type: { 39 | name: type.name, 40 | icon: type.iconUrl, 41 | }, 42 | project: { 43 | name: project.name, 44 | url: `${this.JIRA_BASE_URL}/browse/${project.key}`, 45 | key: project.key, 46 | }, 47 | }; 48 | } catch (error) { 49 | console.log( 50 | 'Error fetching details from JIRA. Please check if token you provide is built correctly & API key has all needed permissions. https://github.com/cakeinpanic/jira-description-action#jira-token' 51 | ); 52 | if (error.response) { 53 | throw new Error(JSON.stringify(error.response.data, null, 4)); 54 | } 55 | throw error; 56 | } 57 | } 58 | 59 | async getIssue(id: string): Promise { 60 | const url = `/issue/${id}?fields=project,summary,issuetype`; 61 | const response = await this.client.get(url); 62 | return response.data; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import { ESource } from './types'; 3 | import { shouldSkipBranch } from './utils'; 4 | import { getInputs } from './action-inputs'; 5 | import { GithubConnector } from './github-connector'; 6 | import { JiraConnector } from './jira-connector'; 7 | 8 | async function run(): Promise { 9 | const { FAIL_WHEN_JIRA_ISSUE_NOT_FOUND } = getInputs(); 10 | 11 | try { 12 | const { BRANCH_IGNORE_PATTERN } = getInputs(); 13 | 14 | const githubConnector = new GithubConnector(); 15 | const jiraConnector = new JiraConnector(); 16 | 17 | if (!githubConnector.isPRAction) { 18 | console.log('This action meant to be run only on PRs'); 19 | setOutputs(null, null); 20 | process.exit(0); 21 | } 22 | 23 | if (shouldSkipBranch(githubConnector.headBranch, BRANCH_IGNORE_PATTERN)) { 24 | setOutputs(null, null); 25 | process.exit(0); 26 | } 27 | 28 | const { key, source } = githubConnector.getIssueKeyFromTitle(); 29 | 30 | const details = await jiraConnector.getTicketDetails(key); 31 | await githubConnector.updatePrDetails(details); 32 | 33 | setOutputs(key, source); 34 | } catch (error) { 35 | console.log('Failed to add JIRA description to PR.'); 36 | core.error(error.message); 37 | setOutputs(null, null); 38 | if (FAIL_WHEN_JIRA_ISSUE_NOT_FOUND) { 39 | core.setFailed(error.message); 40 | process.exit(1); 41 | } else { 42 | process.exit(0); 43 | } 44 | } 45 | } 46 | 47 | function setOutputs(key: string | null, source: ESource | null): void { 48 | var isFound = key !== null; 49 | core.setOutput('jira-issue-key', key); 50 | core.setOutput('jira-issue-found', isFound); 51 | core.setOutput('jira-issue-source', source || 'null'); 52 | } 53 | 54 | run(); 55 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum ESource { 2 | branch = 'branch', 3 | prTitle = 'pr-title', 4 | both = 'both', 5 | } 6 | 7 | export interface PullRequestParams { 8 | number: number; 9 | html_url?: string; 10 | body?: string; 11 | base: { 12 | ref: string; 13 | }; 14 | head: { 15 | ref: string; 16 | }; 17 | changed_files?: number; 18 | additions?: number; 19 | title?: string; 20 | 21 | [key: string]: unknown; 22 | } 23 | 24 | export namespace JIRA { 25 | export interface IssueStatus { 26 | self: string; 27 | description: string; 28 | iconUrl: string; 29 | name: string; 30 | id: string; 31 | statusCategory: { 32 | self: string; 33 | id: number; 34 | key: string; 35 | colorName: string; 36 | name: string; 37 | }; 38 | } 39 | 40 | export interface IssuePriority { 41 | self: string; 42 | iconUrl: string; 43 | name: string; 44 | id: string; 45 | } 46 | 47 | export interface IssueType { 48 | self: string; 49 | id: string; 50 | description: string; 51 | iconUrl: string; 52 | name: string; 53 | subtask: boolean; 54 | avatarId: number; 55 | } 56 | 57 | export interface IssueProject { 58 | self: string; 59 | key: string; 60 | name: string; 61 | } 62 | 63 | export interface Issue { 64 | id: string; 65 | key: string; 66 | self: string; 67 | fields: { 68 | summary: string; 69 | status: IssueStatus; 70 | priority: IssuePriority; 71 | issuetype: IssueType; 72 | project: IssueProject; 73 | labels: string[]; 74 | [k: string]: unknown; 75 | }; 76 | } 77 | } 78 | 79 | export interface JIRADetails { 80 | key: string; 81 | summary: string; 82 | url: string; 83 | type: { 84 | name: string; 85 | icon: string; 86 | }; 87 | project: { 88 | name: string; 89 | url: string; 90 | key: string; 91 | }; 92 | } 93 | 94 | export interface IActionInputs { 95 | JIRA_TOKEN: string; 96 | WHAT_TO_USE: ESource; 97 | JIRA_BASE_URL: string; 98 | GITHUB_TOKEN: string; 99 | BRANCH_IGNORE_PATTERN: string; 100 | JIRA_PROJECT_KEY: string; 101 | CUSTOM_ISSUE_NUMBER_REGEXP: string; 102 | FAIL_WHEN_JIRA_ISSUE_NOT_FOUND: boolean; 103 | } 104 | 105 | export interface IGithubData { 106 | eventName: string; 107 | repository: any; 108 | owner: string; 109 | pullRequest: PullRequestParams; 110 | } 111 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BOT_BRANCH_PATTERNS, 3 | DEFAULT_BRANCH_PATTERNS, 4 | HIDDEN_MARKER_END, 5 | HIDDEN_MARKER_START, 6 | JIRA_REGEX_MATCHER, 7 | WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS, 8 | } from './constants'; 9 | import { JIRADetails } from './types'; 10 | 11 | const getJIRAIssueKey = (input: string, regexp: RegExp = JIRA_REGEX_MATCHER): string | null => { 12 | const matches = regexp.exec(input); 13 | return matches ? matches[matches.length - 1] : null; 14 | }; 15 | 16 | export const getJIRAIssueKeyByDefaultRegexp = (input: string): string | null => { 17 | const key = getJIRAIssueKey(input, new RegExp(JIRA_REGEX_MATCHER)); 18 | return key ? key.toUpperCase() : null; 19 | }; 20 | 21 | export const getJIRAIssueKeysByCustomRegexp = (input: string, numberRegexp: string, projectKey?: string): string | null => { 22 | const customRegexp = new RegExp(numberRegexp, 'gi'); 23 | 24 | const ticketNumber = getJIRAIssueKey(input, customRegexp); 25 | if (!ticketNumber) { 26 | return null; 27 | } 28 | const key = projectKey ? `${projectKey}-${ticketNumber}` : ticketNumber; 29 | return key.toUpperCase(); 30 | }; 31 | 32 | export const shouldSkipBranch = (branch: string, additionalIgnorePattern?: string): boolean => { 33 | if (BOT_BRANCH_PATTERNS.some((pattern) => pattern.test(branch))) { 34 | console.log(`You look like a bot 🤖 so we're letting you off the hook!`); 35 | return true; 36 | } 37 | 38 | if (DEFAULT_BRANCH_PATTERNS.some((pattern) => pattern.test(branch))) { 39 | console.log(`Ignoring check for default branch ${branch}`); 40 | return true; 41 | } 42 | 43 | const ignorePattern = new RegExp(additionalIgnorePattern || ''); 44 | if (!!additionalIgnorePattern && ignorePattern.test(branch)) { 45 | console.log(`branch '${branch}' ignored as it matches the ignore pattern '${additionalIgnorePattern}' provided in skip-branches`); 46 | return true; 47 | } 48 | 49 | return false; 50 | }; 51 | 52 | const escapeRegexp = (str: string): string => { 53 | return str.replace(/[\\^$.|?*+(<>)[{]/g, '\\$&'); 54 | }; 55 | 56 | export const getPRDescription = (oldBody: string, details: string): string => { 57 | const hiddenMarkerStartRg = escapeRegexp(HIDDEN_MARKER_START); 58 | const hiddenMarkerEndRg = escapeRegexp(HIDDEN_MARKER_END); 59 | const warningMsgRg = escapeRegexp(WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS); 60 | 61 | const replaceDetailsRg = new RegExp(`${hiddenMarkerStartRg}([\\s\\S]+)${hiddenMarkerEndRg}[\\s]?`, 'igm'); 62 | const replaceWarningMessageRg = new RegExp(`${warningMsgRg}[\\s]?`, 'igm'); 63 | const jiraDetailsMessage = `${WARNING_MESSAGE_ABOUT_HIDDEN_MARKERS} 64 | ${HIDDEN_MARKER_START} 65 | ${details} 66 | ${HIDDEN_MARKER_END} 67 | `; 68 | if (replaceDetailsRg.test(oldBody)) { 69 | return (oldBody ?? '').replace(replaceWarningMessageRg, '').replace(replaceDetailsRg, jiraDetailsMessage); 70 | } 71 | return jiraDetailsMessage + oldBody; 72 | }; 73 | 74 | export const buildPRDescription = (details: JIRADetails) => { 75 | const displayKey = details.key.toUpperCase(); 76 | return `
77 | ${details.type.name} ${displayKey} 78 | ${details.summary} 79 |
`; 80 | }; 81 | -------------------------------------------------------------------------------- /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": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "strictNullChecks": true, /* Enable strict null checks. */ 10 | 11 | /* Additional Checks */ 12 | "noUnusedLocals": true, /* Report errors on unused locals. */ 13 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 14 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 15 | 16 | /* Module Resolution Options */ 17 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 18 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 19 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 20 | "allowSyntheticDefaultImports": true 21 | }, 22 | "exclude": ["node_modules", "**/*.test.ts"] 23 | } 24 | --------------------------------------------------------------------------------