diff --git a/check-failed-actions/action.yml b/check-failed-actions/action.yml new file mode 100644 index 0000000..3181d4f --- /dev/null +++ b/check-failed-actions/action.yml @@ -0,0 +1,6 @@ +name: Check Failed Actions +description: Check for any failed actions +runs: + using: node16 + pre: '../setup.mjs' + main: ../build/check-failed-actions/index.js diff --git a/package-lock.json b/package-lock.json index c962611..7ff0c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@actions/github": "^5.1.1", "@octokit/rest": "^19.0.7", "debug": "^4.3.4", - "semver": "^7.3.8" + "semver": "^7.3.8", + "undici": "^5.21.0" }, "bin": { "action": "bin/action" @@ -26,9 +27,9 @@ "@types/debug": "^4.1.7", "@types/semver": "^7.3.13", "@types/uuid": "^9.0.1", + "get-port": "^5.1.1", "nock": "^13.3.0", "rimraf": "^4.4.0", - "undici": "^5.21.0", "uuid": "^9.0.0" }, "engines": { @@ -2447,7 +2448,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, "dependencies": { "streamsearch": "^1.1.0" }, @@ -3734,6 +3734,18 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -6526,7 +6538,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, "engines": { "node": ">=10.0.0" } @@ -6946,7 +6957,6 @@ "version": "5.21.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.21.0.tgz", "integrity": "sha512-HOjK8l6a57b2ZGXOcUsI5NLfoTrfmbOl90ixJDl0AEFG4wgHNDQxtZy15/ZQp7HhjkpaGlp/eneMgtsu1dIlUA==", - "dev": true, "dependencies": { "busboy": "^1.6.0" }, diff --git a/package.json b/package.json index 9ff8716..142be80 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/debug": "^4.1.7", "@types/semver": "^7.3.13", "@types/uuid": "^9.0.1", + "get-port": "^5.1.1", "nock": "^13.3.0", "rimraf": "^4.4.0", "uuid": "^9.0.0" diff --git a/src/check-failed-actions/check-failed.spec.ts b/src/check-failed-actions/check-failed.spec.ts new file mode 100644 index 0000000..2c9bd9d --- /dev/null +++ b/src/check-failed-actions/check-failed.spec.ts @@ -0,0 +1,65 @@ +// check-failed-actions/check-failed.spec.ts + +import { strict as assert } from 'node:assert'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import { v4 as uuid } from 'uuid'; +import getPort from 'get-port'; + +import checkFailed from './check-failed'; + +describe('github', () => { + const tmpFile = path.join(os.tmpdir(), uuid()); + let server: http.Server; + let port: number; + let lastRequest: string; + + function setupServer() { + return http + .createServer((req, res) => { + let requestBody = ''; + req.on('data', (chunk) => { + requestBody += chunk; + }); + req.on('end', () => { + lastRequest = requestBody; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'Request body received successfully' })); + }); + }) + .listen(port, '::1'); + } + + beforeAll(async () => { + await fs.writeFile(tmpFile, JSON.stringify({ foo: 'bar' })); + port = await getPort(); + server = setupServer(); + }); + + afterAll(async () => { + await fs.rm(tmpFile); + server.close(); + }); + + it('Error - slack notified', async () => { + process.env['GITHUB_EVENT_PATH'] = tmpFile; + process.env['GITHUB_REPOSITORY'] = 'owner/repo'; + process.env['INPUT_FAILED'] = 'true'; + process.env['SLACK_FAILED_URL'] = `http://[::1]:${port}`; + + await checkFailed(); + assert.equal(lastRequest.includes('owner/repo'), true); + assert.equal(lastRequest.includes('*owner/repo - action failure*'), true); + }); + + it('No error - slack not called', async () => { + process.env['GITHUB_EVENT_PATH'] = tmpFile; + process.env['GITHUB_REPOSITORY'] = 'owner/repo'; + process.env['INPUT_FAILED'] = 'false'; + + await checkFailed(); + assert.ok(true); + }); +}); diff --git a/src/check-failed-actions/check-failed.ts b/src/check-failed-actions/check-failed.ts new file mode 100644 index 0000000..edd3883 --- /dev/null +++ b/src/check-failed-actions/check-failed.ts @@ -0,0 +1,30 @@ +// check-failed-actions/check-failed.ts + +import process from 'node:process'; +import { debug } from 'debug'; +import { getInput } from '@actions/core'; + +import { getPullRequestContext } from '../github-api'; +import slackPost from './slack'; + +const log = debug('check-failed-action'); +export default async function (): Promise { + log('Action starting'); + + const githubContext = await getPullRequestContext(); + if (!githubContext) { + log('Error - unable to get github context'); + return; + } + + const workFlowName = process.env['GITHUB_WORKFLOW'] ?? 'unknown'; + log('GITHUB_WORKFLOW', workFlowName); + + const branch = process.env['GITHUB_REF'] ?? 'unknown'; + + const failedJob = getInput('failed'); + log('Status received', failedJob); + if (failedJob === 'true') { + await slackPost(`${githubContext.owner}/${githubContext.repo}`, branch, workFlowName); + } +} diff --git a/src/check-failed-actions/index.ts b/src/check-failed-actions/index.ts new file mode 100644 index 0000000..8dad6ec --- /dev/null +++ b/src/check-failed-actions/index.ts @@ -0,0 +1,17 @@ +// check-failed-actions/index.ts + +import main from './check-failed'; + +main() + .then(() => { + process.stdin.destroy(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + }) + // eslint-disable-next-line unicorn/prefer-top-level-await + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Action Error - exit 1 - error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }); diff --git a/src/check-failed-actions/slack.ts b/src/check-failed-actions/slack.ts new file mode 100644 index 0000000..0474850 --- /dev/null +++ b/src/check-failed-actions/slack.ts @@ -0,0 +1,46 @@ +// check-failed-actions/slack.ts + +import { strict as assert } from 'node:assert'; +import debug from 'debug'; +import { fetch } from 'undici'; + +const log = debug('check-failed-action:slack'); + +export interface SlackMessage { + text: string; + attachments: [ + { + color: string; + text: string; + } + ]; +} + +async function postSlackMessage(slackMessage: SlackMessage): Promise { + try { + const slackUrl = process.env['SLACK_FAILED_URL']; + assert(slackUrl); + log('slack HTTP POST request options: ', JSON.stringify(slackMessage)); + await fetch(slackUrl, { + method: 'POST', + body: JSON.stringify(slackMessage), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (slackPostError) { + log(`slack HTTP POST Error: ${String(slackPostError)}`); + throw new Error(`slack HTTP POST Error: ${String(slackPostError)}`); + } +} + +export default async function (repoName: string, branch: string, actionName: string): Promise { + const slackMessage: SlackMessage = { + text: `*${repoName} - action failure*`, + attachments: [ + { + color: 'danger', + text: `*Details* \n - Action ${actionName} in ${repoName} in ${branch} has failed*`, + }, + ], + }; + await postSlackMessage(slackMessage); +}