Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RE-2859 Make jira ticket linkage obligatory in PRs with solidity changes #14054

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/scripts/jira/enforce-jira-issue.ts
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions .github/scripts/jira/lib.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
});
}
4 changes: 3 additions & 1 deletion .github/scripts/jira/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 1 addition & 58 deletions .github/scripts/jira/update-jira-issue.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
100 changes: 100 additions & 0 deletions .github/workflows/solidity-jira.yml
Original file line number Diff line number Diff line change
@@ -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
Loading