Skip to content

Commit

Permalink
Merge branch 'develop' into re-2756/goreleaser-build-image
Browse files Browse the repository at this point in the history
* develop:
  RE-2859 Make jira ticket linkage obligatory in PRs with solidity changes (#14054)
  • Loading branch information
momentmaker committed Aug 8, 2024
2 parents cc65cf1 + ebd45ce commit 8e22ef7
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 60 deletions.
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
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

0 comments on commit 8e22ef7

Please sign in to comment.