From 6a61724407b2ffb4708877c4da90bae310685b32 Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 01:52:24 +0000 Subject: [PATCH 1/6] Adding app-name-matcher to Action params --- __tests__/main.test.ts | 36 ++++- action.yml | 4 + dist/index.js | 341 ++++++++++++++++++++------------------- src/main.ts | 357 +++++++++++++++++++++-------------------- 4 files changed, 398 insertions(+), 340 deletions(-) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index df4d99b..4d0d367 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,14 +1,44 @@ import os from 'os'; +import { run, filterAppsByName, type App } from '../src/main'; -describe('main', () => { +describe('Action', () => { // shows how the runner will run a javascript action with env / stdout protocol - test('test runs', async () => { + test('runs', async () => { process.env['RUNNER_TEMP'] = os.tmpdir(); process.env['GITHUB_REPOSITORY'] = 'quizlet/cd-infra'; process.env['INPUT_GITHUB-TOKEN'] = '500'; process.env['INPUT_ARGOCD-VERSION'] = 'v1.6.1'; process.env['INPUT_ARGOCD-SERVER-URL'] = 'argocd.qzlt.io'; process.env['INPUT_ARGOCD-TOKEN'] = 'foo'; - expect(import('../src/main')).resolves.toBeTruthy(); + expect(run()).rejects.toThrow(); + }); + + describe('matches app names', () => { + const makeApp = (name: string) => ({ metadata: { name } }) as App; + + test('allows all apps when matcher is empty', () => { + expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '')).toEqual([ + makeApp('foobar'), + makeApp('bazqux') + ]); + }); + + test('allows only apps when matcher is provided', () => { + expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], 'foobar')).toEqual([ + makeApp('foobar') + ]); + }); + + test('treats matcher as regex when it is delimited by slashes', () => { + expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '/bar$/')).toEqual([ + makeApp('foobar') + ]); + }); + + test('with negative lookahead in regex', () => { + expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '/^(?!foobar$).*$/')).toEqual( + [makeApp('bazqux')] + ); + }); }); }); diff --git a/action.yml b/action.yml index 08e263f..396de3e 100644 --- a/action.yml +++ b/action.yml @@ -28,6 +28,10 @@ inputs: description: Name of env to use in the diff title posted to the PR default: legacy required: false + app-name-matcher: + description: Comma-separated list or '/'-delimited regex of app names to include in diff output + default: "" + required: false runs: using: 'node20' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index c2122a4..4968b59 100644 --- a/dist/index.js +++ b/dist/index.js @@ -52,6 +52,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.filterAppsByName = filterAppsByName; +exports.run = run; const core = __importStar(__nccwpck_require__(7484)); const tc = __importStar(__nccwpck_require__(3472)); const child_process_1 = __nccwpck_require__(5317); @@ -59,106 +61,119 @@ const github = __importStar(__nccwpck_require__(3228)); const fs_1 = __importDefault(__nccwpck_require__(9896)); const path_1 = __importDefault(__nccwpck_require__(6928)); const node_fetch_1 = __importDefault(__nccwpck_require__(6705)); -const ARCH = process.env.ARCH || 'linux'; -const githubToken = core.getInput('github-token'); -core.info(githubToken); -const ARGOCD_SERVER_URL = core.getInput('argocd-server-url'); -const ARGOCD_TOKEN = core.getInput('argocd-token'); -const VERSION = core.getInput('argocd-version'); -const ENV = core.getInput('environment'); -const PLAINTEXT = core.getInput('plaintext').toLowerCase() === 'true'; -let EXTRA_CLI_ARGS = core.getInput('argocd-extra-cli-args'); -if (PLAINTEXT) { - EXTRA_CLI_ARGS += ' --plaintext'; -} -const octokit = github.getOctokit(githubToken); -function execCommand(command, options = {}) { - return new Promise((done, failed) => { - (0, child_process_1.exec)(command, options, (err, stdout, stderr) => { - const res = { - stdout, - stderr - }; - if (err) { - res.err = err; - failed(res); - return; - } - done(res); - }); - }); -} -function scrubSecrets(input) { - let output = input; - const authTokenMatches = input.match(/--auth-token=([\w.\S]+)/); - if (authTokenMatches) { - output = output.replace(new RegExp(authTokenMatches[1], 'g'), '***'); +function filterAppsByName(appsAffected, appNameMatcher) { + if (appNameMatcher.startsWith('/') && appNameMatcher.endsWith('/')) { + const appNameFilter = new RegExp(appNameMatcher.slice(1, -1)); + return appsAffected.filter(app => appNameFilter.test(app.metadata.name)); } - return output; -} -function setupArgoCDCommand() { - return __awaiter(this, void 0, void 0, function* () { - const argoBinaryPath = yield tc.downloadTool(`https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-${ARCH}-amd64`); - fs_1.default.chmodSync(argoBinaryPath, '755'); - return (params) => __awaiter(this, void 0, void 0, function* () { - return execCommand(`${argoBinaryPath} ${params} --auth-token=${ARGOCD_TOKEN} --server=${ARGOCD_SERVER_URL} ${EXTRA_CLI_ARGS}`); - }); - }); + else if (appNameMatcher !== '') { + const appNames = new Set(appNameMatcher.split(',')); + return appsAffected.filter(app => appNames.has(app.metadata.name)); + } + return appsAffected; } -function getApps() { +function run() { return __awaiter(this, void 0, void 0, function* () { - const protocol = PLAINTEXT ? 'http' : 'https'; - const url = `${protocol}://${ARGOCD_SERVER_URL}/api/v1/applications`; - core.info(`Fetching apps from: ${url}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let responseJson; - try { - const response = yield (0, node_fetch_1.default)(url, { - method: 'GET', - headers: { Cookie: `argocd.token=${ARGOCD_TOKEN}` } + const ARCH = process.env.ARCH || 'linux'; + const githubToken = core.getInput('github-token'); + core.info(githubToken); + const ARGOCD_SERVER_URL = core.getInput('argocd-server-url'); + const ARGOCD_TOKEN = core.getInput('argocd-token'); + const VERSION = core.getInput('argocd-version'); + const ENV = core.getInput('environment'); + const PLAINTEXT = core.getInput('plaintext').toLowerCase() === 'true'; + const APP_NAME_MATCHER = core.getInput('app-name-matcher'); + let EXTRA_CLI_ARGS = core.getInput('argocd-extra-cli-args'); + if (PLAINTEXT) { + EXTRA_CLI_ARGS += ' --plaintext'; + } + const octokit = github.getOctokit(githubToken); + function execCommand(command, options = {}) { + return new Promise((done, failed) => { + (0, child_process_1.exec)(command, options, (err, stdout, stderr) => { + const res = { + stdout, + stderr + }; + if (err) { + res.err = err; + failed(res); + return; + } + done(res); + }); }); - responseJson = yield response.json(); } - catch (e) { - if (e instanceof Error || typeof e === 'string') { - core.error(e); + function scrubSecrets(input) { + let output = input; + const authTokenMatches = input.match(/--auth-token=([\w.\S]+)/); + if (authTokenMatches) { + output = output.replace(new RegExp(authTokenMatches[1], 'g'), '***'); } + return output; } - const apps = responseJson.items; - const repoApps = apps.filter(app => { - const targetRevision = app.spec.source.targetRevision; - const targetPrimary = targetRevision === 'master' || targetRevision === 'main' || !targetRevision; - return (app.spec.source.repoURL.includes(`${github.context.repo.owner}/${github.context.repo.repo}`) && targetPrimary); - }); - const changedFiles = yield getChangedFiles(); - core.info(`Changed files: ${changedFiles.join(', ')}`); - const appsAffected = repoApps.filter(app => { - return partOfApp(changedFiles, app); - }); - return appsAffected; - }); -} -function postDiffComment(diffs) { - return __awaiter(this, void 0, void 0, function* () { - var _a, _b, _c; - const protocol = PLAINTEXT ? 'http' : 'https'; - const { owner, repo } = github.context.repo; - const sha = (_b = (_a = github.context.payload.pull_request) === null || _a === void 0 ? void 0 : _a.head) === null || _b === void 0 ? void 0 : _b.sha; - const commitLink = `https://github.com/${owner}/${repo}/pull/${github.context.issue.number}/commits/${sha}`; - const shortCommitSha = String(sha).slice(0, 7); - const filteredDiffs = diffs - .map(diff => { - diff.diff = filterDiff(diff.diff); - return diff; - }) - .filter(d => d.diff !== ''); - const prefixHeader = `## ArgoCD Diff on ${ENV}`; - const diffOutput = filteredDiffs.map(({ app, diff, error }) => ` + function setupArgoCDCommand() { + return __awaiter(this, void 0, void 0, function* () { + const argoBinaryPath = yield tc.downloadTool(`https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-${ARCH}-amd64`); + fs_1.default.chmodSync(argoBinaryPath, '755'); + return (params) => __awaiter(this, void 0, void 0, function* () { + return execCommand(`${argoBinaryPath} ${params} --auth-token=${ARGOCD_TOKEN} --server=${ARGOCD_SERVER_URL} ${EXTRA_CLI_ARGS}`); + }); + }); + } + function getApps() { + return __awaiter(this, void 0, void 0, function* () { + const protocol = PLAINTEXT ? 'http' : 'https'; + const url = `${protocol}://${ARGOCD_SERVER_URL}/api/v1/applications`; + core.info(`Fetching apps from: ${url}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let responseJson; + try { + const response = yield (0, node_fetch_1.default)(url, { + method: 'GET', + headers: { Cookie: `argocd.token=${ARGOCD_TOKEN}` } + }); + responseJson = yield response.json(); + } + catch (e) { + if (e instanceof Error || typeof e === 'string') { + core.setFailed(e); + } + return []; + } + const apps = responseJson.items; + const repoApps = apps.filter(app => { + const targetRevision = app.spec.source.targetRevision; + const targetPrimary = targetRevision === 'master' || targetRevision === 'main' || !targetRevision; + return (app.spec.source.repoURL.includes(`${github.context.repo.owner}/${github.context.repo.repo}`) && targetPrimary); + }); + const changedFiles = yield getChangedFiles(); + core.info(`Changed files: ${changedFiles.join(', ')}`); + const appsAffected = repoApps.filter(partOfApp.bind(null, changedFiles)); + return filterAppsByName(appsAffected, APP_NAME_MATCHER); + }); + } + function postDiffComment(diffs) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b, _c; + const protocol = PLAINTEXT ? 'http' : 'https'; + const { owner, repo } = github.context.repo; + const sha = (_b = (_a = github.context.payload.pull_request) === null || _a === void 0 ? void 0 : _a.head) === null || _b === void 0 ? void 0 : _b.sha; + const commitLink = `https://github.com/${owner}/${repo}/pull/${github.context.issue.number}/commits/${sha}`; + const shortCommitSha = String(sha).slice(0, 7); + const filteredDiffs = diffs + .map(diff => { + diff.diff = filterDiff(diff.diff); + return diff; + }) + .filter(d => d.diff !== ''); + const prefixHeader = `## ArgoCD Diff on ${ENV}`; + const diffOutput = filteredDiffs.map(({ app, diff, error }) => ` App: [\`${app.metadata.name}\`](${protocol}://${ARGOCD_SERVER_URL}/applications/${app.metadata.name}) YAML generation: ${error ? ' Error 🛑' : 'Success 🟢'} App sync status: ${app.status.sync.status === 'Synced' ? 'Synced ✅' : 'Out of Sync ⚠️ '} ${error - ? ` + ? ` **\`stderr:\`** \`\`\` ${error.stderr} @@ -169,20 +184,20 @@ ${error.stderr} ${JSON.stringify(error.err)} \`\`\` ` - : ''} + : ''} ${diff - ? ` + ? ` \`\`\`diff ${diff} \`\`\` ` - : ''} + : ''} --- `); - const output = scrubSecrets(` + const output = scrubSecrets(` ${prefixHeader} for commit [\`${shortCommitSha}\`](${commitLink}) _Updated at ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT_ ${diffOutput.join('\n')} @@ -193,71 +208,68 @@ _Updated at ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angele | ⚠️ | The app is out-of-sync in ArgoCD, and the diffs you see include those changes plus any from this PR. | | 🛑 | There was an error generating the ArgoCD diffs due to changes in this PR. | `); - const commentsResponse = yield octokit.rest.issues.listComments({ - issue_number: github.context.issue.number, - owner, - repo - }); - // Delete stale comments - for (const comment of commentsResponse.data) { - if ((_c = comment.body) === null || _c === void 0 ? void 0 : _c.includes(prefixHeader)) { - core.info(`deleting comment ${comment.id}`); - octokit.rest.issues.deleteComment({ + const commentsResponse = yield octokit.rest.issues.listComments({ + issue_number: github.context.issue.number, + owner, + repo + }); + // Delete stale comments + for (const comment of commentsResponse.data) { + if ((_c = comment.body) === null || _c === void 0 ? void 0 : _c.includes(prefixHeader)) { + core.info(`deleting comment ${comment.id}`); + octokit.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + } + // Only post a new comment when there are changes + if (filteredDiffs.length) { + octokit.rest.issues.createComment({ + issue_number: github.context.issue.number, + owner, + repo, + body: output + }); + } + }); + } + function getChangedFiles() { + return __awaiter(this, void 0, void 0, function* () { + const { owner, repo } = github.context.repo; + const pull_number = github.context.issue.number; + const listFilesResponse = yield octokit.rest.pulls.listFiles({ owner, repo, - comment_id: comment.id + pull_number }); - } + return listFilesResponse.data.map(file => file.filename); + }); } - // Only post a new comment when there are changes - if (filteredDiffs.length) { - octokit.rest.issues.createComment({ - issue_number: github.context.issue.number, - owner, - repo, - body: output + function partOfApp(changedFiles, app) { + const sourcePath = path_1.default.normalize(app.spec.source.path); + const appPath = getFirstTwoDirectories(sourcePath); + return changedFiles.some(file => { + const normalizedFilePath = path_1.default.normalize(file); + return normalizedFilePath.startsWith(appPath); }); } - }); -} -function getChangedFiles() { - return __awaiter(this, void 0, void 0, function* () { - const { owner, repo } = github.context.repo; - const pull_number = github.context.issue.number; - const listFilesResponse = yield octokit.rest.pulls.listFiles({ - owner, - repo, - pull_number - }); - const changedFiles = listFilesResponse.data.map(file => file.filename); - return changedFiles; - }); -} -function partOfApp(changedFiles, app) { - const sourcePath = path_1.default.normalize(app.spec.source.path); - const appPath = getFirstTwoDirectories(sourcePath); - return changedFiles.some(file => { - const normalizedFilePath = path_1.default.normalize(file); - return normalizedFilePath.startsWith(appPath); - }); -} -function getFirstTwoDirectories(filePath) { - const normalizedPath = path_1.default.normalize(filePath); - const parts = normalizedPath.split(path_1.default.sep).filter(Boolean); // filter(Boolean) removes empty strings - if (parts.length < 2) { - return parts.join(path_1.default.sep); // Return the entire path if less than two directories - } - return parts.slice(0, 2).join(path_1.default.sep); -} -function asyncForEach(array, callback) { - return __awaiter(this, void 0, void 0, function* () { - for (let index = 0; index < array.length; index++) { - yield callback(array[index], index, array); + function getFirstTwoDirectories(filePath) { + const normalizedPath = path_1.default.normalize(filePath); + const parts = normalizedPath.split(path_1.default.sep).filter(Boolean); // filter(Boolean) removes empty strings + if (parts.length < 2) { + return parts.join(path_1.default.sep); // Return the entire path if less than two directories + } + return parts.slice(0, 2).join(path_1.default.sep); + } + function asyncForEach(array, callback) { + return __awaiter(this, void 0, void 0, function* () { + for (let index = 0; index < array.length; index++) { + yield callback(array[index], index, array); + } + }); } - }); -} -function run() { - return __awaiter(this, void 0, void 0, function* () { const argocd = yield setupArgoCDCommand(); const apps = yield getApps(); core.info(`Found apps: ${apps.map(a => a.metadata.name).join(', ')}`); @@ -298,23 +310,22 @@ function filterDiff(diffText) { // Split the diff text into sections based on the headers const sections = diffText.split(/(?=^===== )/m); const filteredSection = sections - .map(section => { - return section - .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+argocd\.argoproj\.io\/instance:.*\n---\n>\s+argocd\.argoproj\.io\/instance:.*\n?/g, '') - .trim() - .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+app.kubernetes.io\/part-of:.*\n?/g, '') - .trim(); - }) - .filter(section => section.trim() !== ''); - const removeEmptyHeaders = filteredSection.filter(entry => { - // Remove empty strings and sections that are just headers with line numbers - return !entry.match(/^===== .*\/.* ======$/); - }); + .map(section => section + .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+argocd\.argoproj\.io\/instance:.*\n---\n>\s+argocd\.argoproj\.io\/instance:.*\n?/g, '') + .trim() + .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+app.kubernetes.io\/part-of:.*\n?/g, '') + .trim()) + .filter(section => section !== ''); + // Remove empty strings and sections that are just headers with line numbers + const removeEmptyHeaders = filteredSection.filter(entry => !entry.match(/^===== .*\/.* ======$/)); // Join the filtered sections back together return removeEmptyHeaders.join('\n').trim(); } -// eslint-disable-next-line github/no-then -run().catch(e => core.setFailed(e.message)); +// Avoid executing main automatically during tests +if (require.main === require.cache[eval('__filename')]) { + // eslint-disable-next-line github/no-then + run().catch(e => core.setFailed(e.message)); +} /***/ }), diff --git a/src/main.ts b/src/main.ts index 3bb18de..02bb3ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,13 @@ interface ExecResult { stderr: string; } -interface App { +interface Diff { + app: App; + diff: string; + error?: ExecResult; +} + +export interface App { metadata: { name: string }; spec: { source: { @@ -29,123 +35,131 @@ interface App { }; }; } -const ARCH = process.env.ARCH || 'linux'; -const githubToken = core.getInput('github-token'); -core.info(githubToken); - -const ARGOCD_SERVER_URL = core.getInput('argocd-server-url'); -const ARGOCD_TOKEN = core.getInput('argocd-token'); -const VERSION = core.getInput('argocd-version'); -const ENV = core.getInput('environment'); -const PLAINTEXT = core.getInput('plaintext').toLowerCase() === 'true'; -let EXTRA_CLI_ARGS = core.getInput('argocd-extra-cli-args'); -if (PLAINTEXT) { - EXTRA_CLI_ARGS += ' --plaintext'; -} -const octokit = github.getOctokit(githubToken); - -function execCommand(command: string, options: ExecOptions = {}): Promise { - return new Promise((done, failed) => { - exec(command, options, (err: ExecException | null, stdout: string, stderr: string): void => { - const res: ExecResult = { - stdout, - stderr - }; - if (err) { - res.err = err; - failed(res); - return; - } - done(res); - }); - }); +export function filterAppsByName(appsAffected: App[], appNameMatcher: string): App[] { + if (appNameMatcher.startsWith('/') && appNameMatcher.endsWith('/')) { + const appNameFilter = new RegExp(appNameMatcher.slice(1, -1)); + return appsAffected.filter(app => appNameFilter.test(app.metadata.name)); + } else if (appNameMatcher !== '') { + const appNames = new Set(appNameMatcher.split(',')); + return appsAffected.filter(app => appNames.has(app.metadata.name)); + } + return appsAffected; } -function scrubSecrets(input: string): string { - let output = input; - const authTokenMatches = input.match(/--auth-token=([\w.\S]+)/); - if (authTokenMatches) { - output = output.replace(new RegExp(authTokenMatches[1], 'g'), '***'); +export async function run(): Promise { + const ARCH = process.env.ARCH || 'linux'; + const githubToken = core.getInput('github-token'); + core.info(githubToken); + + const ARGOCD_SERVER_URL = core.getInput('argocd-server-url'); + const ARGOCD_TOKEN = core.getInput('argocd-token'); + const VERSION = core.getInput('argocd-version'); + const ENV = core.getInput('environment'); + const PLAINTEXT = core.getInput('plaintext').toLowerCase() === 'true'; + const APP_NAME_MATCHER = core.getInput('app-name-matcher'); + let EXTRA_CLI_ARGS = core.getInput('argocd-extra-cli-args'); + if (PLAINTEXT) { + EXTRA_CLI_ARGS += ' --plaintext'; } - return output; -} -async function setupArgoCDCommand(): Promise<(params: string) => Promise> { - const argoBinaryPath = await tc.downloadTool( - `https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-${ARCH}-amd64` - ); - fs.chmodSync(argoBinaryPath, '755'); + const octokit = github.getOctokit(githubToken); + + function execCommand(command: string, options: ExecOptions = {}): Promise { + return new Promise((done, failed) => { + exec(command, options, (err: ExecException | null, stdout: string, stderr: string): void => { + const res: ExecResult = { + stdout, + stderr + }; + if (err) { + res.err = err; + failed(res); + return; + } + done(res); + }); + }); + } - return async (params: string) => - execCommand( - `${argoBinaryPath} ${params} --auth-token=${ARGOCD_TOKEN} --server=${ARGOCD_SERVER_URL} ${EXTRA_CLI_ARGS}` + function scrubSecrets(input: string): string { + let output = input; + const authTokenMatches = input.match(/--auth-token=([\w.\S]+)/); + if (authTokenMatches) { + output = output.replace(new RegExp(authTokenMatches[1], 'g'), '***'); + } + return output; + } + + async function setupArgoCDCommand(): Promise<(params: string) => Promise> { + const argoBinaryPath = await tc.downloadTool( + `https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-${ARCH}-amd64` ); -} + fs.chmodSync(argoBinaryPath, '755'); -async function getApps(): Promise { - const protocol = PLAINTEXT ? 'http' : 'https'; - const url = `${protocol}://${ARGOCD_SERVER_URL}/api/v1/applications`; - core.info(`Fetching apps from: ${url}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let responseJson: any; - try { - const response = await nodeFetch(url, { - method: 'GET', - headers: { Cookie: `argocd.token=${ARGOCD_TOKEN}` } - }); - responseJson = await response.json(); - } catch (e) { - if (e instanceof Error || typeof e === 'string') { - core.error(e); + return async (params: string) => + execCommand( + `${argoBinaryPath} ${params} --auth-token=${ARGOCD_TOKEN} --server=${ARGOCD_SERVER_URL} ${EXTRA_CLI_ARGS}` + ); + } + + async function getApps(): Promise { + const protocol = PLAINTEXT ? 'http' : 'https'; + const url = `${protocol}://${ARGOCD_SERVER_URL}/api/v1/applications`; + core.info(`Fetching apps from: ${url}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let responseJson: any; + try { + const response = await nodeFetch(url, { + method: 'GET', + headers: { Cookie: `argocd.token=${ARGOCD_TOKEN}` } + }); + responseJson = await response.json(); + } catch (e) { + if (e instanceof Error || typeof e === 'string') { + core.setFailed(e); + } + return []; } + const apps = responseJson.items as App[]; + const repoApps = apps.filter(app => { + const targetRevision = app.spec.source.targetRevision; + const targetPrimary = + targetRevision === 'master' || targetRevision === 'main' || !targetRevision; + return ( + app.spec.source.repoURL.includes( + `${github.context.repo.owner}/${github.context.repo.repo}` + ) && targetPrimary + ); + }); + + const changedFiles = await getChangedFiles(); + core.info(`Changed files: ${changedFiles.join(', ')}`); + const appsAffected = repoApps.filter(partOfApp.bind(null, changedFiles)); + return filterAppsByName(appsAffected, APP_NAME_MATCHER); } - const apps = responseJson.items as App[]; - const repoApps = apps.filter(app => { - const targetRevision = app.spec.source.targetRevision; - const targetPrimary = - targetRevision === 'master' || targetRevision === 'main' || !targetRevision; - return ( - app.spec.source.repoURL.includes( - `${github.context.repo.owner}/${github.context.repo.repo}` - ) && targetPrimary - ); - }); - const changedFiles = await getChangedFiles(); - core.info(`Changed files: ${changedFiles.join(', ')}`); - const appsAffected = repoApps.filter(app => { - return partOfApp(changedFiles, app); - }); - return appsAffected; -} + async function postDiffComment(diffs: Diff[]): Promise { + const protocol = PLAINTEXT ? 'http' : 'https'; + const { owner, repo } = github.context.repo; + const sha = github.context.payload.pull_request?.head?.sha; -interface Diff { - app: App; - diff: string; - error?: ExecResult; -} -async function postDiffComment(diffs: Diff[]): Promise { - const protocol = PLAINTEXT ? 'http' : 'https'; - const { owner, repo } = github.context.repo; - const sha = github.context.payload.pull_request?.head?.sha; - - const commitLink = `https://github.com/${owner}/${repo}/pull/${github.context.issue.number}/commits/${sha}`; - const shortCommitSha = String(sha).slice(0, 7); - - const filteredDiffs = diffs - .map(diff => { - diff.diff = filterDiff(diff.diff); - return diff; - }) - .filter(d => d.diff !== ''); - - const prefixHeader = `## ArgoCD Diff on ${ENV}`; - const diffOutput = filteredDiffs.map( - ({ app, diff, error }) => ` + const commitLink = `https://github.com/${owner}/${repo}/pull/${github.context.issue.number}/commits/${sha}`; + const shortCommitSha = String(sha).slice(0, 7); + + const filteredDiffs = diffs + .map(diff => { + diff.diff = filterDiff(diff.diff); + return diff; + }) + .filter(d => d.diff !== ''); + + const prefixHeader = `## ArgoCD Diff on ${ENV}`; + const diffOutput = filteredDiffs.map( + ({ app, diff, error }) => ` App: [\`${app.metadata.name}\`](${protocol}://${ARGOCD_SERVER_URL}/applications/${ - app.metadata.name - }) + app.metadata.name + }) YAML generation: ${error ? ' Error 🛑' : 'Success 🟢'} App sync status: ${app.status.sync.status === 'Synced' ? 'Synced ✅' : 'Out of Sync ⚠️ '} ${ @@ -177,9 +191,9 @@ ${diff} } --- ` - ); + ); - const output = scrubSecrets(` + const output = scrubSecrets(` ${prefixHeader} for commit [\`${shortCommitSha}\`](${commitLink}) _Updated at ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT_ ${diffOutput.join('\n')} @@ -191,78 +205,76 @@ _Updated at ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angele | 🛑 | There was an error generating the ArgoCD diffs due to changes in this PR. | `); - const commentsResponse = await octokit.rest.issues.listComments({ - issue_number: github.context.issue.number, - owner, - repo - }); + const commentsResponse = await octokit.rest.issues.listComments({ + issue_number: github.context.issue.number, + owner, + repo + }); - // Delete stale comments - for (const comment of commentsResponse.data) { - if (comment.body?.includes(prefixHeader)) { - core.info(`deleting comment ${comment.id}`); - octokit.rest.issues.deleteComment({ + // Delete stale comments + for (const comment of commentsResponse.data) { + if (comment.body?.includes(prefixHeader)) { + core.info(`deleting comment ${comment.id}`); + octokit.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + } + + // Only post a new comment when there are changes + if (filteredDiffs.length) { + octokit.rest.issues.createComment({ + issue_number: github.context.issue.number, owner, repo, - comment_id: comment.id + body: output }); } } - // Only post a new comment when there are changes - if (filteredDiffs.length) { - octokit.rest.issues.createComment({ - issue_number: github.context.issue.number, + async function getChangedFiles(): Promise { + const { owner, repo } = github.context.repo; + const pull_number = github.context.issue.number; + + const listFilesResponse = await octokit.rest.pulls.listFiles({ owner, repo, - body: output + pull_number }); - } -} - -async function getChangedFiles(): Promise { - const { owner, repo } = github.context.repo; - const pull_number = github.context.issue.number; - const listFilesResponse = await octokit.rest.pulls.listFiles({ - owner, - repo, - pull_number - }); - - const changedFiles = listFilesResponse.data.map(file => file.filename); - return changedFiles; -} + return listFilesResponse.data.map(file => file.filename); + } -function partOfApp(changedFiles: string[], app: App): boolean { - const sourcePath = path.normalize(app.spec.source.path); - const appPath = getFirstTwoDirectories(sourcePath); + function partOfApp(changedFiles: string[], app: App): boolean { + const sourcePath = path.normalize(app.spec.source.path); + const appPath = getFirstTwoDirectories(sourcePath); - return changedFiles.some(file => { - const normalizedFilePath = path.normalize(file); - return normalizedFilePath.startsWith(appPath); - }); -} + return changedFiles.some(file => { + const normalizedFilePath = path.normalize(file); + return normalizedFilePath.startsWith(appPath); + }); + } -function getFirstTwoDirectories(filePath: string): string { - const normalizedPath = path.normalize(filePath); - const parts = normalizedPath.split(path.sep).filter(Boolean); // filter(Boolean) removes empty strings - if (parts.length < 2) { - return parts.join(path.sep); // Return the entire path if less than two directories + function getFirstTwoDirectories(filePath: string): string { + const normalizedPath = path.normalize(filePath); + const parts = normalizedPath.split(path.sep).filter(Boolean); // filter(Boolean) removes empty strings + if (parts.length < 2) { + return parts.join(path.sep); // Return the entire path if less than two directories + } + return parts.slice(0, 2).join(path.sep); } - return parts.slice(0, 2).join(path.sep); -} -async function asyncForEach( - array: T[], - callback: (item: T, i: number, arr: T[]) => Promise -): Promise { - for (let index = 0; index < array.length; index++) { - await callback(array[index], index, array); + async function asyncForEach( + array: T[], + callback: (item: T, i: number, arr: T[]) => Promise + ): Promise { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } } -} -async function run(): Promise { const argocd = await setupArgoCDCommand(); const apps = await getApps(); core.info(`Found apps: ${apps.map(a => a.metadata.name).join(', ')}`); @@ -304,26 +316,27 @@ function filterDiff(diffText: string): string { const sections = diffText.split(/(?=^===== )/m); const filteredSection = sections - .map(section => { - return section + .map(section => + section .replace( /(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+argocd\.argoproj\.io\/instance:.*\n---\n>\s+argocd\.argoproj\.io\/instance:.*\n?/g, '' ) .trim() .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+app.kubernetes.io\/part-of:.*\n?/g, '') - .trim(); - }) - .filter(section => section.trim() !== ''); + .trim() + ) + .filter(section => section !== ''); - const removeEmptyHeaders = filteredSection.filter(entry => { - // Remove empty strings and sections that are just headers with line numbers - return !entry.match(/^===== .*\/.* ======$/); - }); + // Remove empty strings and sections that are just headers with line numbers + const removeEmptyHeaders = filteredSection.filter(entry => !entry.match(/^===== .*\/.* ======$/)); // Join the filtered sections back together return removeEmptyHeaders.join('\n').trim(); } -// eslint-disable-next-line github/no-then -run().catch(e => core.setFailed(e.message)); +// Avoid executing main automatically during tests +if (require.main === module) { + // eslint-disable-next-line github/no-then + run().catch(e => core.setFailed(e.message)); +} From 68b0654f302a07ccd60bc3011c9dfd694bbd0d4b Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 01:58:13 +0000 Subject: [PATCH 2/6] Expanding README example --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e13fe5..f81ac62 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ jobs: argocd-token: ${{ secrets.ARGOCD_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} argocd-version: v1.6.1 - argocd-extra-cli-args: --grpc-web + argocd-extra-cli-args: --grpc-web # optional + app-name-matcher: "/^myapp-/" # optional + plaintext: true # optional + environment: myenv # optional ``` ## How it works From 5d695bb303c68f35b102c2dc476a855e83718b96 Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 02:01:16 +0000 Subject: [PATCH 3/6] Splitting Actions --- .github/workflows/test.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94027b6..0010103 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: "build-test" +name: "test" on: # rebuild any PRs and main branch changes pull_request: push: @@ -7,14 +7,6 @@ on: # rebuild any PRs and main branch changes - 'releases/*' jobs: - build: # make sure build/ci work properly - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: | - npm install - npm run all - test: # make sure the action works on a clean machine without building runs-on: ubuntu-latest steps: From b529dcd4708af2c9106bfb8dfdae5482978f2908 Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 02:03:15 +0000 Subject: [PATCH 4/6] Re-adding build Action --- .github/workflows/build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ca674f8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,16 @@ +name: "build" +on: # rebuild any PRs and main branch changes + pull_request: + push: + branches: + - master + - 'releases/*' + +jobs: + build: # make sure build/ci work properly + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + npm install + npm run all From b7e9805c01eea2242c99d603b46933282d8972e2 Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 16:59:43 +0000 Subject: [PATCH 5/6] Marking generated code --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6ba5456 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf + +dist/** -diff linguist-generated=true From e8bd7a4de7c8943fd7a065cf8529952690064b2b Mon Sep 17 00:00:00 2001 From: Karoun Kasraie Date: Thu, 5 Dec 2024 16:59:55 +0000 Subject: [PATCH 6/6] Adding .env example --- .env.example | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4dd613 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Do not commit your actual .env file to Git! This may contain secrets or other +# private information. + +# Enable/disable step debug logging (default: `false`). For local debugging, it +# may be useful to set it to `true`. +ACTIONS_STEP_DEBUG=true + +# GitHub Actions inputs should follow `INPUT_` format (case-sensitive). +# Hyphens should not be converted to underscores! +# INPUT_MILLISECONDS=2400 + +# GitHub Actions default environment variables. These are set for every run of a +# workflow and can be used in your actions. Setting the value here will override +# any value set by the local-action tool. +# https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + +# CI="true" +# GITHUB_ACTION="" +# GITHUB_ACTION_PATH="" +# GITHUB_ACTION_REPOSITORY="" +# GITHUB_ACTIONS="" +# GITHUB_ACTOR="" +# GITHUB_ACTOR_ID="" +# GITHUB_API_URL="" +# GITHUB_BASE_REF="" +# GITHUB_ENV="" +# GITHUB_EVENT_NAME="" +# GITHUB_EVENT_PATH="" +# GITHUB_GRAPHQL_URL="" +# GITHUB_HEAD_REF="" +# GITHUB_JOB="" +# GITHUB_OUTPUT="" +# GITHUB_PATH="" +# GITHUB_REF="" +# GITHUB_REF_NAME="" +# GITHUB_REF_PROTECTED="" +# GITHUB_REF_TYPE="" +# GITHUB_REPOSITORY="" +# GITHUB_REPOSITORY_ID="" +# GITHUB_REPOSITORY_OWNER="" +# GITHUB_REPOSITORY_OWNER_ID="" +# GITHUB_RETENTION_DAYS="" +# GITHUB_RUN_ATTEMPT="" +# GITHUB_RUN_ID="" +# GITHUB_RUN_NUMBER="" +# GITHUB_SERVER_URL="" +# GITHUB_SHA="" +# GITHUB_STEP_SUMMARY="" +# GITHUB_TRIGGERING_ACTOR="" +# GITHUB_WORKFLOW="" +# GITHUB_WORKFLOW_REF="" +# GITHUB_WORKFLOW_SHA="" +# GITHUB_WORKSPACE="" +# RUNNER_ARCH="" +# RUNNER_DEBUG="" +# RUNNER_NAME="" +# RUNNER_OS="" +# RUNNER_TEMP="" +# RUNNER_TOOL_CACHE="" \ No newline at end of file