├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml ├── label-and-assign.yml ├── pull_request_template.md └── workflows │ ├── automation.yml │ ├── build_test.yml │ └── check-dist.yml ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── __mocks__ └── @actions │ └── github.ts ├── __tests__ ├── github_client.test.ts ├── jira_client.test.ts └── updater.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── common │ ├── github_client.ts │ ├── jira_client.ts │ └── updater.ts └── main.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | .licenses/** -diff linguist-generated=true 3 | 4 | # don't diff machine generated files 5 | dist/index.js -diff 6 | package-lock.json -diff 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global 2 | * @pieterclaerhout 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | assignees: 8 | - "pieterclaerhout" 9 | groups: 10 | actions-deps: 11 | patterns: 12 | - "*" 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | target-branch: "main" 17 | schedule: 18 | interval: "weekly" 19 | assignees: 20 | - "pieterclaerhout" 21 | -------------------------------------------------------------------------------- /.github/label-and-assign.yml: -------------------------------------------------------------------------------- 1 | labels: 2 | dependencies: 3 | - package.json 4 | - package-lock.json 5 | 6 | github_actions: 7 | - '.github/**/*' 8 | 9 | typescript: 10 | - ./**/*.ts 11 | 12 | assign: 13 | dependencies: 14 | - pieterclaerhout 15 | 16 | github_actions: 17 | - pieterclaerhout 18 | 19 | typescript: 20 | - pieterclaerhout 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Added 2 | * For new features 3 | ### Changed 4 | * For changes in existing functionality 5 | ### Deprecated 6 | * For soon-to-be removed features 7 | ### Removed 8 | * For now removed features 9 | ### Fixed 10 | * For any bug fixes 11 | ### Security 12 | * In case of vulnerabilities 13 | 14 | --- 15 | 16 | References # 17 | -------------------------------------------------------------------------------- /.github/workflows/automation.yml: -------------------------------------------------------------------------------- 1 | name: Automation 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - ready_for_review 8 | - reopened 9 | - synchronize 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | 16 | jobs: 17 | automation: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Add Jira issue type as label 23 | uses: ./ 24 | if: ${{ github.actor != 'dependabot[bot]' }} 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | jira-base-url: ${{ secrets.JIRA_BASE_URL }} 28 | jira-username: ${{ secrets.JIRA_USERNAME }} 29 | jira-token: ${{ secrets.JIRA_ACCESS_TOKEN }} 30 | jira-project-key: CTR 31 | 32 | - uses: toshimaru/auto-author-assign@v2.1.1 33 | with: 34 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 35 | -------------------------------------------------------------------------------- /.github/workflows/build_test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '**.md' 7 | push: 8 | branches: 9 | - main 10 | - releases/* 11 | paths-ignore: 12 | - '**.md' 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup node 23 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 23.x 24 | - run: npm install 25 | - run: npm run build 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /.github/workflows/check-dist.yml: -------------------------------------------------------------------------------- 1 | name: Check Dist 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | pull_request: 10 | paths-ignore: 11 | - '**.md' 12 | workflow_dispatch: 13 | 14 | jobs: 15 | check-dist: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set Node.js 23.x 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 23.x 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Rebuild the dist/ directory 30 | run: npm run build 31 | 32 | - name: Compare the expected and actual dist/ directories 33 | run: | 34 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then 35 | echo "Detected uncommitted changes after build. See status below:" 36 | git diff 37 | exit 1 38 | fi 39 | id: diff 40 | 41 | # If index.js was different than expected, upload the expected version as an artifact 42 | - uses: actions/upload-artifact@v4 43 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 44 | with: 45 | name: dist 46 | path: dist/ 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | lib/ 4 | .idea 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "emeraldwalk.runonsave": { 3 | "commands": [ 4 | { 5 | "match": "\\.(ts|js)$", 6 | "cmd": "${workspaceFolder}/node_modules/.bin/prettier --write ${file}", 7 | "silent": true, 8 | }, 9 | ] 10 | }, 11 | "task.allowAutomaticTasks": "on", 12 | "search.exclude": { 13 | "**/node_modules": true, 14 | "**/bower_components": true, 15 | "**/dist": true, 16 | "**/*.code-search": true 17 | }, 18 | "files.watcherExclude": { 19 | "**/dist/**": true 20 | }, 21 | "files.exclude": { 22 | "**/dist": true 23 | }, 24 | "editor.trimAutoWhitespace": false 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "format-check", 7 | "problemMatcher": [ 8 | "$eslint-stylish" 9 | ], 10 | "label": "npm: format-check", 11 | "detail": "prettier --check \"**/*.ts\"" 12 | }, 13 | { 14 | "type": "npm", 15 | "script": "format", 16 | "problemMatcher": [ 17 | "$eslint-stylish" 18 | ], 19 | "label": "npm: format", 20 | "detail": "prettier --write \"**/*.ts\"" 21 | }, 22 | { 23 | "type": "npm", 24 | "script": "test", 25 | "problemMatcher": [], 26 | "label": "npm: tests", 27 | "detail": "npm run test -- --silent=false" 28 | }, 29 | { 30 | "type": "shell", 31 | "problemMatcher": [], 32 | "label": "git: update tag", 33 | "detail": "git tag -fa v1 -m 'Update v1 tag' && git push origin v1 --force", 34 | "command": "git tag -fa v1 -m 'Update v1 tag' && git push origin v1 --force" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2022 Contractify 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. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Add Jira info to a pull request 2 | 3 | At [Contractify](https://contractify.io), we like to keeps things nice, tidy and 4 | organized. We are using [Jira](https://www.atlassian.com/nl/software/jira) for 5 | our issue management and [GitHub](https://www.github.com) for our version control. 6 | 7 | Since we want to have more context with our pull requests, we decided to create 8 | a [GitHub action](https://github.com/features/actions) which helps us in adding 9 | Jira data to the pull request. 10 | 11 | The current version allows you to: 12 | 13 | - Detect Jira key from the branch name 14 | - Add the Jira key as prefix to the pull request title if not present 15 | - Add the Jira key and issue title to the pull request body if not present 16 | - Assign a label to the pull request with the Jira issue type 17 | 18 | This makes it less labour intensive for a developer to create pull requests and 19 | adds more information for the people who need to e.g. review the requests. 20 | 21 | ## Sample action setup 22 | 23 | To get started, you will need to create a GitHub action workflow file. If you 24 | need more information on how to set that up, check 25 | [here](https://docs.github.com/en/actions/quickstart). 26 | 27 | In our repositories, we keep these actions in a separate workflow, so we usually 28 | add a file called `.github/workflows/automation.yml` to our repository and put 29 | the following content in there: 30 | 31 | ```yaml 32 | name: Automation 33 | 34 | on: 35 | pull_request: 36 | types: 37 | - opened 38 | - ready_for_review 39 | - reopened 40 | - synchronize 41 | workflow_dispatch: 42 | 43 | permissions: 44 | contents: read 45 | pull-requests: write 46 | 47 | jobs: 48 | automation: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Add Jira info 52 | uses: contractify/add-jira-info@v1 53 | with: 54 | github-token: ${{ secrets.GITHUB_TOKEN }} 55 | jira-base-url: ${{ secrets.JIRA_BASE_URL }} 56 | jira-username: ${{ secrets.JIRA_USERNAME }} 57 | jira-token: ${{ secrets.JIRA_TOKEN }} 58 | jira-project-key: PRJ 59 | add-label-with-issue-type: true 60 | issue-type-label-color: FBCA04 61 | issue-type-label-description: "Jira Issue Type" 62 | add-jira-key-to-title: true 63 | add-jira-key-to-body: true 64 | add-jira-fix-versions-to-body: true 65 | ``` 66 | 67 | The `on:` section defines when the workflow needs to run. We usually run them 68 | on everything that has to do with a pull request. We also use 69 | `workflow_dispatch` to allow us to manually trigger the workflow. 70 | 71 | The only step which is there is the one that adds the Jira info. 72 | 73 | We strongly suggest to store the sensitive configuration parameters as 74 | [secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). 75 | 76 | ## Inputs 77 | 78 | Various inputs are defined in [`action.yml`](action.yml) to let you configure the action: 79 | 80 | | Name | Description | Required | Default | 81 | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------- | 82 | | `github-token` | Token to use to authorize label changes. Typically the GITHUB_TOKEN secret, with `contents:read` and `pull-requests:write` access | N/A | 83 | | `jira-base-url` | The subdomain of JIRA cloud that you use to access it. Ex: "https://your-domain.atlassian.net". | `true` | `null` | 84 | | `jira-username` | Username used to fetch Jira Issue information. Check [below](#how-to-get-the-jira-token-and-jira-username) for more details on how to generate the token. | `true` | `null` | 85 | | `jira-token` | Token used to fetch Jira Issue information. Check [below](#how-to-get-the-jira-token-and-jira-username) for more details on how to generate the token. | `true` | `null` | 86 | | `jira-project-key` | Key of project in jira. First part of issue key | `true` | `null` | 87 | | `add-label-with-issue-type` | If set to `true`, a label with the issue type from Jira will be added to the pull request | `false` | `true` | 88 | | `issue-type-label-color` | The hex color to use for the issue type label | `false` | `FBCA04` | 89 | | `issue-type-label-description` | The description to use for the issue type label | `false` | `Jira Issue Type` | 90 | | `add-jira-key-to-title` | If set to `true`, the title of the pull request will be prefixed with the Jira issue key | `false` | `true` | 91 | | `add-jira-key-to-body` | If set to `true`, the body of the pull request will be suffix with a link to the Jira issue | `false` | `true` | 92 | | `add-jira-fix-versions-to-body` | If set to `true`, the body of the pull request will be suffix with the `fixVersions` from to the Jira issue | `false` | `true` | 93 | 94 | 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). 95 | 96 | ## Muliple Jira Project keys 97 | If you have muliple projects being used in the repo, you can add each poject key to the `jira-project-key` option as follows: 98 | ``` 99 | jira-project-key: |- 100 | foo 101 | bar 102 | ``` 103 | 104 | ## In Detail 105 | 106 | ### Getting the Jira key from the branch name 107 | 108 | The branch name is used to get the linked Jira key. 109 | 110 | We usually name our branches like this: 111 | 112 | ``` 113 | PRJ-1234_some-short-description 114 | ``` 115 | 116 | The following formats will also work: 117 | 118 | ``` 119 | PRJ-1234-some-short-description 120 | feature/PRJ-1234_some-short-description 121 | some-short-description_PRJ-1234 122 | feature/some-short-description_PRJ-1234 123 | feature/PRJ-1234_some-short-description 124 | ``` 125 | 126 | ### Prefix the title with the Jira key 127 | 128 | When the title of the PR doesn't contain a reference to the Jira key yet, it will 129 | automatically be prefixed to the title (if enabled). 130 | 131 | So, the following title: 132 | 133 | ``` 134 | My Pull Request Title 135 | ``` 136 | 137 | Will become: 138 | 139 | ``` 140 | PRJ-1234 | My Pull Request Title 141 | ``` 142 | 143 | ### Adding the Jira key to the body of the pull request 144 | 145 | When the Jira key isn't present in the pull request body, it will be added as a 146 | suffix including the summary of the Jira issue. 147 | 148 | It will be shown in the following format, as a hyperlink to the Jira issue: 149 | 150 | > [**PRJ-1234** | My Jira issue summary](#) 151 | 152 | ### Adding the Jira issue type as a label 153 | 154 | When enabled, you can automatically have a label added with the Jira issue type 155 | as it's name. The action will check the Jira issue key against your Jira install 156 | and extract the issue type name. 157 | 158 | Once the issue type is found, a label with the issue type in lowercase as the 159 | name will be created if not present yet. You can configure the color of the 160 | label in the settings of the action. The color will only be used for labels that 161 | don't exist yet. 162 | 163 | It will automatically be assigned to the pull request. 164 | 165 | ## How to get the `jira-token` and `jira-username` 166 | 167 | The Jira token is used to fetch issue information via the Jira REST API. To get the token: 168 | 169 | 1. Generate an [API token via JIRA](https://confluence.atlassian.com/cloud/api-tokens-938839638.html) 170 | 2. Add the Jira username to the `JIRA_USERNAME` secret in your project 171 | 3. Add the Jira API token to the `JIRA_TOKEN` secret in your project 172 | 173 | 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). 174 | 175 | ## About Contractify 176 | 177 | Contractify is a blooming Belgian SaaS scale-up offering contract management software and services. 178 | 179 | We help business leaders, legal & finance teams to 180 | 181 | - 🗄️ centralize contracts & responsibilities, even in a decentralized organization. 182 | - 📝 keep track of all contracts & related mails or documents in 1 tool 183 | - 🔔 automate & collaborate on contract follow-up tasks 184 | - ✒️ approve & sign documents safely & fast 185 | - 📊 report on custom contract data 186 | 187 | The cloud platform is easily supplemented with full contract management support, including: 188 | 189 | - ✔️ registration and follow up of your existing & new contracts 190 | - ✔️ expert advice on contract management 191 | - ✔️ periodic reporting & status updates 192 | 193 | Start automating your contract management for free with Contractify on: 194 | https://info.contractify.io/free-trial 195 | -------------------------------------------------------------------------------- /__mocks__/@actions/github.ts: -------------------------------------------------------------------------------- 1 | export const context = { 2 | payload: { 3 | pull_request: { 4 | number: 123, 5 | head: { 6 | ref: "feature/123-sample-feature", 7 | }, 8 | }, 9 | }, 10 | repo: { 11 | owner: "monalisa", 12 | repo: "helloworld", 13 | }, 14 | }; 15 | 16 | const mockApi = { 17 | rest: { 18 | issues: { 19 | addLabels: jest.fn(), 20 | removeLabel: jest.fn(), 21 | }, 22 | pulls: { 23 | get: jest.fn().mockResolvedValue({ 24 | data: { 25 | number: 123, 26 | title: "pr title", 27 | }, 28 | }), 29 | listFiles: { 30 | endpoint: { 31 | merge: jest.fn().mockReturnValue({}), 32 | }, 33 | }, 34 | }, 35 | repos: { 36 | getContent: jest.fn(), 37 | }, 38 | }, 39 | paginate: jest.fn(), 40 | }; 41 | 42 | export const getOctokit = jest.fn().mockImplementation(() => mockApi); 43 | -------------------------------------------------------------------------------- /__tests__/github_client.test.ts: -------------------------------------------------------------------------------- 1 | import { GithubClient } from "../src/common/github_client"; 2 | 3 | describe("basics", () => { 4 | it("constructs", () => { 5 | const client = new GithubClient("token"); 6 | expect(client).toBeDefined(); 7 | }); 8 | }); 9 | 10 | describe("extract jira key", () => { 11 | let client: GithubClient; 12 | 13 | beforeEach(() => { 14 | client = new GithubClient("token"); 15 | }); 16 | 17 | it("gets the pr branch name from the context", () => { 18 | const branchName = client.getBranchName(); 19 | expect(branchName).toBe("feature/123-sample-feature"); 20 | }); 21 | 22 | it("gets a pull request", async () => { 23 | const pullRequest = await client.getPullRequest(); 24 | expect(pullRequest).toBeDefined(); 25 | expect(pullRequest?.number).toBe(123); 26 | expect(pullRequest?.title).toBe("pr title"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /__tests__/jira_client.test.ts: -------------------------------------------------------------------------------- 1 | import { JiraClient, JiraKey } from "../src/common/jira_client"; 2 | import nock from "nock"; 3 | 4 | describe("basics", () => { 5 | it("constructs", () => { 6 | const client = new JiraClient("base-url", "username", "token", "PRJ"); 7 | expect(client).toBeDefined(); 8 | }); 9 | }); 10 | 11 | describe("get jira issue type", () => { 12 | let client: JiraClient; 13 | 14 | beforeEach(() => { 15 | client = new JiraClient("https://base-url", "username", "token", "PRJ"); 16 | }); 17 | 18 | it("gets the issue type of a jira issue", async () => { 19 | const response = { 20 | fields: { 21 | issuetype: { 22 | name: "Story", 23 | }, 24 | summary: "My Issue", 25 | }, 26 | }; 27 | 28 | nock("https://base-url") 29 | .get("/rest/api/3/issue/PRJ-123?fields=issuetype,summary,fixVersions") 30 | .reply(200, () => response); 31 | 32 | const issue = await client.getIssue(new JiraKey("PRJ", "123")); 33 | expect(issue?.type).toBe("story"); 34 | }); 35 | }); 36 | 37 | describe("get jira issue fixVersions", () => { 38 | let client: JiraClient; 39 | 40 | beforeEach(() => { 41 | client = new JiraClient("https://base-url", "username", "token", "PRJ"); 42 | }); 43 | 44 | it("gets the fixVersions property of a jira issue", async () => { 45 | const response = { 46 | fields: { 47 | issuetype: { 48 | name: "Story", 49 | }, 50 | summary: "My Issue", 51 | fixVersions: [ 52 | { 53 | description: "", 54 | name: "v1.0.0", 55 | archived: false, 56 | released: false, 57 | releaseDate: "2023-10-31", 58 | }, 59 | ], 60 | }, 61 | }; 62 | 63 | nock("https://base-url") 64 | .get("/rest/api/3/issue/PRJ-123?fields=issuetype,summary,fixVersions") 65 | .reply(200, () => response); 66 | 67 | const issue = await client.getIssue(new JiraKey("PRJ", "123")); 68 | expect(issue?.type).toBe("story"); 69 | expect(issue?.fixVersions![0]).toBe("v1.0.0"); 70 | }); 71 | }); 72 | 73 | describe("extract jira key", () => { 74 | let client: JiraClient; 75 | 76 | beforeEach(() => { 77 | client = new JiraClient("base-url", "username", "token", "PRJ"); 78 | }); 79 | 80 | it("extracts the jira key if present", async () => { 81 | const jiraKey = await client.extractJiraKey( 82 | "PRJ-3721_actions-workflow-improvements", 83 | ); 84 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 85 | }); 86 | 87 | it("extracts the jira key if present without underscore", async () => { 88 | const jiraKey = await client.extractJiraKey( 89 | "PRJ-3721-actions-workflow-improvements", 90 | ); 91 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 92 | }); 93 | 94 | it("extracts the jira key from a feature branch if present", async () => { 95 | const jiraKey = await client.extractJiraKey( 96 | "feature/PRJ-3721_actions-workflow-improvements", 97 | ); 98 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 99 | }); 100 | 101 | it("extracts the jira key case insensitive", async () => { 102 | const jiraKey = await client.extractJiraKey( 103 | "PRJ-3721_actions-workflow-improvements", 104 | ); 105 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 106 | }); 107 | 108 | it("returns undefined if not present", async () => { 109 | const jiraKey = await client.extractJiraKey( 110 | "prj3721_actions-workflow-improvements", 111 | ); 112 | expect(jiraKey).toBeUndefined(); 113 | }); 114 | }); 115 | 116 | describe("extract jira key when given multiple keys", () => { 117 | let client: JiraClient; 118 | 119 | beforeEach(() => { 120 | client = new JiraClient("base-url", "username", "token", "PRJ\n FOO\n BAR\n"); 121 | }); 122 | 123 | it("extracts the jira key if present", async () => { 124 | const jiraKey = await client.extractJiraKey( 125 | "PRJ-3721_actions-workflow-improvements", 126 | ); 127 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 128 | }); 129 | 130 | it("extracts the jira key if present", async () => { 131 | const jiraKey = await client.extractJiraKey( 132 | "FOO-3721_actions-workflow-improvements", 133 | ); 134 | expect(jiraKey?.toString()).toBe("FOO-3721"); 135 | }); 136 | 137 | it("extracts the jira key if present without underscore", async () => { 138 | const jiraKey = await client.extractJiraKey( 139 | "PRJ-3721-actions-workflow-improvements", 140 | ); 141 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 142 | }); 143 | 144 | it("extracts the jira key from a feature branch if present", async () => { 145 | const jiraKey = await client.extractJiraKey( 146 | "feature/BAR-3721_actions-workflow-improvements", 147 | ); 148 | expect(jiraKey?.toString()).toBe("BAR-3721"); 149 | }); 150 | 151 | it("extracts the jira key case insensitive", async () => { 152 | const jiraKey = await client.extractJiraKey( 153 | "PRJ-3721_actions-workflow-improvements", 154 | ); 155 | expect(jiraKey?.toString()).toBe("PRJ-3721"); 156 | }); 157 | 158 | it("returns undefined if not present", async () => { 159 | const jiraKey = await client.extractJiraKey( 160 | "prj3721_actions-workflow-improvements", 161 | ); 162 | expect(jiraKey).toBeUndefined(); 163 | }); 164 | 165 | }); 166 | -------------------------------------------------------------------------------- /__tests__/updater.test.ts: -------------------------------------------------------------------------------- 1 | import { JiraIssue, JiraKey } from "../src/common/jira_client"; 2 | import { Updater } from "../src/common/updater"; 3 | 4 | describe("title", () => { 5 | let updater: Updater; 6 | 7 | beforeEach(() => { 8 | const jiraKey = new JiraKey("PRJ", "1234"); 9 | const jiraIssue = new JiraIssue(jiraKey, "http://jira", "title", "story"); 10 | updater = new Updater(jiraIssue); 11 | }); 12 | 13 | it("adds the jira number to the title if not present", () => { 14 | const title = "My pull request title"; 15 | 16 | const actual = updater.title(title); 17 | expect(actual).toBe("PRJ-1234 | My pull request title"); 18 | }); 19 | 20 | it("fixes the jira number if present incorrect case", () => { 21 | const title = "Prj 1234 protect web app with a login screen"; 22 | 23 | const actual = updater.title(title); 24 | expect(actual).toBe("PRJ-1234 | protect web app with a login screen"); 25 | }); 26 | 27 | it("fixes the jira number if present case correct case", () => { 28 | const title = "PRJ 1234 protect web app with a login screen"; 29 | 30 | const actual = updater.title(title); 31 | expect(actual).toBe("PRJ-1234 | protect web app with a login screen"); 32 | }); 33 | 34 | it("fixes the jira number if present incorrect case with pipe symbol", () => { 35 | const title = "Prj 1234 | protect web app with a login screen"; 36 | 37 | const actual = updater.title(title); 38 | expect(actual).toBe("PRJ-1234 | protect web app with a login screen"); 39 | }); 40 | 41 | it("fixes the missing pipe symbol", () => { 42 | const title = "PRJ-1234 My pull request title"; 43 | 44 | const actual = updater.title(title); 45 | expect(actual).toBe("PRJ-1234 | My pull request title"); 46 | }); 47 | 48 | it("does nothing when the jira number is already present", () => { 49 | const title = "PRJ-1234 | My pull request title"; 50 | 51 | const actual = updater.title(title); 52 | expect(actual).toBe("PRJ-1234 | My pull request title"); 53 | }); 54 | 55 | it("updates if the jira key is at the end of the title", () => { 56 | const title = "My pull request title | PRJ-1234"; 57 | 58 | const actual = updater.title(title); 59 | expect(actual).toBe("PRJ-1234 | My pull request title"); 60 | }); 61 | 62 | it("does not replace the key in the middle of the title", () => { 63 | const title = "PRJ-1234 | My pull request PRJ-1234 title"; 64 | 65 | const actual = updater.title(title); 66 | expect(actual).toBe("PRJ-1234 | My pull request PRJ-1234 title"); 67 | }); 68 | }); 69 | 70 | describe("body", () => { 71 | let updater: Updater; 72 | 73 | beforeEach(() => { 74 | const jiraKey = new JiraKey("PRJ", "1234"); 75 | const jiraIssue = new JiraIssue(jiraKey, "http://jira", "title", "story", [ 76 | "v1.0.0", 77 | ]); 78 | updater = new Updater(jiraIssue); 79 | }); 80 | 81 | it("adds the key to an undefined body", () => { 82 | const body = undefined; 83 | 84 | const actual = updater.body(body); 85 | expect(actual).toBe("[**PRJ-1234** | title](http://jira)"); 86 | }); 87 | 88 | it("adds the key to an empty body", () => { 89 | const body = ""; 90 | 91 | const actual = updater.body(body); 92 | expect(actual).toBe("[**PRJ-1234** | title](http://jira)"); 93 | }); 94 | 95 | it("adds the key to an existing body", () => { 96 | const body = "test"; 97 | 98 | const actual = updater.body(body); 99 | expect(actual).toBe("test\n\n[**PRJ-1234** | title](http://jira)"); 100 | }); 101 | 102 | it("adds the key to an existing body with suffix", () => { 103 | const body = "test\n\nPRJ-"; 104 | 105 | const actual = updater.body(body); 106 | expect(actual).toBe("test\n\n[**PRJ-1234** | title](http://jira)"); 107 | }); 108 | 109 | it("adds the key to an existing body with reference to ticket", () => { 110 | const body = "test\n\nReferences PRJ-1234"; 111 | 112 | const actual = updater.body(body); 113 | expect(actual).toBe("test\n\n[**PRJ-1234** | title](http://jira)"); 114 | }); 115 | 116 | it("adds the key to an existing body with suffix", () => { 117 | const body = "test\n\nReferences PRJ-"; 118 | 119 | const actual = updater.body(body); 120 | expect(actual).toBe("test\n\n[**PRJ-1234** | title](http://jira)"); 121 | }); 122 | 123 | it("does nothing if the body contains the key already", () => { 124 | const body = "PRJ-1234\n\ntest"; 125 | 126 | const actual = updater.body(body); 127 | expect(actual).toBe("PRJ-1234\n\ntest"); 128 | }); 129 | 130 | it("adds the fixVersions to an undefined body", () => { 131 | const body = undefined; 132 | 133 | const actual = updater.addFixVersionsToBody(body); 134 | expect(actual).toBe("**Fix versions**: v1.0.0"); 135 | }); 136 | 137 | it("adds the fixVersions to an empty body", () => { 138 | const body = ""; 139 | 140 | const actual = updater.addFixVersionsToBody(body); 141 | expect(actual).toBe("**Fix versions**: v1.0.0"); 142 | }); 143 | 144 | it("adds the fixVersions to an existing body", () => { 145 | const body = "test"; 146 | 147 | const actual = updater.addFixVersionsToBody(body); 148 | expect(actual).toBe("test\n\n**Fix versions**: v1.0.0"); 149 | }); 150 | 151 | it("adds the fixVersions to an existing body with reference to ticket", () => { 152 | const body = "test\n\nReferences PRJ-1234"; 153 | 154 | const actual = updater.addFixVersionsToBody(body); 155 | expect(actual).toBe( 156 | "test\n\nReferences PRJ-1234\n\n**Fix versions**: v1.0.0", 157 | ); 158 | }); 159 | 160 | it("update the fixVersions if the body contains the fixVersions already", () => { 161 | const body = "**Fix versions**: v0.9.9"; 162 | 163 | const actual = updater.addFixVersionsToBody(body); 164 | expect(actual).toBe("**Fix versions**: v1.0.0"); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Add Jira info to pull request" 2 | description: "Automatically add Jira info to a pull request" 3 | author: "Contractify" 4 | inputs: 5 | github-token: 6 | description: "The GITHUB_TOKEN secret" 7 | jira-username: 8 | description: "Username used to access the Jira REST API. Must have read access to your Jira Projects & Issues." 9 | jira-token: 10 | description: "API Token used to access the Jira REST API. Must have read access to your Jira Projects & Issues." 11 | required: true 12 | jira-base-url: 13 | description: 'The subdomain of JIRA cloud that you use to access it. Ex: "https://your-domain.atlassian.net"' 14 | required: true 15 | jira-project-key: 16 | description: "Key of project in jira. First part of issue key, will grab project keys using provided jira credentials if not provided." 17 | required: false 18 | default: "" 19 | add-label-with-issue-type: 20 | description: "If set to true, a label with the issue type from Jira will be added to the pull request" 21 | default: "true" 22 | required: false 23 | issue-type-label-color: 24 | description: "The hex color of the label to use for the issue type" 25 | default: "FBCA04" 26 | required: false 27 | issue-type-label-description: 28 | description: "The description of the label to use for the issue type" 29 | default: "Jira Issue Type" 30 | required: false 31 | add-jira-key-to-title: 32 | description: "If set to true, the title of the pull request will be prefixed with the Jira issue key" 33 | default: "true" 34 | required: false 35 | add-jira-key-to-body: 36 | description: "If set to true, the body of the pull request will be suffix with a link to the Jira issue" 37 | default: "true" 38 | required: false 39 | add-jira-fix-versions-to-body: 40 | description: "If set to `true`, the body of the pull request will be suffix with the `fixVersions` from to the Jira issue" 41 | default: "true" 42 | required: false 43 | 44 | branding: 45 | icon: tag 46 | color: green 47 | 48 | runs: 49 | using: 'node20' 50 | main: "dist/index.js" 51 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ["js", "ts"], 4 | testEnvironment: "node", 5 | testMatch: ["**/*.test.ts"], 6 | transform: { 7 | "^.+\\.ts$": "ts-jest", 8 | }, 9 | verbose: true, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "add-jira-info", 3 | "version": "1.9.0", 4 | "description": "Automatically add Jira info to a pull request.", 5 | "main": "lib/main.js", 6 | "scripts": { 7 | "build": "tsc && ncc build lib/main.js", 8 | "format": "prettier --write \"**/*.ts\"", 9 | "format-check": "prettier --check \"**/*.ts\"", 10 | "test": "jest" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/contractify/add-jira-info.git" 15 | }, 16 | "keywords": [ 17 | "github", 18 | "actions", 19 | "jira", 20 | "labels" 21 | ], 22 | "author": "Contractify", 23 | "license": "MIT", 24 | "dependencies": { 25 | "@actions/core": "^1.11.1", 26 | "@actions/github": "^6.0.1", 27 | "@actions/http-client": "^2.2.3", 28 | "nock": "^14.0.5" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.5.14", 32 | "@types/js-yaml": "^4.0.9", 33 | "@types/minimatch": "^5.1.2", 34 | "@types/node": "^22.15.29", 35 | "@vercel/ncc": "^0.38.3", 36 | "jest": "^29.7.0", 37 | "prettier": "^3.5.3", 38 | "ts-jest": "^29.3.4", 39 | "typescript": "^5.8.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/common/github_client.ts: -------------------------------------------------------------------------------- 1 | import * as github from "@actions/github"; 2 | import * as core from "@actions/core"; 3 | 4 | export type GithubClientType = ReturnType; 5 | 6 | class GithubPullRequest { 7 | constructor( 8 | public number: number, 9 | public title: string, 10 | public body: string | undefined 11 | ) {} 12 | 13 | toString(): string { 14 | return `${this.number} | ${this.title}`; 15 | } 16 | } 17 | 18 | export class GithubClient { 19 | client: GithubClientType; 20 | owner: string; 21 | repo: string; 22 | 23 | constructor(private token: string) { 24 | this.client = github.getOctokit(this.token); 25 | this.owner = github.context.repo.owner; 26 | this.repo = github.context.repo.repo; 27 | } 28 | 29 | getBranchName(): string { 30 | return ( 31 | github.context.payload.pull_request?.head.ref || github.context.ref 32 | ).replace("refs/heads/", ""); 33 | } 34 | 35 | async getPullRequest(): Promise { 36 | try { 37 | if (github.context.payload.pull_request?.number) { 38 | return this.getPullRequestByNumber( 39 | github.context.payload.pull_request?.number 40 | ); 41 | } 42 | return this.getPullRequestAssociatedWithCommit(github.context.sha); 43 | } catch (error: any) { 44 | core.error(`🚨 Failed to get pull request: ${error}`); 45 | return undefined; 46 | } 47 | } 48 | 49 | async createLabel( 50 | label: string, 51 | description: string = "", 52 | color: string = "FBCA04" 53 | ): Promise { 54 | try { 55 | await this.client.rest.issues.createLabel({ 56 | owner: this.owner, 57 | repo: this.repo, 58 | name: label, 59 | description: description, 60 | color: color, 61 | }); 62 | } catch (error: any) { 63 | if (error.response?.code === "already_exists") { 64 | return; 65 | } 66 | this.throwError(error); 67 | } 68 | } 69 | 70 | async labelExists(label: string): Promise { 71 | try { 72 | await this.client.rest.issues.getLabel({ 73 | owner: this.owner, 74 | repo: this.repo, 75 | name: label, 76 | }); 77 | } catch (error: any) { 78 | if (error.response?.status === 404) { 79 | return false; 80 | } 81 | this.throwError(error); 82 | } 83 | return true; 84 | } 85 | 86 | async addLabelsToIssue( 87 | pullRequest: GithubPullRequest, 88 | labels: string[] 89 | ): Promise { 90 | try { 91 | await this.client.rest.issues.addLabels({ 92 | owner: this.owner, 93 | repo: this.repo, 94 | issue_number: pullRequest.number, 95 | labels: labels, 96 | }); 97 | } catch (error: any) { 98 | core.error(`🚨 Failed to add labels to issue: ${error}`); 99 | throw error; 100 | } 101 | } 102 | 103 | async updatePullRequest(pullRequest: GithubPullRequest): Promise { 104 | await this.client.rest.pulls.update({ 105 | owner: this.owner, 106 | repo: this.repo, 107 | pull_number: pullRequest.number, 108 | title: pullRequest.title, 109 | body: pullRequest.body, 110 | }); 111 | } 112 | 113 | private async getPullRequestByNumber( 114 | number: number 115 | ): Promise { 116 | try { 117 | const response = await this.client.rest.pulls.get({ 118 | owner: this.owner, 119 | repo: this.repo, 120 | pull_number: number, 121 | }); 122 | 123 | return new GithubPullRequest( 124 | response.data.number, 125 | response.data.title.trim(), 126 | response.data.body?.trim() 127 | ); 128 | } catch (error: any) { 129 | if (error.response?.status === 404) { 130 | return undefined; 131 | } 132 | this.throwError(error); 133 | } 134 | } 135 | 136 | private async getPullRequestAssociatedWithCommit( 137 | sha: string 138 | ): Promise { 139 | try { 140 | const response = 141 | await this.client.rest.repos.listPullRequestsAssociatedWithCommit({ 142 | owner: this.owner, 143 | repo: this.repo, 144 | commit_sha: sha, 145 | }); 146 | 147 | const pullRequest = response.data 148 | .filter((el) => el.state === "open") 149 | .find((el) => { 150 | return github.context.payload.ref === `refs/heads/${el.head.ref}`; 151 | }); 152 | 153 | if (!pullRequest) { 154 | return undefined; 155 | } 156 | 157 | return new GithubPullRequest( 158 | pullRequest.number, 159 | pullRequest.title.trim(), 160 | pullRequest.body?.trim() 161 | ); 162 | } catch (error: any) { 163 | if (error.response?.status === 404) { 164 | return undefined; 165 | } 166 | this.throwError(error); 167 | } 168 | } 169 | 170 | private throwError(error: any) { 171 | if (error.response) { 172 | throw new Error(JSON.stringify(error.response)); 173 | } 174 | throw error; 175 | } 176 | 177 | // private async fetchContent(repoPath: string): Promise { 178 | // const response: any = await this.client.rest.repos.getContent({ 179 | // owner: this.owner, 180 | // repo: this.repo, 181 | // path: repoPath, 182 | // ref: github.context.sha, 183 | // }); 184 | 185 | // return Buffer.from( 186 | // response.data.content, 187 | // response.data.encoding 188 | // ).toString(); 189 | // } 190 | } 191 | -------------------------------------------------------------------------------- /src/common/jira_client.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from "@actions/http-client"; 2 | import { BasicCredentialHandler } from "@actions/http-client/lib/auth"; 3 | 4 | export class JiraKey { 5 | constructor( 6 | public project: string, 7 | public number: string, 8 | ) {} 9 | 10 | toString(): string { 11 | return `${this.project}-${this.number}`; 12 | } 13 | } 14 | 15 | export class JiraIssue { 16 | constructor( 17 | public key: JiraKey, 18 | public link: string, 19 | public title: string | undefined, 20 | public type: string | undefined, 21 | public fixVersions?: string[], 22 | ) {} 23 | 24 | toString(): string { 25 | return `${this.key} | ${this.type} | ${this.title}`; 26 | } 27 | } 28 | 29 | export class JiraClient { 30 | client: HttpClient; 31 | 32 | constructor( 33 | private baseUrl: string, 34 | private username: string, 35 | private token: string, 36 | private projectKey: string, 37 | ) { 38 | const credentials = new BasicCredentialHandler(this.username, this.token); 39 | 40 | this.client = new HttpClient("add-jira-info-action", [credentials], { 41 | socketTimeout: 2000, 42 | }); 43 | } 44 | 45 | async extractJiraKey(input: string): Promise { 46 | 47 | // if project keys are not set, fetch it using current credentials 48 | if (!this.projectKey) { 49 | await this.getKeys() 50 | } 51 | 52 | /** 53 | * Allows for grabbing of multiple keys when given as the follwoing 54 | * jira-project-key: |- 55 | foo 56 | bar 57 | * or 1 key if given only as 58 | jira-project-key: foo 59 | */ 60 | const keys = this.projectKey 61 | .split(/[\r\n]/) 62 | .map(input => input.trim()) 63 | .filter(input => input !== ''); // grab 1 or many project keys 64 | 65 | let matchingKey: JiraKey | undefined = undefined 66 | 67 | keys.forEach(projectKey => { 68 | const regex = new RegExp(`${projectKey}-(?\\d+)`, "i"); 69 | const match = input.match(regex); 70 | 71 | if (match?.groups?.number) { 72 | matchingKey = new JiraKey(projectKey, match?.groups?.number) 73 | } 74 | }); 75 | 76 | 77 | return matchingKey 78 | 79 | } 80 | 81 | /** 82 | * Fetches all project keys from Jira for the current user 83 | * @returns undefined 84 | */ 85 | async getKeys(): Promise { 86 | 87 | try { 88 | const res = await this.client.get( 89 | this.getRestApiUrl(`/rest/api/3/project`), 90 | ); 91 | 92 | const body: string = await res.readBody(); 93 | const projects = JSON.parse(body); 94 | 95 | projects.map((project: { key: string }) => { 96 | this.projectKey += `${project.key}\r\n`; // added as string with \r\n to be split out to an array later 97 | }); 98 | 99 | 100 | } catch (error) { 101 | console.error("Failed to fetch projects:", error); 102 | } 103 | } 104 | 105 | 106 | async getIssue(key: JiraKey): Promise { 107 | try { 108 | const res = await this.client.get( 109 | this.getRestApiUrl(`/rest/api/3/issue/${key}?fields=issuetype,summary,fixVersions`), 110 | ); 111 | const body: string = await res.readBody(); 112 | const obj = JSON.parse(body); 113 | 114 | var issuetype: string | undefined = undefined; 115 | var title: string | undefined = undefined; 116 | var fixVersions: string[] | undefined = undefined; 117 | for (let field in obj.fields) { 118 | if (field === "issuetype") { 119 | issuetype = obj.fields[field].name?.toLowerCase(); 120 | } else if (field === "summary") { 121 | title = obj.fields[field]; 122 | } else if (field === "fixVersions") { 123 | fixVersions = obj.fields[field] 124 | .map(({ name }) => name) 125 | .filter(Boolean); 126 | } 127 | } 128 | 129 | return new JiraIssue( 130 | key, 131 | `${this.baseUrl}/browse/${key}`, 132 | title, 133 | issuetype, 134 | fixVersions, 135 | ); 136 | } catch (error: any) { 137 | if (error.response) { 138 | throw new Error(JSON.stringify(error.response, null, 4)); 139 | } 140 | throw error; 141 | } 142 | } 143 | 144 | private getRestApiUrl(endpoint: string): string { 145 | return `${this.baseUrl}${endpoint}`; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/common/updater.ts: -------------------------------------------------------------------------------- 1 | import { JiraIssue } from "./jira_client"; 2 | 3 | export class Updater { 4 | constructor(private jiraIssue: JiraIssue) {} 5 | 6 | title(title: string): string { 7 | if (title.startsWith(`${this.jiraIssue.key} | `)) { 8 | return title; 9 | } 10 | 11 | const patternsToStrip = [ 12 | `^${this.jiraIssue.key.project} ${this.jiraIssue.key.number}`, 13 | `^${this.jiraIssue.key.project}-${this.jiraIssue.key.number}`, 14 | `${this.jiraIssue.key}$`, 15 | ]; 16 | 17 | for (const pattern of patternsToStrip) { 18 | const regex = new RegExp(`${pattern}`, "i"); 19 | title = title.replace(regex, "").trim(); 20 | title = title.replace(/^\|+/, "").trim(); 21 | title = title.replace(/\|+$/, "").trim(); 22 | } 23 | 24 | return `${this.jiraIssue.key} | ${title}`; 25 | } 26 | 27 | body(body: string | undefined): string | undefined { 28 | if ( 29 | body?.includes(`${this.jiraIssue.key}`) && 30 | !body?.includes(`References ${this.jiraIssue.key}`) 31 | ) { 32 | return body; 33 | } 34 | 35 | if (!body) { 36 | body = ""; 37 | } 38 | 39 | const patternsToStrip = [ 40 | `References ${this.jiraIssue.key}$`, 41 | `References ${this.jiraIssue.key.project}-$`, 42 | `${this.jiraIssue.key.project}-$`, 43 | `${this.jiraIssue.key}$`, 44 | ]; 45 | 46 | for (const pattern of patternsToStrip) { 47 | const regex = new RegExp(`${pattern}`, "i"); 48 | body = body.replace(regex, "").trim(); 49 | } 50 | 51 | return `${body}\n\n[**${this.jiraIssue.key}** | ${this.jiraIssue.title}](${this.jiraIssue.link})`.trim(); 52 | } 53 | 54 | addFixVersionsToBody(body: string | undefined): string | undefined { 55 | const { fixVersions } = this.jiraIssue; 56 | 57 | if (!fixVersions?.length) { 58 | return body; 59 | } 60 | 61 | if (!body) { 62 | body = ""; 63 | } 64 | 65 | if (body.includes("**Fix versions**:")) { 66 | body = body.replace( 67 | /\*\*Fix versions\*\*:.*$/, 68 | `**Fix versions**: ${fixVersions.join(",")}`, 69 | ); 70 | } else { 71 | body = `${body}\n\n**Fix versions**: ${fixVersions.join(",")}`.trim(); 72 | } 73 | 74 | return body; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | 4 | import { GithubClient } from "./common/github_client"; 5 | import { JiraClient } from "./common/jira_client"; 6 | import { Updater } from "./common/updater"; 7 | 8 | export async function run() { 9 | if (github.context.actor === "dependabot[bot]") { 10 | core.info(`🚨 Dependabot, ignoring`); 11 | return; 12 | } 13 | 14 | const githubToken = core.getInput("github-token", { required: true }); 15 | const jiraBaseUrl = core.getInput("jira-base-url", { required: true }); 16 | const jiraUsername = core.getInput("jira-username", { required: true }); 17 | const jiraToken = core.getInput("jira-token", { required: true }); 18 | const jiraProjectKey = core.getInput("jira-project-key", { required: false }); 19 | 20 | const addLabelWithIssueType = core.getBooleanInput( 21 | "add-label-with-issue-type", 22 | ); 23 | const issueTypeLabelColor = 24 | core.getInput("issue-type-label-color") || "FBCA04"; 25 | const issueTypeLabelDescription = 26 | core.getInput("issue-type-label-description") || "Jira Issue Type"; 27 | const addJiraKeyToTitle = core.getBooleanInput("add-jira-key-to-title"); 28 | const addJiraKeyToBody = core.getBooleanInput("add-jira-key-to-body"); 29 | const addJiraFixVersionsToBody = core.getBooleanInput( 30 | "add-jira-fix-versions-to-body", 31 | ); 32 | 33 | const githubClient = new GithubClient(githubToken); 34 | 35 | const jiraClient = new JiraClient( 36 | jiraBaseUrl, 37 | jiraUsername, 38 | jiraToken, 39 | jiraProjectKey, 40 | ); 41 | 42 | const pullRequest = await githubClient.getPullRequest(); 43 | const branchName = githubClient.getBranchName(); 44 | core.info(`📄 Context details`); 45 | core.info(` Branch name: ${branchName}`); 46 | 47 | if (branchName.startsWith("dependabot")) { 48 | core.info(`🚨 Dependabot, ignoring`); 49 | return; 50 | } 51 | 52 | const jiraKey = await jiraClient.extractJiraKey(branchName); 53 | 54 | if (!jiraKey) { 55 | core.warning("⚠️ No Jira key found in branch name, exiting"); 56 | return; 57 | } 58 | 59 | if (!pullRequest) { 60 | core.warning("⚠️ Could not get pull request number, exiting"); 61 | return; 62 | } 63 | 64 | const jiraIssue = await jiraClient.getIssue(jiraKey); 65 | if (!jiraIssue) { 66 | core.warning("⚠️ Could not get issue, exiting"); 67 | return; 68 | } 69 | core.info(` Pull Request: ${pullRequest}`); 70 | core.info(` Jira key: ${jiraKey}`); 71 | core.info(` Issue type: ${jiraIssue}`); 72 | 73 | if (addLabelWithIssueType) { 74 | core.info(`📄 Adding pull request label`); 75 | 76 | if (!jiraIssue.type) { 77 | core.info(` Issue type undefined for ${jiraIssue}`); 78 | } else { 79 | if (!(await githubClient.labelExists(jiraIssue.type))) { 80 | core.info(` Creating label: ${jiraIssue.type}`); 81 | await githubClient.createLabel( 82 | jiraIssue.type, 83 | issueTypeLabelDescription, 84 | issueTypeLabelColor, 85 | ); 86 | } 87 | 88 | core.info(` Adding label: ${jiraIssue.type} to: ${pullRequest}`); 89 | await githubClient.addLabelsToIssue(pullRequest, [jiraIssue.type]); 90 | } 91 | } 92 | 93 | if (addJiraKeyToTitle || addJiraKeyToBody) { 94 | core.info(`📄 Adding Jira key to pull request`); 95 | 96 | const updater = new Updater(jiraIssue); 97 | 98 | if (addJiraKeyToTitle) { 99 | core.info(` Updating pull request title`); 100 | pullRequest.title = updater.title(pullRequest.title); 101 | } 102 | 103 | if (addJiraKeyToBody) { 104 | core.info(` Updating pull request body`); 105 | pullRequest.body = updater.body(pullRequest.body); 106 | } 107 | 108 | if (addJiraFixVersionsToBody) { 109 | pullRequest.body = updater.addFixVersionsToBody(pullRequest.body); 110 | } 111 | 112 | core.info(` Updating pull request`); 113 | await githubClient.updatePullRequest(pullRequest); 114 | } 115 | 116 | core.info(`📄 Finished`); 117 | } 118 | 119 | run(); 120 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./lib", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "exclude": ["node_modules", "__tests__", "__mocks__"] 63 | } 64 | --------------------------------------------------------------------------------