Skip to content

Commit

Permalink
RE 2877 Jira<->Changeset traceability for solidity changes (#14141)
Browse files Browse the repository at this point in the history
* Migrate contract changeset handling to solidity-jira

* Update solidity-jira to handle jira tracability as a whole

* Fix changesets output typo

* Make file paths absolute

* Prevent merge commits from being added by auto commit action

* Add issue number to tail of document rather than head

* Tighten up file pattern for committing

* Add GATI so workflows run on auto commits

* Modify workflow to generate jira traceabillity

* Add checkout

* Fix typo

* Add better naming for job

* Add more logging

* Append to step summary from within script

* Extract functions and use labels over individual issues

* Fix comments handling

* Use plain artifacts URL for action

* Add test for jira issues in the middle of comments

* Formatting

* Actually write to step summary

* Make jira host public

* Handle csv output rather than JSON

* Fix typo in changeset reference

* Comment out broken step

* Notify that auto commits are made via bot

* Pin planetscale/ghcommit-action

* Rename

* Add pull-requests write perm for comments

* Add always() to gha metrics

* Use env var for head_ref

* Formatting

* Use JIRA_HOST rather than hard coded URL
  • Loading branch information
HenryNguyen5 authored Aug 27, 2024
1 parent 808912d commit ca7b958
Show file tree
Hide file tree
Showing 9 changed files with 611 additions and 146 deletions.
205 changes: 205 additions & 0 deletions .github/scripts/jira/create-jira-traceability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as jira from "jira.js";
import {
createJiraClient,
extractJiraIssueNumbersFrom,
generateIssueLabel,
generateJiraIssuesLink,
getJiraEnvVars,
} from "./lib";
import * as core from "@actions/core";

/**
* Extracts the list of changeset files. Intended to be used with https://github.com/dorny/paths-filter with
* the 'csv' output format.
*
* @returns An array of strings representing the changeset files.
* @throws {Error} If the required environment variable CHANGESET_FILES is missing.
* @throws {Error} If no changeset file exists.
*/
function extractChangesetFiles(): string[] {
const changesetFiles = process.env.CHANGESET_FILES;
if (!changesetFiles) {
throw Error("Missing required environment variable CHANGESET_FILES");
}
const parsedChangesetFiles = changesetFiles.split(",");
if (parsedChangesetFiles.length === 0) {
throw Error("At least one changeset file must exist");
}

core.info(
`Changeset to extract issues from: ${parsedChangesetFiles.join(", ")}`
);
return parsedChangesetFiles;
}

/**
* Adds traceability to JIRA issues by commenting on each issue with a link to the artifact payload
* along with a label to connect all issues to the same chainlink product review.
*
* @param client The jira client
* @param issues The list of JIRA issue numbers to add traceability to
* @param label The label to add to each issue
* @param artifactUrl The url to the artifact payload that we'll comment on each issue with
*/
async function addTraceabillityToJiraIssues(
client: jira.Version3Client,
issues: string[],
label: string,
artifactUrl: string
) {
for (const issue of issues) {
await checkAndAddArtifactPayloadComment(client, issue, artifactUrl);

// CHECK: We don't need to see if the label exists, should no-op
core.info(`Adding label ${label} to issue ${issue}`);
await client.issues.editIssue({
issueIdOrKey: issue,
update: {
labels: [{ add: label }],
},
});
}
}

/**
* Checks if the artifact payload already exists as a comment on the issue, if not, adds it.
*/
async function checkAndAddArtifactPayloadComment(
client: jira.Version3.Version3Client,
issue: string,
artifactUrl: string
) {
const maxResults = 5000;
const getCommentsResponse = await client.issueComments.getComments({
issueIdOrKey: issue,
maxResults, // this is the default maxResults, see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/#api-rest-api-3-issue-issueidorkey-comment-get
});
core.debug(JSON.stringify(getCommentsResponse.comments));
if ((getCommentsResponse.total ?? 0) > maxResults) {
throw Error(
`Too many (${getCommentsResponse.total}) comments on issue ${issue}, please increase maxResults (${maxResults})`
);
}

// Search path is getCommentsResponse.comments[].body.content[].content[].marks[].attrs.href
//
// Example:
// [ // getCommentsResponse.comments
// {
// body: {
// type: "doc",
// version: 1,
// content: [
// {
// type: "paragraph",
// content: [
// {
// type: "text",
// text: "Artifact URL",
// marks: [
// {
// type: "link",
// attrs: {
// href: "https://github.com/smartcontractkit/chainlink/actions/runs/10517121836/artifacts/1844867108",
// },
// },
// ],
// },
// ],
// },
// ],
// },
// },
// ];
const commentExists = getCommentsResponse.comments?.some((c) =>
c?.body?.content?.some((innerContent) =>
innerContent?.content?.some((c) =>
c.marks?.some((m) => m.attrs?.href === artifactUrl)
)
)
);

if (commentExists) {
core.info(`Artifact payload already exists as comment on issue, skipping`);
} else {
core.info(`Adding artifact payload as comment on issue ${issue}`);
await client.issueComments.addComment({
issueIdOrKey: issue,
comment: {
type: "doc",
version: 1,
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Artifact Download URL",
marks: [
{
type: "link",
attrs: {
href: artifactUrl,
},
},
],
},
],
},
],
},
});
}
}

function fetchEnvironmentVariables() {
const product = process.env.CHAINLINK_PRODUCT;
if (!product) {
throw Error("CHAINLINK_PRODUCT environment variable is missing");
}
const baseRef = process.env.BASE_REF;
if (!baseRef) {
throw Error("BASE_REF environment variable is missing");
}
const headRef = process.env.HEAD_REF;
if (!headRef) {
throw Error("HEAD_REF environment variable is missing");
}

const artifactUrl = process.env.ARTIFACT_URL;
if (!artifactUrl) {
throw Error("ARTIFACT_URL environment variable is missing");
}
return { product, baseRef, headRef, artifactUrl };
}

/**
* For all affected jira issues listed within the changeset files supplied,
* we update each jira issue so that they are all labelled and have a comment linking them
* to the relevant artifact URL.
*/
async function main() {
const { product, baseRef, headRef, artifactUrl } =
fetchEnvironmentVariables();
const changesetFiles = extractChangesetFiles();
core.info(
`Extracting Jira issue numbers from changeset files: ${changesetFiles.join(
", "
)}`
);
const jiraIssueNumbers = await extractJiraIssueNumbersFrom(changesetFiles);

const client = createJiraClient();
const label = generateIssueLabel(product, baseRef, headRef);
await addTraceabillityToJiraIssues(
client,
jiraIssueNumbers,
label,
artifactUrl
);

const { jiraHost } = getJiraEnvVars()
core.summary.addLink("Jira Issues", generateJiraIssuesLink(`${jiraHost}/issues/`, label));
core.summary.write();
}
main();
46 changes: 44 additions & 2 deletions .github/scripts/jira/enforce-jira-issue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as core from "@actions/core";
import jira from "jira.js";
import { createJiraClient, parseIssueNumberFrom } from "./lib";
import { createJiraClient, getGitTopLevel, parseIssueNumberFrom } from "./lib";
import { promises as fs } from "fs";
import { join } from "path";

async function doesIssueExist(
client: jira.Version3Client,
Expand Down Expand Up @@ -44,6 +46,8 @@ async function main() {
const commitMessage = process.env.COMMIT_MESSAGE;
const branchName = process.env.BRANCH_NAME;
const dryRun = !!process.env.DRY_RUN;
const { changesetFile } = extractChangesetFile();

const client = createJiraClient();

// Checks for the Jira issue number and exit if it can't find it
Expand All @@ -58,9 +62,47 @@ async function main() {

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.`);
core.setFailed(
`JIRA issue ${issueNumber} not found, this pull request must be associated with a JIRA issue.`
);
return;
}

core.info(`Appending JIRA issue ${issueNumber} to changeset file`);
await appendIssueNumberToChangesetFile(changesetFile, issueNumber);
}

async function appendIssueNumberToChangesetFile(
changesetFile: string,
issueNumber: string
) {
const gitTopLevel = await getGitTopLevel();
const fullChangesetPath = join(gitTopLevel, changesetFile);
const changesetContents = await fs.readFile(fullChangesetPath, "utf-8");
// Check if the issue number is already in the changeset file
if (changesetContents.includes(issueNumber)) {
core.info("Issue number already exists in changeset file, skipping...");
return;
}

const updatedChangesetContents = `${changesetContents}\n\n${issueNumber}`;
await fs.writeFile(fullChangesetPath, updatedChangesetContents);
}

function extractChangesetFile() {
const changesetFiles = process.env.CHANGESET_FILES;
if (!changesetFiles) {
throw Error("Missing required environment variable CHANGESET_FILES");
}
const parsedChangesetFiles = JSON.parse(changesetFiles);
if (parsedChangesetFiles.length !== 1) {
throw Error(
"This action only supports one changeset file per pull request."
);
}
const [changesetFile] = parsedChangesetFiles;

return { changesetFile };
}

async function run() {
Expand Down
106 changes: 104 additions & 2 deletions .github/scripts/jira/lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { expect, describe, it } from "vitest";
import { parseIssueNumberFrom, tagsToLabels } from "./lib";
import { expect, describe, it, vi } from "vitest";
import {
generateIssueLabel,
generateJiraIssuesLink,
getGitTopLevel,
parseIssueNumberFrom,
tagsToLabels,
} from "./lib";
import * as core from "@actions/core";

describe("parseIssueNumberFrom", () => {
it("should return the first JIRA issue number found", () => {
Expand Down Expand Up @@ -33,6 +40,20 @@ CORE-1011`,
const result = parseIssueNumberFrom("No issue number");
expect(result).to.be.undefined;
});

it("works when the label is in the middle of the commit message", () => {
let r = parseIssueNumberFrom(
"This is a commit message with CORE-123 in the middle",
"CORE-456",
"CORE-789"
);
expect(r).to.equal("CORE-123");

r = parseIssueNumberFrom(
"#internal address security vulnerabilities RE-2917 around updating nodes and node operators on capabilities registry"
);
expect(r).to.equal("RE-2917");
});
});

describe("tagsToLabels", () => {
Expand All @@ -45,3 +66,84 @@ describe("tagsToLabels", () => {
]);
});
});

const mockExecPromise = vi.fn();
vi.mock("util", () => ({
promisify: () => mockExecPromise,
}));

describe("getGitTopLevel", () => {
it("should log the top-level directory when git command succeeds", async () => {
mockExecPromise.mockResolvedValueOnce({
stdout: "/path/to/top-level-dir",
stderr: "",
});

const mockConsoleLog = vi.spyOn(core, "info");
await getGitTopLevel();

expect(mockExecPromise).toHaveBeenCalledWith(
"git rev-parse --show-toplevel"
);
expect(mockConsoleLog).toHaveBeenCalledWith(
"Top-level directory: /path/to/top-level-dir"
);
});

it("should log an error message when git command fails", async () => {
mockExecPromise.mockRejectedValueOnce({
message: "Command failed",
});

const mockConsoleError = vi.spyOn(core, "error");
await getGitTopLevel().catch(() => {});

expect(mockExecPromise).toHaveBeenCalledWith(
"git rev-parse --show-toplevel"
);
expect(mockConsoleError).toHaveBeenCalledWith(
"Error executing command: Command failed"
);
});

it("should log an error message when git command output contains an error", async () => {
mockExecPromise.mockResolvedValueOnce({
stdout: "",
stderr: "Error: Command failed",
});

const mockConsoleError = vi.spyOn(core, "error");
await getGitTopLevel().catch(() => {});

expect(mockExecPromise).toHaveBeenCalledWith(
"git rev-parse --show-toplevel"
);
expect(mockConsoleError).toHaveBeenCalledWith(
"Error in command output: Error: Command failed"
);
});
});

describe("generateJiraIssuesLink", () => {
it("should generate a Jira issues link", () => {
expect(
generateJiraIssuesLink(
"https://smartcontract-it.atlassian.net/issues/",
"review-artifacts-automation-base:0de9b3b-head:e5b3b9d"
)
).toMatchInlineSnapshot(
`"https://smartcontract-it.atlassian.net/issues/?jql=labels+%3D+%22review-artifacts-automation-base%3A0de9b3b-head%3Ae5b3b9d%22"`
);
});
});

describe("generateIssueLabel", () => {
it("should generate an issue label", () => {
const product = "automation";
const baseRef = "0de9b3b";
const headRef = "e5b3b9d";
expect(generateIssueLabel(product, baseRef, headRef)).toMatchInlineSnapshot(
`"review-artifacts-automation-base:0de9b3b-head:e5b3b9d"`
);
});
});
Loading

0 comments on commit ca7b958

Please sign in to comment.