From ca7b95841843e8e315d0be3c35b4911310cbc175 Mon Sep 17 00:00:00 2001 From: HenryNguyen5 <6404866+HenryNguyen5@users.noreply.github.com> Date: Tue, 27 Aug 2024 14:58:19 -0700 Subject: [PATCH] RE 2877 Jira<->Changeset traceability for solidity changes (#14141) * 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 --- .../scripts/jira/create-jira-traceability.ts | 205 ++++++++++++++++++ .github/scripts/jira/enforce-jira-issue.ts | 46 +++- .github/scripts/jira/lib.test.ts | 106 ++++++++- .github/scripts/jira/lib.ts | 74 ++++++- .github/scripts/jira/package.json | 1 + .github/workflows/changeset.yml | 25 --- .../workflows/solidity-foundry-artifacts.yml | 64 ++++-- .github/workflows/solidity-jira.yml | 100 --------- .github/workflows/solidity-tracability.yml | 136 ++++++++++++ 9 files changed, 611 insertions(+), 146 deletions(-) create mode 100644 .github/scripts/jira/create-jira-traceability.ts delete mode 100644 .github/workflows/solidity-jira.yml create mode 100644 .github/workflows/solidity-tracability.yml diff --git a/.github/scripts/jira/create-jira-traceability.ts b/.github/scripts/jira/create-jira-traceability.ts new file mode 100644 index 00000000000..b151c9d5eab --- /dev/null +++ b/.github/scripts/jira/create-jira-traceability.ts @@ -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(); diff --git a/.github/scripts/jira/enforce-jira-issue.ts b/.github/scripts/jira/enforce-jira-issue.ts index e0054b25d0e..9d8c6e490ee 100644 --- a/.github/scripts/jira/enforce-jira-issue.ts +++ b/.github/scripts/jira/enforce-jira-issue.ts @@ -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, @@ -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 @@ -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() { diff --git a/.github/scripts/jira/lib.test.ts b/.github/scripts/jira/lib.test.ts index 9c751e84088..6ef629a53ed 100644 --- a/.github/scripts/jira/lib.test.ts +++ b/.github/scripts/jira/lib.test.ts @@ -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", () => { @@ -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", () => { @@ -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"` + ); + }); +}); diff --git a/.github/scripts/jira/lib.ts b/.github/scripts/jira/lib.ts index 72f1d57966c..0d0983f5c3e 100644 --- a/.github/scripts/jira/lib.ts +++ b/.github/scripts/jira/lib.ts @@ -1,6 +1,51 @@ +import { readFile } from "fs/promises"; +import * as core from "@actions/core"; +import * as jira from "jira.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { join } from "path"; -import * as core from '@actions/core' -import * as jira from 'jira.js' +export function generateJiraIssuesLink(baseUrl: string, label: string) { + // https://smartcontract-it.atlassian.net/issues/?jql=labels%20%3D%20%22review-artifacts-automation-base%3A8d818ea265ff08887e61ace4f83364a3ee149ef0-head%3A3c45b71f3610de28f429cef0163936eaa448e63c%22 + const jqlQuery = `labels = "${label}"`; + const fullUrl = new URL(baseUrl); + fullUrl.searchParams.set("jql", jqlQuery); + + const urlStr = fullUrl.toString(); + core.info(`Jira issues link: ${urlStr}`); + return urlStr; +} + +export function generateIssueLabel( + product: string, + baseRef: string, + headRef: string +) { + return `review-artifacts-${product}-base:${baseRef}-head:${headRef}`; +} + +export async function getGitTopLevel(): Promise { + const execPromise = promisify(exec); + try { + const { stdout, stderr } = await execPromise( + "git rev-parse --show-toplevel" + ); + + if (stderr) { + const msg = `Error in command output: ${stderr}`; + core.error(msg); + throw Error(msg); + } + + const topLevelDir = stdout.trim(); + core.info(`Top-level directory: ${topLevelDir}`); + return topLevelDir; + } catch (error) { + const msg = `Error executing command: ${(error as any).message}`; + core.error(msg); + throw Error(msg); + } +} /** * Given a list of strings, this function will return the first JIRA issue number it finds. @@ -24,6 +69,24 @@ export function parseIssueNumberFrom( return parsed[0]; } +export async function extractJiraIssueNumbersFrom(filePaths: string[]) { + const issueNumbers: string[] = []; + const gitTopLevel = await getGitTopLevel(); + + for (const path of filePaths) { + const fullPath = join(gitTopLevel, path); + core.info(`Reading file: ${fullPath}`); + const content = await readFile(fullPath, "utf-8"); + const issueNumber = parseIssueNumberFrom(content); + core.info(`Extracted issue number: ${issueNumber}`); + if (issueNumber) { + issueNumbers.push(issueNumber); + } + } + + return issueNumbers; +} + /** * Converts an array of tags to an array of labels. * @@ -39,7 +102,7 @@ export function tagsToLabels(tags: string[]) { })); } -export function createJiraClient() { +export function getJiraEnvVars() { const jiraHost = process.env.JIRA_HOST; const jiraUserName = process.env.JIRA_USERNAME; const jiraApiToken = process.env.JIRA_API_TOKEN; @@ -51,6 +114,11 @@ export function createJiraClient() { process.exit(1); } + return { jiraHost, jiraUserName, jiraApiToken }; +} + +export function createJiraClient() { + const { jiraHost, jiraUserName, jiraApiToken } = getJiraEnvVars(); return new jira.Version3Client({ host: jiraHost, authentication: { diff --git a/.github/scripts/jira/package.json b/.github/scripts/jira/package.json index 95bfbb1e486..94a805314af 100644 --- a/.github/scripts/jira/package.json +++ b/.github/scripts/jira/package.json @@ -15,6 +15,7 @@ "scripts": { "issue:update": "tsx update-jira-issue.ts", "issue:enforce": "tsx enforce-jira-issue.ts", + "issue:traceability": "tsx create-jira-traceability.ts", "test": "vitest" }, "dependencies": { diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 5e16b90c400..a89e91171e6 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -50,13 +50,8 @@ jobs: - '!core/**/*.json' - '!core/chainlink.goreleaser.Dockerfile' - '!core/chainlink.Dockerfile' - contracts: - - contracts/**/*.sol - - '!contracts/**/*.t.sol' core-changeset: - added: '.changeset/**' - contracts-changeset: - - added: 'contracts/.changeset/**' - name: Check for changeset tags for core id: changeset-tags @@ -120,19 +115,6 @@ jobs: mode: ${{ steps.files-changed.outputs.core-changeset == 'false' && 'upsert' || 'delete' }} create_if_not_exists: ${{ steps.files-changed.outputs.core-changeset == 'false' && 'true' || 'false' }} - - name: Make a comment - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - if: ${{ steps.files-changed.outputs.contracts == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - message: | - I see you updated files related to `contracts`. Please run `pnpm changeset` in the `contracts` directory to add a changeset. - reactions: eyes - comment_tag: changeset-contracts - mode: ${{ steps.files-changed.outputs.contracts-changeset == 'false' && 'upsert' || 'delete' }} - create_if_not_exists: ${{ steps.files-changed.outputs.contracts-changeset == 'false' && 'true' || 'false' }} - - name: Check for new changeset for core if: ${{ (steps.files-changed.outputs.core == 'true' || steps.files-changed.outputs.shared == 'true') && steps.files-changed.outputs.core-changeset == 'false' }} shell: bash @@ -140,13 +122,6 @@ jobs: echo "Please run pnpm changeset to add a changeset for core and include in the text at least one tag." exit 1 - - name: Check for new changeset for contracts - if: ${{ steps.files-changed.outputs.contracts == 'true' && steps.files-changed.outputs.contracts-changeset == 'false' }} - shell: bash - run: | - echo "Please run pnpm changeset to add a changeset for contracts." - exit 1 - - name: Make a comment uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 if: ${{ steps.files-changed.outputs.core-changeset == 'true' }} diff --git a/.github/workflows/solidity-foundry-artifacts.yml b/.github/workflows/solidity-foundry-artifacts.yml index 061caf1ea7f..5f03acbe1d6 100644 --- a/.github/workflows/solidity-foundry-artifacts.yml +++ b/.github/workflows/solidity-foundry-artifacts.yml @@ -28,6 +28,9 @@ on: env: FOUNDRY_PROFILE: ci + # Unfortunately, we can't use the "default" field in the inputs section, because it does not have + # access to the workflow context + head_ref: ${{ inputs.commit_to_use || github.sha }} jobs: changes: @@ -36,13 +39,13 @@ jobs: outputs: product_changes: ${{ steps.changes-transform.outputs.product_changes }} product_files: ${{ steps.changes-transform.outputs.product_files }} - changeset_changes: ${{ steps.changes-dorny.outputs.changeset }} - changeset_files: ${{ steps.changes-dorny.outputs.changeset_files }} + changeset_changes: ${{ steps.changes.outputs.changeset }} + changeset_files: ${{ steps.changes.outputs.changeset_files }} steps: - name: Checkout the repo uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: - ref: ${{ inputs.commit_to_use || github.sha }} + ref: ${{ env.head_ref }} - name: Find modified contracts uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: changes-dorny @@ -144,13 +147,13 @@ jobs: shell: bash run: | echo "Product: ${{ inputs.product }}" > contracts/commit_sha_base_ref.txt - echo "Commit SHA used to generate artifacts: ${{ inputs.commit_to_use || github.sha }}" >> contracts/commit_sha_base_ref.txt + echo "Commit SHA used to generate artifacts: ${{ env.head_ref }}" >> contracts/commit_sha_base_ref.txt echo "Base reference SHA used to find modified contracts: ${{ inputs.base_ref }}" >> contracts/commit_sha_base_ref.txt IFS=',' read -r -a modified_files <<< "${{ needs.changes.outputs.product_files }}" echo "# Modified contracts:" > contracts/modified_contracts.md for file in "${modified_files[@]}"; do - echo " - [$file](${{ github.server_url }}/${{ github.repository }}/blob/${{ inputs.commit_to_use || github.sha }}/$file)" >> contracts/modified_contracts.md + echo " - [$file](${{ github.server_url }}/${{ github.repository }}/blob/${{ env.head_ref }}/$file)" >> contracts/modified_contracts.md echo "$file" >> contracts/modified_contracts.txt done @@ -187,7 +190,7 @@ jobs: - name: Checkout the repo uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: - ref: ${{ inputs.commit_to_use || github.sha }} + ref: ${{ env.head_ref }} - name: Setup NodeJS uses: ./.github/actions/setup-nodejs @@ -268,7 +271,7 @@ jobs: uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 with: fetch-depth: 0 - ref: ${{ inputs.commit_to_use || github.sha }} + ref: ${{ env.head_ref }} - name: Setup NodeJS uses: ./.github/actions/setup-nodejs @@ -315,7 +318,7 @@ jobs: cp contracts/foundry.toml foundry.toml echo "::debug::Processing contracts: $contract_list" - ./contracts/scripts/ci/generate_slither_report.sh "${{ github.server_url }}/${{ github.repository }}/blob/${{ inputs.commit_to_use || github.sha }}/" contracts/configs/slither/.slither.config-artifacts.json "." "$contract_list" "contracts/slither-reports" "--solc-remaps @=contracts/node_modules/@" + ./contracts/scripts/ci/generate_slither_report.sh "${{ github.server_url }}/${{ github.repository }}/blob/${{ env.head_ref }}/" contracts/configs/slither/.slither.config-artifacts.json "." "$contract_list" "contracts/slither-reports" "--solc-remaps @=contracts/node_modules/@" - name: Upload UMLs and Slither reports uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 @@ -343,6 +346,11 @@ jobs: runs-on: ubuntu-latest needs: [coverage-and-book, uml-static-analysis, gather-basic-info, changes] steps: + - name: Checkout the repo + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + ref: ${{ env.head_ref }} + - name: Download all artifacts uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 with: @@ -352,7 +360,7 @@ jobs: - name: Upload all artifacts as single package uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: - name: review-artifacts-${{ inputs.product }}-${{ inputs.base_ref }}-${{ inputs.commit_to_use || github.sha }} + name: review-artifacts-${{ inputs.product }}-${{ inputs.base_ref }}-${{ env.head_ref }} path: review_artifacts - name: Remove temporary artifacts @@ -361,18 +369,46 @@ jobs: name: tmp-* - name: Print Artifact URL in job summary + id: gather-all-artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ARTIFACTS=$(gh api -X GET repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts) - ARTIFACT_ID=$(echo "$ARTIFACTS" | jq '.artifacts[] | select(.name=="review-artifacts-${{ inputs.product }}-${{ inputs.commit_to_use || github.sha }}") | .id') + ARTIFACT_ID=$(echo "$ARTIFACTS" | jq '.artifacts[] | select(.name=="review-artifacts-${{ inputs.product }}-${{ env.head_ref }}") | .id') echo "Artifact ID: $ARTIFACT_ID" echo "# Solidity Review Artifact Generated" >> $GITHUB_STEP_SUMMARY echo "Product: **${{ inputs.product }}**" >> $GITHUB_STEP_SUMMARY echo "Base Ref used: **${{ inputs.base_ref }}**" >> $GITHUB_STEP_SUMMARY - echo "Commit SHA used: **${{ inputs.commit_to_use || github.sha }}**" >> $GITHUB_STEP_SUMMARY - echo "[Artifact URL](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID)" >> $GITHUB_STEP_SUMMARY + echo "Commit SHA used: **${{ env.head_ref }}**" >> $GITHUB_STEP_SUMMARY + + artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID" + echo "[Artifact URL]($artifact_url)" >> $GITHUB_STEP_SUMMARY + echo "artifact-url=$artifact_url" >> $GITHUB_OUTPUT + + - name: Setup NodeJS + uses: ./.github/actions/setup-nodejs + + - name: Setup Jira + working-directory: ./.github/scripts/jira + run: pnpm i + + - name: Create Traceability + working-directory: ./.github/scripts/jira + run: | + pnpm issue:traceability + env: + CHANGESET_FILES: ${{ needs.changes.outputs.changeset_files }} + CHAINLINK_PRODUCT: ${{ inputs.product }} + BASE_REF: ${{ inputs.base_ref }} + HEAD_REF: ${{ env.head_ref }} + ARTIFACT_URL: ${{ steps.gather-all-artifacts.outputs.artifact-url }} + + JIRA_HOST: https://smartcontract-it.atlassian.net/ + JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} notify-no-changes: if: ${{ needs.changes.outputs.product_changes == 'false' }} @@ -384,9 +420,9 @@ jobs: run: | echo "# Solidity Review Artifact NOT Generated" >> $GITHUB_STEP_SUMMARY echo "Base Ref used: **${{ inputs.base_ref }}**" >> $GITHUB_STEP_SUMMARY - echo "Commit SHA used: **${{ inputs.commit_to_use || github.sha }}**" >> $GITHUB_STEP_SUMMARY + echo "Commit SHA used: **${{ env.head_ref }}**" >> $GITHUB_STEP_SUMMARY echo "## Reason: No modified Solidity files found for ${{ inputs.product }}" >> $GITHUB_STEP_SUMMARY - echo "* no modified Solidity files found between ${{ inputs.base_ref }} and ${{ inputs.commit_to_use || github.sha }} commits" >> $GITHUB_STEP_SUMMARY + echo "* no modified Solidity files found between ${{ inputs.base_ref }} and ${{ env.head_ref }} commits" >> $GITHUB_STEP_SUMMARY echo "* or they are located outside of ./contracts/src/v0.8 folder" >> $GITHUB_STEP_SUMMARY echo "* or they were limited to test files" >> $GITHUB_STEP_SUMMARY exit 1 diff --git a/.github/workflows/solidity-jira.yml b/.github/workflows/solidity-jira.yml deleted file mode 100644 index 1054bfa9875..00000000000 --- a/.github/workflows/solidity-jira.yml +++ /dev/null @@ -1,100 +0,0 @@ -# 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 diff --git a/.github/workflows/solidity-tracability.yml b/.github/workflows/solidity-tracability.yml new file mode 100644 index 00000000000..4acf94688a1 --- /dev/null +++ b/.github/workflows/solidity-tracability.yml @@ -0,0 +1,136 @@ +# This workflow handles the enforcement of code Traceability via changesets and jira issue linking for our Solidity codebase. +name: Solidity Tracability + +on: + merge_group: + pull_request: + +defaults: + run: + shell: bash + +jobs: + files-changed: + # The job skips on merge_group events, and any release branches, and forks + # 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/') && github.event.pull_request.head.repo.full_name == 'smartcontractkit/chainlink' }} + name: Detect Changes + runs-on: ubuntu-latest + outputs: + source: ${{ steps.files-changed.outputs.source }} + changesets: ${{ steps.files-changed.outputs.changesets }} + changesets_files: ${{ steps.files-changed.outputs.changesets_files }} + steps: + - name: Checkout the repo + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + + - name: Filter paths + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: files-changed + with: + list-files: "json" + # This is a valid input, see https://github.com/dorny/paths-filter/pull/226 + predicate-quantifier: "every" + filters: | + source: + - contracts/**/*.sol + - '!contracts/**/*.t.sol' + changesets: + - 'contracts/.changeset/**' + + enforce-traceability: + # 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. + needs: [files-changed] + # We only want to run this job if the source files have changed + if: ${{ needs.files-changed.outputs.source == 'true' }} + name: Enforce Traceability + runs-on: ubuntu-latest + permissions: + actions: read + id-token: write + contents: read + pull-requests: write + steps: + # https://github.com/planetscale/ghcommit-action/blob/c7915d6c18d5ce4eb42b0eff3f10a29fe0766e4c/README.md?plain=1#L41 + # + # Include the pull request ref in the checkout action to prevent merge commit + # https://github.com/actions/checkout?tab=readme-ov-file#checkout-pull-request-head-commit-instead-of-merge-commit + - name: Checkout the repo + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Assume role capable of dispatching action + uses: smartcontractkit/.github/actions/setup-github-token@ef78fa97bf3c77de6563db1175422703e9e6674f # setup-github-token@0.2.1 + id: get-gh-token + with: + aws-role-arn: ${{ secrets.AWS_OIDC_CHAINLINK_CI_AUTO_PR_TOKEN_ISSUER_ROLE_ARN }} + aws-lambda-url: ${{ secrets.AWS_INFRA_RELENG_TOKEN_ISSUER_LAMBDA_URL }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Make a comment + uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 + with: + message: | + I see you updated files related to `contracts`. Please run `pnpm changeset` in the `contracts` directory to add a changeset. + reactions: eyes + comment_tag: changeset-contracts + # If the changeset is added, then we delete the comment, otherwise we add it. + mode: ${{ needs.files-changed.outputs.changesets == 'true' && 'delete' || 'upsert' }} + # We only create the comment if the changeset is not added + create_if_not_exists: ${{ needs.files-changed.outputs.changesets == 'true' && 'false' || 'true' }} + + - name: Check for new changeset for contracts + if: ${{ needs.files-changed.outputs.changesets == 'false' }} + shell: bash + run: | + echo "Please run pnpm changeset to add a changeset for contracts." + exit 1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-nodejs + + - name: Setup Jira + working-directory: ./.github/scripts/jira + run: pnpm i + + # Because of our earlier checks, we know that both the source and changeset files have changed + - name: Enforce Traceability + working-directory: ./.github/scripts/jira + run: | + echo "COMMIT_MESSAGE=$(git log -1 --pretty=format:'%s')" >> $GITHUB_ENV + pnpm issue:enforce + env: + CHANGESET_FILES: ${{ needs.files-changed.outputs.changesets_files }} + + PR_TITLE: ${{ github.event.pull_request.title }} + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + + JIRA_HOST: https://smartcontract-it.atlassian.net/ + JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Commit appended changeset file back to repo + - uses: planetscale/ghcommit-action@13a844326508cdefc72235201bb0446d6d10a85f # v0.1.6 + with: + commit_message: "[Bot] Update changeset file with jira issue" + repo: ${{ github.repository }} + branch: ${{ github.head_ref }} + file_pattern: "contracts/.changeset/*" + env: + GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} + + - name: Collect Metrics + id: collect-gha-metrics + if: always() + uses: smartcontractkit/push-gha-metrics-action@d9da21a2747016b3e13de58c7d4115a3d5c97935 # v3.0.1 + with: + id: soldity-traceability + org-id: ${{ secrets.GRAFANA_INTERNAL_TENANT_ID }} + basic-auth: ${{ secrets.GRAFANA_INTERNAL_BASIC_AUTH }} + hostname: ${{ secrets.GRAFANA_INTERNAL_HOST }} + this-job-name: Enforce Traceability + continue-on-error: true