From ebd45cef2a19b14ca5e3e394007d746feee5a852 Mon Sep 17 00:00:00 2001 From: HenryNguyen5 <6404866+HenryNguyen5@users.noreply.github.com> Date: Thu, 8 Aug 2024 16:05:16 -0400 Subject: [PATCH] RE-2859 Make jira ticket linkage obligatory in PRs with solidity changes (#14054) * Split out lib functions into their own file * Create issue enforcement option * Setup solidity-jira workflow * chore: Improve error message for JIRA issue key not found --- .github/scripts/jira/enforce-jira-issue.ts | 77 ++++++++++++++ ...{update-jira-issue.test.ts => lib.test.ts} | 13 ++- .github/scripts/jira/lib.ts | 63 +++++++++++ .github/scripts/jira/package.json | 4 +- .github/scripts/jira/update-jira-issue.ts | 59 +---------- .github/workflows/changeset.yml | 2 +- .github/workflows/solidity-jira.yml | 100 ++++++++++++++++++ 7 files changed, 257 insertions(+), 61 deletions(-) create mode 100644 .github/scripts/jira/enforce-jira-issue.ts rename .github/scripts/jira/{update-jira-issue.test.ts => lib.test.ts} (78%) create mode 100644 .github/scripts/jira/lib.ts create mode 100644 .github/workflows/solidity-jira.yml diff --git a/.github/scripts/jira/enforce-jira-issue.ts b/.github/scripts/jira/enforce-jira-issue.ts new file mode 100644 index 00000000000..e0054b25d0e --- /dev/null +++ b/.github/scripts/jira/enforce-jira-issue.ts @@ -0,0 +1,77 @@ +import * as core from "@actions/core"; +import jira from "jira.js"; +import { createJiraClient, parseIssueNumberFrom } from "./lib"; + +async function doesIssueExist( + client: jira.Version3Client, + issueNumber: string, + dryRun: boolean +) { + const payload = { + issueIdOrKey: issueNumber, + }; + + if (dryRun) { + core.info("Dry run enabled, skipping JIRA issue enforcement"); + return true; + } + + try { + /** + * The issue is identified by its ID or key, however, if the identifier doesn't match an issue, a case-insensitive search and check for moved issues is performed. + * If a matching issue is found its details are returned, a 302 or other redirect is not returned. The issue key returned in the response is the key of the issue found. + */ + const issue = await client.issues.getIssue(payload); + core.debug( + `JIRA issue id:${issue.id} key: ${issue.key} found while querying for ${issueNumber}` + ); + if (issue.key !== issueNumber) { + core.error( + `JIRA issue key ${issueNumber} not found, but found issue key ${issue.key} instead. This can happen if the identifier doesn't match an issue, in which case a case-insensitive search and check for moved issues is performed. Make sure the issue key is correct.` + ); + return false; + } + + return true; + } catch (e) { + core.debug(e as any); + return false; + } +} + +async function main() { + const prTitle = process.env.PR_TITLE; + const commitMessage = process.env.COMMIT_MESSAGE; + const branchName = process.env.BRANCH_NAME; + const dryRun = !!process.env.DRY_RUN; + const client = createJiraClient(); + + // Checks for the Jira issue number and exit if it can't find it + const issueNumber = parseIssueNumberFrom(prTitle, commitMessage, branchName); + if (!issueNumber) { + const msg = + "No JIRA issue number found in PR title, commit message, or branch name. This pull request must be associated with a JIRA issue."; + + core.setFailed(msg); + return; + } + + const exists = await doesIssueExist(client, issueNumber, dryRun); + if (!exists) { + core.setFailed(`JIRA issue ${issueNumber} not found, this pull request must be associated with a JIRA issue.`); + return; + } +} + +async function run() { + try { + await main(); + } catch (error) { + if (error instanceof Error) { + return core.setFailed(error.message); + } + core.setFailed(error as any); + } +} + +run(); diff --git a/.github/scripts/jira/update-jira-issue.test.ts b/.github/scripts/jira/lib.test.ts similarity index 78% rename from .github/scripts/jira/update-jira-issue.test.ts rename to .github/scripts/jira/lib.test.ts index c9efebc92d7..9c751e84088 100644 --- a/.github/scripts/jira/update-jira-issue.test.ts +++ b/.github/scripts/jira/lib.test.ts @@ -1,5 +1,5 @@ import { expect, describe, it } from "vitest"; -import { parseIssueNumberFrom, tagsToLabels } from "./update-jira-issue"; +import { parseIssueNumberFrom, tagsToLabels } from "./lib"; describe("parseIssueNumberFrom", () => { it("should return the first JIRA issue number found", () => { @@ -18,6 +18,17 @@ describe("parseIssueNumberFrom", () => { expect(r).to.equal("CORE-123"); }); + it("works with multiline commit bodies", () => { + const r = parseIssueNumberFrom( + `This is a multiline commit body + +CORE-1011`, + "CORE-456", + "CORE-789" + ); + expect(r).to.equal("CORE-1011"); + }); + it("should return undefined if no JIRA issue number is found", () => { const result = parseIssueNumberFrom("No issue number"); expect(result).to.be.undefined; diff --git a/.github/scripts/jira/lib.ts b/.github/scripts/jira/lib.ts new file mode 100644 index 00000000000..72f1d57966c --- /dev/null +++ b/.github/scripts/jira/lib.ts @@ -0,0 +1,63 @@ + +import * as core from '@actions/core' +import * as jira from 'jira.js' + +/** + * Given a list of strings, this function will return the first JIRA issue number it finds. + * + * @example parseIssueNumberFrom("CORE-123", "CORE-456", "CORE-789") => "CORE-123" + * @example parseIssueNumberFrom("2f3df5gf", "chore/test-RE-78-branch", "RE-78 Create new test branches") => "RE-78" + */ +export function parseIssueNumberFrom( + ...inputs: (string | undefined)[] +): string | undefined { + function parse(str?: string) { + const jiraIssueRegex = /[A-Z]{2,}-\d+/; + + return str?.toUpperCase().match(jiraIssueRegex)?.[0]; + } + + core.debug(`Parsing issue number from: ${inputs.join(", ")}`); + const parsed: string[] = inputs.map(parse).filter((x) => x !== undefined); + core.debug(`Found issue number: ${parsed[0]}`); + + return parsed[0]; +} + +/** + * Converts an array of tags to an array of labels. + * + * A label is a string that is formatted as `core-release/{tag}`, with the leading `v` removed from the tag. + * + * @example tagsToLabels(["v1.0.0", "v1.1.0"]) => [{ add: "core-release/1.0.0" }, { add: "core-release/1.1.0" }] + */ +export function tagsToLabels(tags: string[]) { + const labelPrefix = "core-release"; + + return tags.map((t) => ({ + add: `${labelPrefix}/${t.substring(1)}`, + })); +} + +export function createJiraClient() { + const jiraHost = process.env.JIRA_HOST; + const jiraUserName = process.env.JIRA_USERNAME; + const jiraApiToken = process.env.JIRA_API_TOKEN; + + if (!jiraHost || !jiraUserName || !jiraApiToken) { + core.setFailed( + "Error: Missing required environment variables: JIRA_HOST and JIRA_USERNAME and JIRA_API_TOKEN." + ); + process.exit(1); + } + + return new jira.Version3Client({ + host: jiraHost, + authentication: { + basic: { + email: jiraUserName, + apiToken: jiraApiToken, + }, + }, + }); +} diff --git a/.github/scripts/jira/package.json b/.github/scripts/jira/package.json index 9902b489ea1..95bfbb1e486 100644 --- a/.github/scripts/jira/package.json +++ b/.github/scripts/jira/package.json @@ -13,7 +13,9 @@ "pnpm": ">=9" }, "scripts": { - "start": "tsx update-jira-issue.ts" + "issue:update": "tsx update-jira-issue.ts", + "issue:enforce": "tsx enforce-jira-issue.ts", + "test": "vitest" }, "dependencies": { "@actions/core": "^1.10.1", diff --git a/.github/scripts/jira/update-jira-issue.ts b/.github/scripts/jira/update-jira-issue.ts index 2659f4e5174..6e539c7ffa8 100644 --- a/.github/scripts/jira/update-jira-issue.ts +++ b/.github/scripts/jira/update-jira-issue.ts @@ -1,40 +1,6 @@ import * as core from "@actions/core"; import jira from "jira.js"; - -/** - * Given a list of strings, this function will return the first JIRA issue number it finds. - * - * @example parseIssueNumberFrom("CORE-123", "CORE-456", "CORE-789") => "CORE-123" - * @example parseIssueNumberFrom("2f3df5gf", "chore/test-RE-78-branch", "RE-78 Create new test branches") => "RE-78" - */ -export function parseIssueNumberFrom( - ...inputs: (string | undefined)[] -): string | undefined { - function parse(str?: string) { - const jiraIssueRegex = /[A-Z]{2,}-\d+/; - - return str?.toUpperCase().match(jiraIssueRegex)?.[0]; - } - - const parsed: string[] = inputs.map(parse).filter((x) => x !== undefined); - - return parsed[0]; -} - -/** - * Converts an array of tags to an array of labels. - * - * A label is a string that is formatted as `core-release/{tag}`, with the leading `v` removed from the tag. - * - * @example tagsToLabels(["v1.0.0", "v1.1.0"]) => [{ add: "core-release/1.0.0" }, { add: "core-release/1.1.0" }] - */ -export function tagsToLabels(tags: string[]) { - const labelPrefix = "core-release"; - - return tags.map((t) => ({ - add: `${labelPrefix}/${t.substring(1)}`, - })); -} +import { tagsToLabels, createJiraClient, parseIssueNumberFrom } from "./lib"; function updateJiraIssue( client: jira.Version3Client, @@ -64,29 +30,6 @@ function updateJiraIssue( return client.issues.editIssue(payload); } -function createJiraClient() { - const jiraHost = process.env.JIRA_HOST; - const jiraUserName = process.env.JIRA_USERNAME; - const jiraApiToken = process.env.JIRA_API_TOKEN; - - if (!jiraHost || !jiraUserName || !jiraApiToken) { - core.setFailed( - "Error: Missing required environment variables: JIRA_HOST and JIRA_USERNAME and JIRA_API_TOKEN." - ); - process.exit(1); - } - - return new jira.Version3Client({ - host: jiraHost, - authentication: { - basic: { - email: jiraUserName, - apiToken: jiraApiToken, - }, - }, - }); -} - async function main() { const prTitle = process.env.PR_TITLE; const commitMessage = process.env.COMMIT_MESSAGE; diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 01df70a20d6..5e16b90c400 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -94,7 +94,7 @@ jobs: working-directory: ./.github/scripts/jira run: | echo "COMMIT_MESSAGE=$(git log -1 --pretty=format:'%s')" >> $GITHUB_ENV - pnpm install && pnpm start + pnpm install && pnpm issue:update env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JIRA_HOST: ${{ secrets.JIRA_HOST }} diff --git a/.github/workflows/solidity-jira.yml b/.github/workflows/solidity-jira.yml new file mode 100644 index 00000000000..1054bfa9875 --- /dev/null +++ b/.github/workflows/solidity-jira.yml @@ -0,0 +1,100 @@ +# This is its own independent workflow since "solidity.yml" depends on "merge_group" and "push" events. +# But for ensuring that JIRA tickets are always updated, we only care about "pull_request" events. +# +# We still need to add "merge_group" event and noop so that we'll pass required workflow checks. +# +# I didn't add this to the "changeset.yml" workflow because the "changeset" job isnt required, and we'd need to add the "merge_group" event to the "changeset.yml" workflow. +# If we made the change to make it required. +name: Solidity Jira + +on: + merge_group: + pull_request: + +defaults: + run: + shell: bash + +jobs: + skip-enforce-jira-issue: + name: Should Skip + # We want to skip merge_group events, and any release branches + # Since we only want to enforce Jira issues on pull requests related to feature branches + if: ${{ github.event_name != 'merge_group' && !startsWith(github.head_ref, 'release/') }} + outputs: + should-enforce: ${{ steps.changed_files.outputs.only_src_contracts }} + runs-on: ubuntu-latest + steps: + - name: Checkout the repo + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + + # We don't use detect-solidity-file-changes here because we need to use the "every" predicate quantifier + - name: Filter paths + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: changed_files + with: + list-files: "csv" + # This is a valid input, see https://github.com/dorny/paths-filter/pull/226 + predicate-quantifier: "every" + filters: | + only_src_contracts: + - contracts/**/*.sol + - '!contracts/**/*.t.sol' + + - name: Collect Metrics + id: collect-gha-metrics + uses: smartcontractkit/push-gha-metrics-action@d9da21a2747016b3e13de58c7d4115a3d5c97935 # v3.0.1 + with: + id: solidity-jira + org-id: ${{ secrets.GRAFANA_INTERNAL_TENANT_ID }} + basic-auth: ${{ secrets.GRAFANA_INTERNAL_BASIC_AUTH }} + hostname: ${{ secrets.GRAFANA_INTERNAL_HOST }} + this-job-name: Should Skip + continue-on-error: true + + enforce-jira-issue: + name: Enforce Jira Issue + runs-on: ubuntu-latest + # If a needs job is skipped, this job will be skipped and counted as successful + # The job skips on merge_group events, and any release branches + # Since we only want to enforce Jira issues on pull requests related to feature branches + needs: [skip-enforce-jira-issue] + # In addition to the above conditions, we only want to running on solidity related PRs. + # + # Note: A job that is skipped will report its status as "Success". + # It will not prevent a pull request from merging, even if it is a required check. + if: ${{ needs.skip-enforce-jira-issue.outputs.should-enforce == 'true' }} + steps: + - name: Checkout the repo + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + + - name: Setup NodeJS + uses: ./.github/actions/setup-nodejs + + - name: Setup Jira + working-directory: ./.github/scripts/jira + run: pnpm i + + - name: Enforce Jira Issue + working-directory: ./.github/scripts/jira + run: | + echo "COMMIT_MESSAGE=$(git log -1 --pretty=format:'%s')" >> $GITHUB_ENV + pnpm issue:enforce + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JIRA_HOST: ${{ secrets.JIRA_HOST }} + JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + PR_TITLE: ${{ github.event.pull_request.title }} + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + + - name: Collect Metrics + id: collect-gha-metrics + uses: smartcontractkit/push-gha-metrics-action@d9da21a2747016b3e13de58c7d4115a3d5c97935 # v3.0.1 + with: + id: solidity-jira + org-id: ${{ secrets.GRAFANA_INTERNAL_TENANT_ID }} + basic-auth: ${{ secrets.GRAFANA_INTERNAL_BASIC_AUTH }} + hostname: ${{ secrets.GRAFANA_INTERNAL_HOST }} + this-job-name: Enforce Jira Issue + continue-on-error: true