diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml new file mode 100644 index 000000000000..17e18e6e53f0 --- /dev/null +++ b/.github/workflows/failureNotifier.yml @@ -0,0 +1,96 @@ +name: Notify on Workflow Failure + +on: + workflow_run: + workflows: ["Process new code merged to main"] + types: + - completed + +permissions: + issues: write + +jobs: + notifyFailure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - name: Fetch Workflow Run Jobs + id: fetch-workflow-jobs + uses: actions/github-script@v7 + with: + script: | + const runId = "${{ github.event.workflow_run.id }}"; + const jobsData = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + return jobsData.data; + + - name: Process Each Failed Job + uses: actions/github-script@v7 + with: + script: | + const jobs = ${{ steps.fetch-workflow-jobs.outputs.result }}; + + const headCommit = "${{ github.event.workflow_run.head_commit.id }}"; + const prData = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: headCommit, + }); + + const pr = prData.data[0]; + const prLink = pr.html_url; + const prAuthor = pr.user.login; + const prMerger = "${{ github.event.workflow_run.actor.login }}"; + + const failureLabel = 'Workflow Failure'; + for (let i = 0; i < jobs.total_count; i++) { + if (jobs.jobs[i].conclusion == 'failure') { + const jobName = jobs.jobs[i].name; + const jobLink = jobs.jobs[i].html_url; + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: failureLabel, + state: 'open' + }); + const existingIssue = issues.data.find(issue => issue.title.includes(jobName)); + if (!existingIssue) { + const annotations = await github.rest.checks.listAnnotations({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: jobs.jobs[i].id, + }); + let errorMessage = ""; + for(let j = 0; j < annotations.data.length; j++) { + errorMessage += annotations.data[j].annotation_level + ": "; + errorMessage += annotations.data[j].message + "\n"; + } + const issueTitle = `Investigate workflow job failing on main: ${ jobName }`; + const issueBody = `🚨 **Failure Summary** 🚨:\n\n` + + `- **📋 Job Name**: [${ jobName }](${ jobLink })\n` + + `- **🔧 Failure in Workflow**: Process new code merged to main\n` + + `- **🔗 Triggered by PR**: [PR Link](${ prLink })\n` + + `- **👤 PR Author**: @${ prAuthor }\n` + + `- **🤝 Merged by**: @${ prMerger }\n` + + `- **🐛 Error Message**: \n ${errorMessage}\n\n` + + `⚠️ **Action Required** ⚠️:\n\n` + + `🛠️ A recent merge appears to have caused a failure in the job named [${ jobName }](${ jobLink }).\n` + + `This issue has been automatically created and labeled with \`${ failureLabel }\` for investigation. \n\n` + + `👀 **Please look into the following**:\n` + + `1. **Why the PR caused the job to fail?**\n` + + `2. **Address any underlying issues.**\n\n` + + `🐛 We appreciate your help in squashing this bug!`; + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: [failureLabel, 'Daily'], + assignees: [prMerger, prAuthor] + }); + } + } + } diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 8f9512062e9d..f09865de0194 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -1,3 +1,4 @@ +# Reminder: If this workflow's name changes, update the name in the dependent workflow at .github/workflows/failureNotifier.yml. name: Process new code merged to main on: diff --git a/workflow_tests/assertions/failureNotifierAssertions.js b/workflow_tests/assertions/failureNotifierAssertions.js new file mode 100644 index 000000000000..2491c4fa8469 --- /dev/null +++ b/workflow_tests/assertions/failureNotifierAssertions.js @@ -0,0 +1,20 @@ +const utils = require('../utils/utils'); + +const assertNotifyFailureJobExecuted = (workflowResult, didExecute = true) => { + const steps = [ + utils.createStepAssertion('Fetch Workflow Run Jobs', true, null, 'NOTIFYFAILURE', 'Fetch Workflow Run Jobs', [], []), + utils.createStepAssertion('Process Each Failed Job', true, null, 'NOTIFYFAILURE', 'Process Each Failed Job', [], []), + ]; + + for (const expectedStep of steps) { + if (didExecute) { + expect(workflowResult).toEqual(expect.arrayContaining([expectedStep])); + } else { + expect(workflowResult).not.toEqual(expect.arrayContaining([expectedStep])); + } + } +}; + +module.exports = { + assertNotifyFailureJobExecuted, +}; diff --git a/workflow_tests/failureNotifier.test.js b/workflow_tests/failureNotifier.test.js new file mode 100644 index 000000000000..655d5ed64d83 --- /dev/null +++ b/workflow_tests/failureNotifier.test.js @@ -0,0 +1,59 @@ +const path = require('path'); +const kieMockGithub = require('@kie/mock-github'); +const assertions = require('./assertions/failureNotifierAssertions'); +const mocks = require('./mocks/failureNotifierMocks'); +const eAct = require('./utils/ExtendedAct'); + +jest.setTimeout(90 * 1000); +let mockGithub; +const FILES_TO_COPY_INTO_TEST_REPO = [ + { + src: path.resolve(__dirname, '..', '.github', 'workflows', 'failureNotifier.yml'), + dest: '.github/workflows/failureNotifier.yml', + }, +]; + +describe('test workflow failureNotifier', () => { + const actor = 'Dummy Actor'; + beforeEach(async () => { + // create a local repository and copy required files + mockGithub = new kieMockGithub.MockGithub({ + repo: { + testFailureNotifierWorkflowRepo: { + files: FILES_TO_COPY_INTO_TEST_REPO, + + // if any branches besides main are need add: pushedBranches: ['staging', 'production'], + }, + }, + }); + + await mockGithub.setup(); + }); + + afterEach(async () => { + await mockGithub.teardown(); + }); + it('runs the notify failure when main fails', async () => { + const repoPath = mockGithub.repo.getPath('testFailureNotifierWorkflowRepo') || ''; + const workflowPath = path.join(repoPath, '.github', 'workflows', 'failureNotifier.yml'); + let act = new eAct.ExtendedAct(repoPath, workflowPath); + const event = 'workflow_run'; + act = act.setEvent({ + workflow_run: { + name: 'Process new code merged to main', + conclusion: 'failure', + }, + }); + const testMockSteps = { + notifyFailure: mocks.FAILURENOTIFIER__NOTIFYFAILURE__STEP_MOCKS, + }; + const result = await act.runEvent(event, { + workflowFile: path.join(repoPath, '.github', 'workflows', 'failureNotifier.yml'), + mockSteps: testMockSteps, + actor, + }); + + // assert execution with imported assertions + assertions.assertNotifyFailureJobExecuted(result); + }); +}); diff --git a/workflow_tests/mocks/failureNotifierMocks.js b/workflow_tests/mocks/failureNotifierMocks.js new file mode 100644 index 000000000000..6d37f08ff7ac --- /dev/null +++ b/workflow_tests/mocks/failureNotifierMocks.js @@ -0,0 +1,11 @@ +/* eslint-disable rulesdir/no-negated-variables */ +const utils = require('../utils/utils'); + +// notifyfailure +const FAILURENOTIFIER__NOTIFYFAILURE__FETCH_WORKFLOW_RUN_JOBS__STEP_MOCK = utils.createMockStep('Fetch Workflow Run Jobs', 'Fetch Workflow Run Jobs', 'NOTIFYFAILURE', [], []); +const FAILURENOTIFIER__NOTIFYFAILURE__PROCESS_EACH_FAILED_JOB__STEP_MOCK = utils.createMockStep('Process Each Failed Job', 'Process Each Failed Job', 'NOTIFYFAILURE', [], []); +const FAILURENOTIFIER__NOTIFYFAILURE__STEP_MOCKS = [FAILURENOTIFIER__NOTIFYFAILURE__FETCH_WORKFLOW_RUN_JOBS__STEP_MOCK, FAILURENOTIFIER__NOTIFYFAILURE__PROCESS_EACH_FAILED_JOB__STEP_MOCK]; + +module.exports = { + FAILURENOTIFIER__NOTIFYFAILURE__STEP_MOCKS, +};