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 2877 Jira<->Changeset traceability for solidity changes #14141

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5139166
Migrate contract changeset handling to solidity-jira
HenryNguyen5 Aug 17, 2024
d5e936b
Update solidity-jira to handle jira tracability as a whole
HenryNguyen5 Aug 17, 2024
50ab692
Fix changesets output typo
HenryNguyen5 Aug 22, 2024
8accdb9
Make file paths absolute
HenryNguyen5 Aug 22, 2024
0a1dfc8
Prevent merge commits from being added by auto commit action
HenryNguyen5 Aug 22, 2024
2ec5506
Add issue number to tail of document rather than head
HenryNguyen5 Aug 22, 2024
5b7a182
Tighten up file pattern for committing
HenryNguyen5 Aug 22, 2024
95319fb
Add GATI so workflows run on auto commits
HenryNguyen5 Aug 22, 2024
3d02dfc
Modify workflow to generate jira traceabillity
HenryNguyen5 Aug 22, 2024
eb353dc
Add checkout
HenryNguyen5 Aug 22, 2024
d91b909
Fix typo
HenryNguyen5 Aug 22, 2024
c30a37c
Add better naming for job
HenryNguyen5 Aug 22, 2024
a42f66b
Add more logging
HenryNguyen5 Aug 22, 2024
7c4aae4
Append to step summary from within script
HenryNguyen5 Aug 22, 2024
e49d519
Extract functions and use labels over individual issues
HenryNguyen5 Aug 23, 2024
13dcfc8
Fix comments handling
HenryNguyen5 Aug 23, 2024
7ebea7a
Use plain artifacts URL for action
HenryNguyen5 Aug 23, 2024
3142af8
Add test for jira issues in the middle of comments
HenryNguyen5 Aug 23, 2024
de5bb6b
Formatting
HenryNguyen5 Aug 23, 2024
421da71
Actually write to step summary
HenryNguyen5 Aug 23, 2024
78ad248
Make jira host public
HenryNguyen5 Aug 23, 2024
0e7d6d7
Handle csv output rather than JSON
HenryNguyen5 Aug 23, 2024
a13ad1e
Fix typo in changeset reference
HenryNguyen5 Aug 23, 2024
99613b3
Comment out broken step
HenryNguyen5 Aug 23, 2024
f24703a
Notify that auto commits are made via bot
HenryNguyen5 Aug 23, 2024
8e9746e
Pin planetscale/ghcommit-action
HenryNguyen5 Aug 23, 2024
09fae75
Rename
HenryNguyen5 Aug 23, 2024
302db73
Add pull-requests write perm for comments
HenryNguyen5 Aug 25, 2024
74edbeb
Add always() to gha metrics
HenryNguyen5 Aug 26, 2024
fb4140c
Use env var for head_ref
HenryNguyen5 Aug 26, 2024
5086914
Formatting
HenryNguyen5 Aug 26, 2024
5034911
Use JIRA_HOST rather than hard coded URL
HenryNguyen5 Aug 26, 2024
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
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
Loading