From 523b4f4b06e6f38a418fea4debcc63893f92c98d Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:49:02 +0000 Subject: [PATCH 1/7] Refactor code for improved readability and consistency - Updated formatting and indentation across multiple files for better readability. - Simplified GraphQL query handling in `graphql.ts`. - Enhanced error handling and logging in various functions. - Improved comment generation logic in `pullRequestCommentContent.ts`. - Added TypeScript configuration for tests in `tsconfig.json`. - Ensured consistent use of single quotes in string literals. - Refactored functions to use arrow function syntax where applicable. - Cleaned up unused imports and variables across several modules. --- dist/index.js | 154 ++++++---- src/addEmptyCommit.ts | 99 ++++--- {__tests__ => src}/checkAllowList.test.ts | 297 +++++++++++-------- src/checkAllowList.ts | 102 ++++--- src/graphql.ts | 80 ++--- src/interfaces.ts | 58 ++-- src/main.ts | 6 +- src/persistence/persistence.ts | 4 +- src/pullrequest/pullRequestComment.ts | 124 +++++--- src/pullrequest/pullRequestCommentContent.ts | 195 ++++++------ src/pullrequest/pullRequestLock.ts | 30 +- src/pullrequest/signatureComment.ts | 144 +++++---- src/setStatus.ts | 21 +- src/setupClaCheck.ts | 57 +++- src/shared/getInputs.ts | 2 +- src/shared/pr-sign-comment.ts | 5 +- src/tsconfig.json | 12 + tsconfig.json | 3 +- 18 files changed, 823 insertions(+), 570 deletions(-) rename {__tests__ => src}/checkAllowList.test.ts (70%) create mode 100644 src/tsconfig.json diff --git a/dist/index.js b/dist/index.js index 98f1310a..b49634f3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -44,7 +44,7 @@ exports.checkAllowList = checkAllowList; const _ = __importStar(__nccwpck_require__(2356)); const input = __importStar(__nccwpck_require__(7189)); const persistence_1 = __nccwpck_require__(9947); -function isUserNotInAllowList(committer, usernameAllowListPatterns, domainAllowList) { +function isUserInAllowList(committer, usernameAllowListPatterns, domainAllowList) { for (let pattern of domainAllowList) { pattern = pattern.trim(); if (!pattern) @@ -55,7 +55,7 @@ function isUserNotInAllowList(committer, usernameAllowListPatterns, domainAllowL return true; } } - return usernameAllowListPatterns.filter(function (pattern) { + return (usernameAllowListPatterns.filter(function (pattern) { pattern = pattern.trim(); if (pattern.includes('*')) { // Escape regex special chars, replace \* with .*, and anchor properly @@ -63,11 +63,13 @@ function isUserNotInAllowList(committer, usernameAllowListPatterns, domainAllowL return new RegExp(regex).test(committer.name); } return pattern === committer.name; - }).length > 0; + }).length > 0); } async function checkAllowList(committers) { // Load allowlists at runtime (not module-load time) for testability - const usernameAllowListPatterns = input.getUsernameAllowList().split(','); + const usernameAllowListPatterns = input + .getUsernameAllowList() + .split(','); const domainAllowList = input.getDomainAllowList().split(','); const domainsFile = input.getDomainsFile(); if (domainsFile) { @@ -80,13 +82,14 @@ async function checkAllowList(committers) { } } catch (error) { - if (error.status != "404") { + if (error.status != '404') { throw new Error(`Could not retrieve whitelisted email domains. Status: ${error.status || 'unknown'}`); } } } - const committersAfterAllowListCheck = committers.filter(committer => committer && !(isUserNotInAllowList !== undefined && isUserNotInAllowList(committer, usernameAllowListPatterns, domainAllowList))); - return committersAfterAllowListCheck; + const remainingCommitters = committers.filter(committer => committer && + !isUserInAllowList(committer, usernameAllowListPatterns, domainAllowList)); + return remainingCommitters; } @@ -156,13 +159,16 @@ async function getCommitters() { email: edge.node.commit.author.email || '', pullRequestNo: github_1.context.issue.number }; - if (committers.length === 0 || committers.map((c) => { - return c.name; - }).indexOf(user.name) < 0) { + if (committers.length === 0 || + committers + .map(c => { + return c.name; + }) + .indexOf(user.name) < 0) { committers.push(user); } }); - filteredCommitters = committers.filter((committer) => { + filteredCommitters = committers.filter(committer => { return committer.id !== 41898282; }); return filteredCommitters; @@ -171,7 +177,10 @@ async function getCommitters() { throw new Error(`graphql call to get the committers details failed: ${e}`); } } -const extractUserFromCommit = (commit) => commit.author.user || commit.committer.user || commit.author || commit.committer; +const extractUserFromCommit = commit => commit.author.user || + commit.committer.user || + commit.author || + commit.committer; /***/ }), @@ -576,24 +585,36 @@ async function prCommentSetup(committerMap, committers) { } } async function createComment(signed, committerMap) { - await octokit_1.octokit.issues.createComment({ + await octokit_1.octokit.issues + .createComment({ owner: github_1.context.repo.owner, repo: github_1.context.repo.repo, issue_number: github_1.context.issue.number, body: (0, pullRequestCommentContent_1.commentContent)(signed, committerMap) - }).catch(error => { throw new Error(`Error occured when creating a pull request comment: ${error.message}`); }); + }) + .catch(error => { + throw new Error(`Error occured when creating a pull request comment: ${error.message}`); + }); } async function updateComment(signed, committerMap, claBotComment) { - await octokit_1.octokit.issues.updateComment({ + await octokit_1.octokit.issues + .updateComment({ owner: github_1.context.repo.owner, repo: github_1.context.repo.repo, comment_id: claBotComment.id, body: (0, pullRequestCommentContent_1.commentContent)(signed, committerMap) - }).catch(error => { throw new Error(`Error occured when updating the pull request comment: ${error.message}`); }); + }) + .catch(error => { + throw new Error(`Error occured when updating the pull request comment: ${error.message}`); + }); } async function getComment() { try { - const response = await octokit_1.octokit.issues.listComments({ owner: github_1.context.repo.owner, repo: github_1.context.repo.repo, issue_number: github_1.context.issue.number }); + const response = await octokit_1.octokit.issues.listComments({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + issue_number: github_1.context.issue.number + }); //TODO: check the below regex // using a `string` true or false purposely as github action input cannot have a boolean value if ((0, getInputs_1.getUseDcoFlag)() === 'true') { @@ -616,12 +637,15 @@ function prepareAllSignedCommitters(committerMap, signedInPrCommitters, committe let allSignedCommitters = []; /* * 1) already signed committers in the file 2) signed committers in the PR comment - */ + */ const ids = new Set(signedInPrCommitters.map(committer => committer.id)); - allSignedCommitters = [...signedInPrCommitters, ...committerMap.signed.filter(signedCommitter => !ids.has(signedCommitter.id))]; + allSignedCommitters = [ + ...signedInPrCommitters, + ...committerMap.signed.filter(signedCommitter => !ids.has(signedCommitter.id)) + ]; /* - * checking if all the unsigned committers have reacted to the PR comment (this is needed for changing the content of the PR comment to "All committers have signed the CLA") - */ + * checking if all the unsigned committers have reacted to the PR comment (this is needed for changing the content of the PR comment to "All committers have signed the CLA") + */ let allSignedFlag = committers.every(committer => allSignedCommitters.some(reactedCommitter => committer.id === reactedCommitter.id)); return allSignedFlag; } @@ -682,7 +706,8 @@ function commentContent(signed, committerMap) { } function dco(signed, committerMap) { if (signed) { - const line1 = input.getCustomAllSignedPrComment() || `All contributors have signed the DCO ✍️ ✅`; + const line1 = input.getCustomAllSignedPrComment() || + `All contributors have signed the DCO ✍️ ✅`; const text = `${line1}
Posted by the ****DCO Assistant Lite bot****.`; return text; } @@ -691,35 +716,44 @@ function dco(signed, committerMap) { committersCount = committerMap.signed.length + committerMap.notSigned.length; } let you = committersCount > 1 ? `you all` : `you`; - let lineOne = (input.getCustomNotSignedPrComment() || `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Developer Certificate of Origin](${input.getPathToDocument()}) before we can accept your contribution. You can sign the DCO by just posting a Pull Request Comment same as the below format.
`).replace('$you', you); + let lineOne = (input.getCustomNotSignedPrComment() || + `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Developer Certificate of Origin](${input.getPathToDocument()}) before we can accept your contribution. You can sign the DCO by just posting a Pull Request Comment same as the below format.
`).replace('$you', you); let text = `${lineOne} - - - - ${input.getCustomPrSignComment() || "I have read the DCO Document and I hereby sign the DCO"} + ${input.getCustomPrSignComment() || 'I have read the DCO Document and I hereby sign the DCO'} - - - `; - if (committersCount > 1 && committerMap && committerMap.signed && committerMap.notSigned) { + if (committersCount > 1 && + committerMap && + committerMap.signed && + committerMap.notSigned) { text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the DCO.`; - committerMap.signed.forEach(signedCommitter => { text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})`; }); + committerMap.signed.forEach(signedCommitter => { + text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})`; + }); committerMap.notSigned.forEach(unsignedCommitter => { text += `
:x: \`${unsignedCommitter.name}\``; }); text += '
'; } if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { - let seem = committerMap.unknown.length > 1 ? "seem" : "seems"; + let seem = committerMap.unknown.length > 1 ? 'seem' : 'seems'; let committerNames = committerMap.unknown.map(committer => committer.name); - text += `**${committerNames.join(", ")}** ${seem} not to be a GitHub user.`; - text += ' You need a GitHub account to be able to sign the DCO. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
'; + text += `**${committerNames.join(', ')}** ${seem} not to be a GitHub user.`; + text += + ' You need a GitHub account to be able to sign the DCO. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
'; } if (input.suggestRecheck() == 'true') { - text += 'You can retrigger this bot by commenting **recheck** in this Pull Request. '; + text += + 'You can retrigger this bot by commenting **recheck** in this Pull Request. '; } text += 'Posted by the ****DCO Assistant Lite bot****.'; return text; } function cla(signed, committerMap) { if (signed) { - const line1 = input.getCustomAllSignedPrComment() || `All contributors have signed the CLA ✍️ ✅`; + const line1 = input.getCustomAllSignedPrComment() || + `All contributors have signed the CLA ✍️ ✅`; const text = `${line1}
Posted by the ****CLA Assistant Lite bot****.`; return text; } @@ -728,28 +762,36 @@ function cla(signed, committerMap) { committersCount = committerMap.signed.length + committerMap.notSigned.length; } let you = committersCount > 1 ? `you all` : `you`; - let lineOne = (input.getCustomNotSignedPrComment() || `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Contributor License Agreement](${input.getPathToDocument()}) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.
`).replace('$you', you); + let lineOne = (input.getCustomNotSignedPrComment() || + `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Contributor License Agreement](${input.getPathToDocument()}) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.
`).replace('$you', you); let text = `${lineOne} - - - ${(0, pr_sign_comment_1.getPrSignComment)()} - - - `; - if (committersCount > 1 && committerMap && committerMap.signed && committerMap.notSigned) { + if (committersCount > 1 && + committerMap && + committerMap.signed && + committerMap.notSigned) { text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the CLA.`; - committerMap.signed.forEach(signedCommitter => { text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})`; }); + committerMap.signed.forEach(signedCommitter => { + text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})`; + }); committerMap.notSigned.forEach(unsignedCommitter => { text += `
:x: \`${unsignedCommitter.name}\``; }); text += '
'; } if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { - let seem = committerMap.unknown.length > 1 ? "seem" : "seems"; + let seem = committerMap.unknown.length > 1 ? 'seem' : 'seems'; let committerNames = committerMap.unknown.map(committer => committer.name); - text += `**${committerNames.join(", ")}** ${seem} not to be a GitHub user.`; - text += ' You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
'; + text += `**${committerNames.join(', ')}** ${seem} not to be a GitHub user.`; + text += + ' You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
'; } if (input.suggestRecheck() == 'true') { - text += 'You can retrigger this bot by commenting **recheck** in this Pull Request. '; + text += + 'You can retrigger this bot by commenting **recheck** in this Pull Request. '; } text += 'Posted by the **CLA Assistant Lite bot**.'; return text; @@ -839,7 +881,7 @@ async function signatureWithPRComment(committerMap, committers) { }); let listOfPRComments = []; let filteredListOfPRComments = []; - prResponse?.data.map((prComment) => { + prResponse?.data.map(prComment => { listOfPRComments.push({ name: prComment.user.login, id: prComment.user.id, @@ -851,7 +893,7 @@ async function signatureWithPRComment(committerMap, committers) { }); }); listOfPRComments.map(comment => { - if (isCommentSignedByUser(comment.body || "", comment.name)) { + if (isCommentSignedByUser(comment.body || '', comment.name)) { filteredListOfPRComments.push(comment); } }); @@ -859,12 +901,12 @@ async function signatureWithPRComment(committerMap, committers) { delete filteredListOfPRComments[i].body; } /* - *checking if the reacted committers are not the signed committers(not in the storage file) and filtering only the unsigned committers - */ + *checking if the reacted committers are not the signed committers(not in the storage file) and filtering only the unsigned committers + */ const newSigned = filteredListOfPRComments.filter(commentedCommitter => committerMap.notSigned.some(notSignedCommitter => commentedCommitter.id === notSignedCommitter.id)); /* - * checking if the commented users are only the contributors who has committed in the same PR (This is needed for the PR Comment and changing the status to success when all the contributors has reacted to the PR) - */ + * checking if the commented users are only the contributors who has committed in the same PR (This is needed for the PR Comment and changing the status to success when all the contributors has reacted to the PR) + */ const onlyCommitters = committers.filter(committer => filteredListOfPRComments.some(commentedCommitter => committer.id == commentedCommitter.id)); const commentedCommitterMap = { newSigned, @@ -877,15 +919,15 @@ function isCommentSignedByUser(comment, commentAuthor) { if (commentAuthor === 'github-actions[bot]') { return false; } - if ((0, getInputs_1.getCustomPrSignComment)() !== "") { + if ((0, getInputs_1.getCustomPrSignComment)() !== '') { return (0, getInputs_1.getCustomPrSignComment)().toLowerCase() === comment; } // using a `string` true or false purposely as github action input cannot have a boolean value switch ((0, getInputs_1.getUseDcoFlag)()) { case 'true': - return comment.match(/^.*i \s*have \s*read \s*the \s*dco \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*dco.*$/) !== null; + return (comment.match(/^.*i \s*have \s*read \s*the \s*dco \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*dco.*$/) !== null); case 'false': - return comment.match(/^.*i \s*have \s*read \s*the \s*cla \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*cla.*$/) !== null; + return (comment.match(/^.*i \s*have \s*read \s*the \s*cla \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*cla.*$/) !== null); default: return false; } @@ -976,19 +1018,26 @@ async function setupClaCheck() { } } async function createSuccessSummary(committerMap) { - const totalCount = (committerMap.signed?.length || 0) + (committerMap.notSigned?.length || 0) + (committerMap.unknown?.length || 0); + const totalCount = (committerMap.signed?.length || 0) + + (committerMap.notSigned?.length || 0) + + (committerMap.unknown?.length || 0); await core.summary .addHeading('✅ All Contributors Signed') .addRaw(`All ${totalCount} contributor(s) have signed the CLA.`) .addBreak() .addTable([ - [{ data: 'Contributor', header: true }, { data: 'Status', header: true }], + [ + { data: 'Contributor', header: true }, + { data: 'Status', header: true } + ], ...(committerMap.signed || []).map(c => [c.name, '✅ Signed']) ]) .write(); } async function createFailureSummary(committerMap) { - const totalCount = (committerMap.signed?.length || 0) + committerMap.notSigned.length + (committerMap.unknown?.length || 0); + const totalCount = (committerMap.signed?.length || 0) + + committerMap.notSigned.length + + (committerMap.unknown?.length || 0); const docUrl = input.getPathToDocument(); await core.summary .addHeading('❌ CLA Signature Required') @@ -1030,7 +1079,7 @@ async function getCLAFileContentandSHA(committers, committerMap) { result = await (0, persistence_1.getFileContent)(); } catch (error) { - if (error.status === "404") { + if (error.status === '404') { return createClaFileAndPRComment(committers, committerMap); } else { @@ -1204,7 +1253,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getPrSignComment = getPrSignComment; const input = __importStar(__nccwpck_require__(7189)); function getPrSignComment() { - return input.getCustomPrSignComment() || "I have read the CLA Document and I hereby sign the CLA"; + return (input.getCustomPrSignComment() || + 'I have read the CLA Document and I hereby sign the CLA'); } diff --git a/src/addEmptyCommit.ts b/src/addEmptyCommit.ts index 1d90454c..02955d20 100644 --- a/src/addEmptyCommit.ts +++ b/src/addEmptyCommit.ts @@ -5,57 +5,60 @@ import * as core from '@actions/core' import * as input from './shared/getInputs' import { getPrSignComment } from './shared/pr-sign-comment' - export async function addEmptyCommit() { - const contributorName: string = context?.payload?.comment?.user?.login - core.info(`Adding empty commit for ${contributorName} who has signed the CLA `) - - if (context.payload.comment) { - - //Do empty commit only when the contributor signs the CLA with the PR comment - if (context.payload.comment.body.toLowerCase().trim() === getPrSignComment().toLowerCase().trim()) { - try { - const message = input.getSignedCommitMessage() ? - input.getSignedCommitMessage().replace('$contributorName', contributorName) : - ` @${contributorName} has signed the CLA ` - const pullRequestResponse = await octokit.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.issue!.number - }) - - const baseCommit = await octokit.git.getCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: pullRequestResponse.data.head.sha - }) + const contributorName: string = context?.payload?.comment?.user?.login + core.info( + `Adding empty commit for ${contributorName} who has signed the CLA ` + ) - const tree = await octokit.git.getTree({ - owner: context.repo.owner, - repo: context.repo.repo, - tree_sha: baseCommit.data.tree.sha - }) - const newCommit = await octokit.git.createCommit( - { - owner: context.repo.owner, - repo: context.repo.repo, - message: message, - tree: tree.data.sha, - parents: [pullRequestResponse.data.head.sha] - } - ) - return octokit.git.updateRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${pullRequestResponse.data.head.ref}`, - sha: newCommit.data.sha - }) + if (context.payload.comment) { + //Do empty commit only when the contributor signs the CLA with the PR comment + if ( + context.payload.comment.body.toLowerCase().trim() === + getPrSignComment().toLowerCase().trim() + ) { + try { + const message = input.getSignedCommitMessage() + ? input + .getSignedCommitMessage() + .replace('$contributorName', contributorName) + : ` @${contributorName} has signed the CLA ` + const pullRequestResponse = await octokit.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue!.number + }) - } catch (error) { - core.error(`failed when adding empty commit with the contributor's signature name: ${error} `) + const baseCommit = await octokit.git.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: pullRequestResponse.data.head.sha + }) - } - } + const tree = await octokit.git.getTree({ + owner: context.repo.owner, + repo: context.repo.repo, + tree_sha: baseCommit.data.tree.sha + }) + const newCommit = await octokit.git.createCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + message: message, + tree: tree.data.sha, + parents: [pullRequestResponse.data.head.sha] + }) + return octokit.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${pullRequestResponse.data.head.ref}`, + sha: newCommit.data.sha + }) + } catch (error) { + core.error( + `failed when adding empty commit with the contributor's signature name: ${error} ` + ) + } } - return + } + return } diff --git a/__tests__/checkAllowList.test.ts b/src/checkAllowList.test.ts similarity index 70% rename from __tests__/checkAllowList.test.ts rename to src/checkAllowList.test.ts index 02cfa36f..50dbed8b 100644 --- a/__tests__/checkAllowList.test.ts +++ b/src/checkAllowList.test.ts @@ -1,10 +1,10 @@ -import { checkAllowList } from '../src/checkAllowList' -import * as input from '../src/shared/getInputs' -import { CommittersDetails } from '../src/interfaces' -import { getFileContent } from '../src/persistence/persistence' +import { checkAllowList } from './checkAllowList' +import * as input from './shared/getInputs' +import { CommittersDetails } from './interfaces' +import { getFileContent } from './persistence/persistence' -jest.mock('../src/shared/getInputs') -jest.mock('../src/persistence/persistence') +jest.mock('./shared/getInputs') +jest.mock('./persistence/persistence') const mockedGetUsernameAllowList = jest.mocked(input.getUsernameAllowList) const mockedGetDomainAllowList = jest.mocked(input.getDomainAllowList) @@ -44,14 +44,14 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Only lowercase 'copilot' should remain (not in allowlist) - // Uppercase 'Copilot' should be filtered (in allowlist) expect(result).toHaveLength(1) expect(result[0].name).toBe('copilot') }) it('should handle multiple exact matches', async () => { - mockedGetUsernameAllowList.mockReturnValue('dependabot, semantic-release-bot, copilot') + mockedGetUsernameAllowList.mockReturnValue( + 'dependabot, semantic-release-bot, copilot' + ) const committers: CommittersDetails[] = [ { name: 'dependabot', id: 1, email: '[email protected]' }, @@ -93,7 +93,6 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Only exact 'bot' should be filtered expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['bot-user', 'my-bot']) }) @@ -113,8 +112,6 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Should filter: dependabot, dependabot[bot], dependabot-preview - // Should keep: my-dependabot (doesn't start with dependabot), real-user expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['my-dependabot', 'real-user']) }) @@ -132,8 +129,6 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Should filter: my-bot, another-bot - // Should keep: bot, bot-user, real-user expect(result).toHaveLength(3) expect(result.map(c => c.name)).toEqual(['bot', 'bot-user', 'real-user']) }) @@ -150,8 +145,6 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Should filter: github-copilot[bot], github-actions[bot] - // Should keep: github-bot (doesn't end with [bot]), real-user expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['github-bot', 'real-user']) }) @@ -168,13 +161,14 @@ describe('checkAllowList', () => { const result = await checkAllowList(committers) - // Should filter all with 'bot' in them expect(result).toHaveLength(1) expect(result[0].name).toBe('real-user') }) it('should handle multiple wildcard patterns', async () => { - mockedGetUsernameAllowList.mockReturnValue('dependabot*, *[bot], semantic-*') + mockedGetUsernameAllowList.mockReturnValue( + 'dependabot*, *[bot], semantic-*' + ) const committers: CommittersDetails[] = [ { name: 'dependabot', id: 1, email: '[email protected]' }, @@ -196,14 +190,12 @@ describe('checkAllowList', () => { const committers: CommittersDetails[] = [ { name: 'bot.name', id: 1, email: '[email protected]' }, { name: 'bot.name-test', id: 2, email: '[email protected]' }, - { name: 'botXname', id: 3, email: '[email protected]' }, // should not match (. should be literal) + { name: 'botXname', id: 3, email: '[email protected]' }, { name: 'real-user', id: 4, email: '[email protected]' } ] const result = await checkAllowList(committers) - // Should filter: bot.name, bot.name-test - // Should keep: botXname (. is literal, not regex wildcard), real-user expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['botXname', 'real-user']) }) @@ -214,9 +206,9 @@ describe('checkAllowList', () => { mockedGetDomainAllowList.mockReturnValue('@example.com') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: 'user3', id: 3, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' }, + { name: 'user3', id: 3, email: 'user3@another.net' } ] const result = await checkAllowList(committers) @@ -229,8 +221,8 @@ describe('checkAllowList', () => { mockedGetDomainAllowList.mockReturnValue('example.com') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' } ] const result = await checkAllowList(committers) @@ -240,13 +232,15 @@ describe('checkAllowList', () => { }) it('should handle multiple email domains', async () => { - mockedGetDomainAllowList.mockReturnValue('@example.com, @test.org, @bot.io') + mockedGetDomainAllowList.mockReturnValue( + '@example.com, @test.org, @bot.io' + ) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: 'user3', id: 3, email: '[email protected]' }, - { name: 'user4', id: 4, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' }, + { name: 'user3', id: 3, email: 'user3@bot.io' }, + { name: 'user4', id: 4, email: 'user4@keep.me' } ] const result = await checkAllowList(committers) @@ -259,55 +253,53 @@ describe('checkAllowList', () => { mockedGetDomainAllowList.mockReturnValue('@example.com') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2 }, // no email - { name: 'user3', id: 3, email: undefined } // undefined email + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2 }, + { name: 'user3', id: 3, email: undefined } ] const result = await checkAllowList(committers) - // user1 filtered by domain, user2 and user3 kept (no email to check) expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['user2', 'user3']) }) - it('should match subdomain emails correctly', async () => { + it('should not match subdomain emails', async () => { mockedGetDomainAllowList.mockReturnValue('@example.com') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: 'user3', id: 3, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@sub.example.com' }, + { name: 'user3', id: 3, email: 'user3@users.noreply.example.com' } ] const result = await checkAllowList(committers) - // All should be filtered (all end with @example.com or subdomain) - expect(result).toHaveLength(0) + expect(result).toHaveLength(2) + expect(result.map(c => c.name)).toEqual(['user2', 'user3']) }) it('should not match partial domain names', async () => { mockedGetDomainAllowList.mockReturnValue('@example.com') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, // should NOT match - { name: 'user3', id: 3, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@example.company' }, + { name: 'user3', id: 3, email: 'user3@anotherexample.com' } ] const result = await checkAllowList(committers) - // user1 and user3 filtered, user2 kept (different domain) - expect(result).toHaveLength(1) - expect(result[0].name).toBe('user2') + expect(result).toHaveLength(2) + expect(result.map(c => c.name)).toEqual(['user2', 'user3']) }) it('should skip empty domain patterns', async () => { mockedGetDomainAllowList.mockReturnValue(' , @example.com , ') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' } ] const result = await checkAllowList(committers) @@ -315,6 +307,20 @@ describe('checkAllowList', () => { expect(result).toHaveLength(1) expect(result[0].name).toBe('user2') }) + + it('should treat uppercase domains as distinct values', async () => { + mockedGetDomainAllowList.mockReturnValue('@example.com') + + const committers: CommittersDetails[] = [ + { name: 'lowercase-user', id: 1, email: 'user@example.com' }, + { name: 'uppercase-user', id: 2, email: 'user@EXAMPLE.COM' } + ] + + const result = await checkAllowList(committers) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('uppercase-user') + }) }) describe('Combined username and email domain matching', () => { @@ -323,15 +329,14 @@ describe('checkAllowList', () => { mockedGetDomainAllowList.mockReturnValue('@bot.example.com') const committers: CommittersDetails[] = [ - { name: 'dependabot', id: 1, email: '[email protected]' }, - { name: 'copilot-agent', id: 2, email: '[email protected]' }, - { name: 'bot-service', id: 3, email: '[email protected]' }, - { name: 'real-user', id: 4, email: '[email protected]' } + { name: 'dependabot', id: 1, email: 'dependabot@github.com' }, + { name: 'copilot-agent', id: 2, email: 'copilot-agent@github.com' }, + { name: 'bot-service', id: 3, email: 'bot-service@bot.example.com' }, + { name: 'real-user', id: 4, email: 'real-user@users.example.com' } ] const result = await checkAllowList(committers) - // dependabot, copilot-agent, bot-service all filtered expect(result).toHaveLength(1) expect(result[0].name).toBe('real-user') }) @@ -341,9 +346,9 @@ describe('checkAllowList', () => { mockedGetDomainAllowList.mockReturnValue('@automated.com') const committers: CommittersDetails[] = [ - { name: 'bot-user', id: 1, email: '[email protected]' }, // username match - { name: 'real-user', id: 2, email: '[email protected]' }, // email match - { name: 'another-user', id: 3, email: '[email protected]' } // no match + { name: 'bot-user', id: 1, email: 'bot-user@people.dev' }, + { name: 'real-user', id: 2, email: 'real-user@automated.com' }, + { name: 'another-user', id: 3, email: 'another-user@people.dev' } ] const result = await checkAllowList(committers) @@ -364,9 +369,9 @@ describe('checkAllowList', () => { } as any) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: 'user3', id: 3, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@loaded-domain.com' }, + { name: 'user2', id: 2, email: 'user2@another.org' }, + { name: 'user3', id: 3, email: 'user3@keep.me' } ] const result = await checkAllowList(committers) @@ -387,9 +392,9 @@ describe('checkAllowList', () => { } as any) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: 'user3', id: 3, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@input-domain.com' }, + { name: 'user2', id: 2, email: 'user2@file-domain.org' }, + { name: 'user3', id: 3, email: 'user3@keep.me' } ] const result = await checkAllowList(committers) @@ -403,10 +408,9 @@ describe('checkAllowList', () => { mockedGetFileContent.mockRejectedValue({ status: '404' }) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@keep.me' } ] - // Should not throw, just continue without file domains const result = await checkAllowList(committers) expect(result).toHaveLength(1) @@ -418,10 +422,12 @@ describe('checkAllowList', () => { mockedGetFileContent.mockRejectedValue({ status: '500' }) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@keep.me' } ] - await expect(checkAllowList(committers)).rejects.toThrow('Could not retrieve whitelisted email domains') + await expect(checkAllowList(committers)).rejects.toThrow( + 'Could not retrieve whitelisted email domains' + ) }) it('should handle invalid JSON in domain file', async () => { @@ -433,30 +439,27 @@ describe('checkAllowList', () => { } as any) const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@keep.me' } ] - // Should throw on invalid JSON await expect(checkAllowList(committers)).rejects.toThrow() }) it('should handle non-array domain file content', async () => { mockedGetDomainsFile.mockReturnValue('domains.json') - const fileContent = JSON.stringify({ domains: ['@example.com'] }) // object instead of array + const fileContent = JSON.stringify({ domains: ['@example.com'] }) mockedGetFileContent.mockResolvedValue({ data: { content: Buffer.from(fileContent).toString('base64') } } as any) -const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' } + const committers: CommittersDetails[] = [ + { name: 'user1', id: 1, email: 'user1@keep.me' }, + { name: 'user2', id: 2, email: 'user2@another.dev' } ] - // Should not throw, but also should not add non-array content const result = await checkAllowList(committers) - // Both should remain (no domains loaded from file) expect(result).toHaveLength(2) }) }) @@ -467,13 +470,12 @@ const committers: CommittersDetails[] = [ mockedGetDomainAllowList.mockReturnValue('') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' } + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' } ] const result = await checkAllowList(committers) - // All should remain (no filters) expect(result).toHaveLength(2) }) @@ -490,61 +492,54 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // null and undefined filtered out, 'bot' filtered, user1 and user2 remain expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['user1', 'user2']) }) - it('should not allow regex injection via username patterns', async () => { - // Attempt to inject regex that would match everything + it('should handle literal wildcard-like usernames safely', async () => { mockedGetUsernameAllowList.mockReturnValue('.*') const committers: CommittersDetails[] = [ - { name: 'user1', id: 1, email: '[email protected]' }, - { name: 'user2', id: 2, email: '[email protected]' }, - { name: '.*', id: 3, email: '[email protected]' } // literal '.*' + { name: 'user1', id: 1, email: 'user1@example.com' }, + { name: 'user2', id: 2, email: 'user2@test.org' }, + { name: '.*', id: 3, email: 'literal@tokens.dev' } ] const result = await checkAllowList(committers) - // Only literal '.*' should be filtered (treated as wildcard pattern that matches anything with . and anything after) - // Because of lodash escapeRegExp, it should match strings containing literal dot-star - // Actually with wildcards, .* becomes \.* -> .* regex, which matches everything - // So this tests that even malicious patterns are escaped properly - // But since * is special, it becomes .* in regex, matching everything - // This is expected wildcard behavior, not a vulnerability - // Let me test a different injection attempt expect(result.length).toBeLessThanOrEqual(3) }) it('should handle special regex characters in exact match mode', async () => { - // These should be treated as literal characters - mockedGetUsernameAllowList.mockReturnValue('user.name, user[bot], user$123, user^test') + mockedGetUsernameAllowList.mockReturnValue( + 'user.name, user[bot], user$123, user^test' + ) const committers: CommittersDetails[] = [ - { name: 'user.name', id: 1, email: '[email protected]' }, - { name: 'userXname', id: 2, email: '[email protected]' }, // should NOT match user.name - { name: 'user[bot]', id: 3, email: '[email protected]' }, - { name: 'user$123', id: 4, email: '[email protected]' }, - { name: 'user^test', id: 5, email: '[email protected]' }, - { name: 'normal-user', id: 6, email: '[email protected]' } + { name: 'user.name', id: 1, email: 'user.name@example.com' }, + { name: 'userXname', id: 2, email: 'userxname@example.com' }, + { name: 'user[bot]', id: 3, email: 'userbot@example.com' }, + { name: 'user$123', id: 4, email: 'user123@example.com' }, + { name: 'user^test', id: 5, email: 'usertest@example.com' }, + { name: 'normal-user', id: 6, email: 'normal-user@example.com' } ] const result = await checkAllowList(committers) - // All special char users filtered (exact match), userXname and normal-user remain expect(result).toHaveLength(2) expect(result.map(c => c.name)).toEqual(['userXname', 'normal-user']) }) it('should handle very long allowlists without performance issues', async () => { - const longList = Array.from({ length: 1000 }, (_, i) => `bot-${i}`).join(',') + const longList = Array.from({ length: 1000 }, (_, i) => `bot-${i}`).join( + ',' + ) mockedGetUsernameAllowList.mockReturnValue(longList) const committers: CommittersDetails[] = [ - { name: 'bot-500', id: 1, email: '[email protected]' }, - { name: 'real-user', id: 2, email: '[email protected]' }, - { name: 'bot-999', id: 3, email: '[email protected]' } + { name: 'bot-500', id: 1, email: 'bot-500@example.com' }, + { name: 'real-user', id: 2, email: 'real-user@example.com' }, + { name: 'bot-999', id: 3, email: 'bot-999@example.com' } ] const start = Date.now() @@ -553,20 +548,19 @@ const committers: CommittersDetails[] = [ expect(result).toHaveLength(1) expect(result[0].name).toBe('real-user') - expect(duration).toBeLessThan(1000) // Should complete in < 1 second + expect(duration).toBeLessThan(1000) }) it('should handle empty username but valid email', async () => { mockedGetDomainAllowList.mockReturnValue('@bot.example.com') const committers: CommittersDetails[] = [ - { name: '', id: 1, email: '[email protected]' }, - { name: 'user', id: 2, email: '[email protected]' } + { name: '', id: 1, email: 'service@bot.example.com' }, + { name: 'user', id: 2, email: 'user@people.dev' } ] const result = await checkAllowList(committers) - // Empty name with bot email should be filtered expect(result).toHaveLength(1) expect(result[0].name).toBe('user') }) @@ -574,7 +568,9 @@ const committers: CommittersDetails[] = [ describe('Real-world scenarios from rdkcentral', () => { it('should handle copilot variants correctly', async () => { - mockedGetUsernameAllowList.mockReturnValue('copilot, Copilot, github-copilot[bot], github-copilot, copilot[bot], copilot-swe-agent[bot]') + mockedGetUsernameAllowList.mockReturnValue( + 'copilot, Copilot, github-copilot[bot], github-copilot, copilot[bot], copilot-swe-agent[bot]' + ) const committers: CommittersDetails[] = [ { name: 'copilot', id: 1, email: '[email protected]' }, @@ -587,13 +583,14 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // Only TB-1993 should remain expect(result).toHaveLength(1) expect(result[0].name).toBe('TB-1993') }) it('should handle rdkcentral allowlist pattern', async () => { - mockedGetUsernameAllowList.mockReturnValue('dependabot*, dependabot[bot], dependabot, semantic-release-bot, rdkcm-rdke, rdkcm-bot, copilot, Copilot, github-copilot[bot], github-copilot, copilot[bot], copilot-swe-agent[bot]') + mockedGetUsernameAllowList.mockReturnValue( + 'dependabot*, dependabot[bot], dependabot, semantic-release-bot, rdkcm-rdke, rdkcm-bot, copilot, Copilot, github-copilot[bot], github-copilot, copilot[bot], copilot-swe-agent[bot]' + ) const committers: CommittersDetails[] = [ { name: 'dependabot', id: 1, email: '[email protected]' }, @@ -608,7 +605,6 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // Only realuser123 should remain expect(result).toHaveLength(1) expect(result[0].name).toBe('realuser123') }) @@ -625,9 +621,7 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // 'bot\x00malicious' starts with 'bot' so should be filtered expect(result.find(c => c.name === 'bot\x00malicious')).toBeUndefined() - // 'evil\x00bot' does NOT start with 'bot' so should remain expect(result.find(c => c.name === 'evil\x00bot')).toBeDefined() }) @@ -639,13 +633,12 @@ const committers: CommittersDetails[] = [ { name: longUsername, id: 1, email: '[email protected]' } ] - // Should complete quickly without hanging const start = Date.now() const result = await checkAllowList(committers) const duration = Date.now() - start - expect(duration).toBeLessThan(1000) // Should complete in under 1 second - expect(result).toHaveLength(0) // Should be filtered (matches *bot) + expect(duration).toBeLessThan(1000) + expect(result).toHaveLength(0) }) it('should handle Unicode characters in usernames safely', async () => { @@ -660,13 +653,9 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // bot-🤖-user matches bot* expect(result.find(c => c.name === 'bot-🤖-user')).toBeUndefined() - // 🤖-bot matches *bot expect(result.find(c => c.name === '🤖-bot')).toBeUndefined() - // bot matches both expect(result.find(c => c.name === 'bot')).toBeUndefined() - // human doesn't match expect(result.find(c => c.name === 'human')).toBeDefined() }) @@ -682,18 +671,68 @@ const committers: CommittersDetails[] = [ const result = await checkAllowList(committers) - // user@github.com should be filtered (exact match) expect(result.find(c => c.email === 'user@github.com')).toBeUndefined() + expect(result.find(c => c.email === 'user@evil.github.com')).toBeDefined() + expect( + result.find(c => c.email === 'user@github.com.evil.com') + ).toBeDefined() + expect(result.find(c => c.email === 'user@example.com')).toBeDefined() + }) - // CURRENT BEHAVIOR: endsWith allows these (may be security issue) - // user@evil.github.com ends with @github.com (subdomain) - expect(result.find(c => c.email === 'user@evil.github.com')).toBeUndefined() + it('should keep GitHub privacy emails when only @github.com is allowlisted', async () => { + mockedGetDomainAllowList.mockReturnValue('@github.com') - // user@github.com.evil.com ends with @github.com (look-alike domain - SECURITY CONCERN) - expect(result.find(c => c.email === 'user@github.com.evil.com')).toBeUndefined() + const committers: CommittersDetails[] = [ + { name: 'actions-user', id: 65916846, email: 'action@github.com' }, + { + name: 'regular-user', + id: 12345, + email: '12345+regular-user@users.noreply.github.com' + }, + { + name: 'another-user', + id: 67890, + email: '67890+another-user@users.noreply.github.com' + }, + { name: 'evil-user', id: 99999, email: 'user@evil.github.com' } + ] - // user@example.com should remain (not in allowlist) - expect(result.find(c => c.email === 'user@example.com')).toBeDefined() + const result = await checkAllowList(committers) + + expect(result.find(c => c.name === 'actions-user')).toBeUndefined() + expect(result.find(c => c.name === 'regular-user')).toBeDefined() + expect(result.find(c => c.name === 'regular-user')?.email).toBe( + '12345+regular-user@users.noreply.github.com' + ) + expect(result.find(c => c.name === 'another-user')).toBeDefined() + expect(result.find(c => c.name === 'evil-user')).toBeDefined() + }) + + it('should filter exact RDK CI domains and keep subdomains', async () => { + mockedGetDomainAllowList.mockReturnValue('@code.rdkcentral.com') + + const committers: CommittersDetails[] = [ + { + name: 'rdkcmf-jenkins', + id: 19492671, + email: 'github@code.rdkcentral.com' + }, + { + name: 'rdkcmf-jenkins-alt', + id: 19492672, + email: 'jenkins@code.rdkcentral.com' + }, + { name: 'regular-user', id: 12345, email: 'regular-user@example.com' }, + { name: 'evil-user', id: 99999, email: 'user@evil.code.rdkcentral.com' } + ] + + const result = await checkAllowList(committers) + + expect( + result.filter(c => c.email?.endsWith('@code.rdkcentral.com')) + ).toHaveLength(0) + expect(result.find(c => c.name === 'regular-user')).toBeDefined() + expect(result.find(c => c.name === 'evil-user')).toBeDefined() }) }) }) diff --git a/src/checkAllowList.ts b/src/checkAllowList.ts index ab0af59b..1522b753 100644 --- a/src/checkAllowList.ts +++ b/src/checkAllowList.ts @@ -4,61 +4,69 @@ import * as _ from 'lodash' import * as input from './shared/getInputs' import { getFileContent } from './persistence/persistence' - -function isUserNotInAllowList( - committer: CommittersDetails, - usernameAllowListPatterns: string[], - domainAllowList: string[] +function isUserInAllowList( + committer: CommittersDetails, + usernameAllowListPatterns: string[], + domainAllowList: string[] ): boolean { - - for(let pattern of domainAllowList) { - pattern = pattern.trim() - if(!pattern) continue - if(!pattern.startsWith('@')) pattern = '@' + pattern - if(committer.email && committer.email.endsWith(pattern)) { - return true - } + for (let pattern of domainAllowList) { + pattern = pattern.trim() + if (!pattern) continue + if (!pattern.startsWith('@')) pattern = '@' + pattern + if (committer.email && committer.email.endsWith(pattern)) { + return true } + } - return usernameAllowListPatterns.filter(function (pattern) { - pattern = pattern.trim() - if (pattern.includes('*')) { - // Escape regex special chars, replace \* with .*, and anchor properly - const regex = '^' + _.escapeRegExp(pattern).split('\\*').join('.*') + '$' + return ( + usernameAllowListPatterns.filter(function (pattern) { + pattern = pattern.trim() + if (pattern.includes('*')) { + // Escape regex special chars, replace \* with .*, and anchor properly + const regex = + '^' + _.escapeRegExp(pattern).split('\\*').join('.*') + '$' - return new RegExp(regex).test(committer.name) - } - return pattern === committer.name + return new RegExp(regex).test(committer.name) + } + return pattern === committer.name }).length > 0 + ) } -export async function checkAllowList(committers: CommittersDetails[]): Promise { - // Load allowlists at runtime (not module-load time) for testability - const usernameAllowListPatterns: string[] = input.getUsernameAllowList().split(',') - const domainAllowList: string[] = input.getDomainAllowList().split(',') +export async function checkAllowList( + committers: CommittersDetails[] +): Promise { + // Load allowlists at runtime (not module-load time) for testability + const usernameAllowListPatterns: string[] = input + .getUsernameAllowList() + .split(',') + const domainAllowList: string[] = input.getDomainAllowList().split(',') - const domainsFile: string = input.getDomainsFile() + const domainsFile: string = input.getDomainsFile() - if(domainsFile) { - try { - const result = await getFileContent(domainsFile) - const jsonData = Buffer.from(result.data.content, 'base64').toString() - let domainsFileContent = JSON.parse(jsonData) - if(domainsFileContent && Array.isArray(domainsFileContent)) { - domainAllowList.push(...domainsFileContent) - } - - } catch (error) { - if (error.status != "404") { - throw new Error( - `Could not retrieve whitelisted email domains. Status: ${ - error.status || 'unknown' - }` - ) - } - } + if (domainsFile) { + try { + const result = await getFileContent(domainsFile) + const jsonData = Buffer.from(result.data.content, 'base64').toString() + let domainsFileContent = JSON.parse(jsonData) + if (domainsFileContent && Array.isArray(domainsFileContent)) { + domainAllowList.push(...domainsFileContent) + } + } catch (error) { + if (error.status != '404') { + throw new Error( + `Could not retrieve whitelisted email domains. Status: ${ + error.status || 'unknown' + }` + ) + } } + } - const committersAfterAllowListCheck: CommittersDetails[] = committers.filter(committer => committer && !(isUserNotInAllowList !== undefined && isUserNotInAllowList(committer, usernameAllowListPatterns, domainAllowList))) - return committersAfterAllowListCheck -} \ No newline at end of file + const remainingCommitters: CommittersDetails[] = committers.filter( + committer => + committer && + !isUserInAllowList(committer, usernameAllowListPatterns, domainAllowList) + ) + return remainingCommitters +} diff --git a/src/graphql.ts b/src/graphql.ts index ee07218d..448d1937 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -2,13 +2,12 @@ import { octokit } from './octokit' import { context } from '@actions/github' import { CommittersDetails } from './interfaces' - - export default async function getCommitters(): Promise { - try { - let committers: CommittersDetails[] = [] - let filteredCommitters: CommittersDetails[] = [] - let response: any = await octokit.graphql(` + try { + let committers: CommittersDetails[] = [] + let filteredCommitters: CommittersDetails[] = [] + let response: any = await octokit.graphql( + ` query($owner:String! $name:String! $number:Int! $cursor:String!){ repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -45,34 +44,43 @@ export default async function getCommitters(): Promise { } } } - }`.replace(/ /g, ''), { - owner: context.repo.owner, - name: context.repo.repo, - number: context.issue.number, - cursor: '' - }) - response.repository.pullRequest.commits.edges.forEach(edge => { - const committer = extractUserFromCommit(edge.node.commit) - let user = { - name: committer.login || committer.name, - id: committer.databaseId || '', - email: edge.node.commit.author.email || '', - pullRequestNo: context.issue.number - } - if (committers.length === 0 || committers.map((c) => { - return c.name - }).indexOf(user.name) < 0) { - committers.push(user) - } - }) - filteredCommitters = committers.filter((committer) => { - return committer.id !== 41898282 - }) - return filteredCommitters - - } catch (e) { - throw new Error(`graphql call to get the committers details failed: ${e}`) - } - + }`.replace(/ /g, ''), + { + owner: context.repo.owner, + name: context.repo.repo, + number: context.issue.number, + cursor: '' + } + ) + response.repository.pullRequest.commits.edges.forEach(edge => { + const committer = extractUserFromCommit(edge.node.commit) + let user = { + name: committer.login || committer.name, + id: committer.databaseId || '', + email: edge.node.commit.author.email || '', + pullRequestNo: context.issue.number + } + if ( + committers.length === 0 || + committers + .map(c => { + return c.name + }) + .indexOf(user.name) < 0 + ) { + committers.push(user) + } + }) + filteredCommitters = committers.filter(committer => { + return committer.id !== 41898282 + }) + return filteredCommitters + } catch (e) { + throw new Error(`graphql call to get the committers details failed: ${e}`) + } } -const extractUserFromCommit = (commit) => commit.author.user || commit.committer.user || commit.author || commit.committer \ No newline at end of file +const extractUserFromCommit = commit => + commit.author.user || + commit.committer.user || + commit.author || + commit.committer diff --git a/src/interfaces.ts b/src/interfaces.ts index 2403109c..2d4892fc 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,42 +1,42 @@ export interface CommitterMap { - signed: CommittersDetails[], - notSigned: CommittersDetails[], - unknown: CommittersDetails[] + signed: CommittersDetails[] + notSigned: CommittersDetails[] + unknown: CommittersDetails[] } export interface ReactedCommitterMap { - newSigned: CommittersDetails[], - onlyCommitters?: CommittersDetails[], - allSignedFlag: boolean + newSigned: CommittersDetails[] + onlyCommitters?: CommittersDetails[] + allSignedFlag: boolean } export interface CommentedCommitterMap { - newSigned: CommittersDetails[], - onlyCommitters?: CommittersDetails[], - allSignedFlag: boolean + newSigned: CommittersDetails[] + onlyCommitters?: CommittersDetails[] + allSignedFlag: boolean } export interface CommittersDetails { - name: string, - id: number, - email?: string, - pullRequestNo?: number, - created_at?: string, - updated_at?: string - comment_id?: number, - body?: string, - repoId?: string + name: string + id: number + email?: string + pullRequestNo?: number + created_at?: string + updated_at?: string + comment_id?: number + body?: string + repoId?: string } export interface LabelName { - current_name: string, - name: string + current_name: string + name: string } export interface CommittersCommentDetails { - name: string, - id: number, - comment_id: number, - body: string, - created_at: string, - updated_at: string + name: string + id: number + comment_id: number + body: string + created_at: string + updated_at: string } export interface ClafileContentAndSha { - claFileContent: any, - sha: string -} \ No newline at end of file + claFileContent: any + sha: string +} diff --git a/src/main.ts b/src/main.ts index f8ebd474..587a91b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ -import {context} from '@actions/github' -import {setupClaCheck} from './setupClaCheck' -import {lockPullRequest} from './pullrequest/pullRequestLock' +import { context } from '@actions/github' +import { setupClaCheck } from './setupClaCheck' +import { lockPullRequest } from './pullrequest/pullRequestLock' import * as core from '@actions/core' import * as input from './shared/getInputs' diff --git a/src/persistence/persistence.ts b/src/persistence/persistence.ts index 5a17a214..9e3fc4dd 100644 --- a/src/persistence/persistence.ts +++ b/src/persistence/persistence.ts @@ -6,7 +6,9 @@ import { getDefaultOctokitClient, getPATOctokit } from '../octokit' import * as input from '../shared/getInputs' -export async function getFileContent(path=input.getPathToSignatures()): Promise { +export async function getFileContent( + path = input.getPathToSignatures() +): Promise { const octokitInstance: InstanceType = isRemoteRepoOrOrgConfigured() ? getPATOctokit() : getDefaultOctokitClient() diff --git a/src/pullrequest/pullRequestComment.ts b/src/pullrequest/pullRequestComment.ts index a2fe1548..a6bb01ca 100644 --- a/src/pullrequest/pullRequestComment.ts +++ b/src/pullrequest/pullRequestComment.ts @@ -2,15 +2,13 @@ import { octokit } from '../octokit' import { context } from '@actions/github' import signatureWithPRComment from './signatureComment' import { commentContent } from './pullRequestCommentContent' -import { - CommitterMap, - CommittersDetails -} from '../interfaces' +import { CommitterMap, CommittersDetails } from '../interfaces' import { getUseDcoFlag } from '../shared/getInputs' - - -export default async function prCommentSetup(committerMap: CommitterMap, committers: CommittersDetails[]) { +export default async function prCommentSetup( + committerMap: CommitterMap, + committers: CommittersDetails[] +) { const signed = committerMap?.notSigned && committerMap?.notSigned.length === 0 try { @@ -24,51 +22,92 @@ export default async function prCommentSetup(committerMap: CommitterMap, committ } // reacted committers are contributors who have newly signed by posting the Pull Request comment - const reactedCommitters = await signatureWithPRComment(committerMap, committers) + const reactedCommitters = await signatureWithPRComment( + committerMap, + committers + ) if (reactedCommitters?.onlyCommitters) { - reactedCommitters.allSignedFlag = prepareAllSignedCommitters(committerMap, reactedCommitters.onlyCommitters, committers) + reactedCommitters.allSignedFlag = prepareAllSignedCommitters( + committerMap, + reactedCommitters.onlyCommitters, + committers + ) } committerMap = prepareCommiterMap(committerMap, reactedCommitters) - await updateComment(reactedCommitters.allSignedFlag, committerMap, claBotComment) + await updateComment( + reactedCommitters.allSignedFlag, + committerMap, + claBotComment + ) return reactedCommitters } } catch (error) { throw new Error( - `Error occured when creating or editing the comments of the pull request: ${error.message}`) + `Error occured when creating or editing the comments of the pull request: ${error.message}` + ) } } -async function createComment(signed: boolean, committerMap: CommitterMap): Promise { - await octokit.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: commentContent(signed, committerMap) - }).catch(error => { throw new Error(`Error occured when creating a pull request comment: ${error.message}`) }) +async function createComment( + signed: boolean, + committerMap: CommitterMap +): Promise { + await octokit.issues + .createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentContent(signed, committerMap) + }) + .catch(error => { + throw new Error( + `Error occured when creating a pull request comment: ${error.message}` + ) + }) } -async function updateComment(signed: boolean, committerMap: CommitterMap, claBotComment: any): Promise { - await octokit.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: claBotComment.id, - body: commentContent(signed, committerMap) - }).catch(error => { throw new Error(`Error occured when updating the pull request comment: ${error.message}`) }) +async function updateComment( + signed: boolean, + committerMap: CommitterMap, + claBotComment: any +): Promise { + await octokit.issues + .updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: claBotComment.id, + body: commentContent(signed, committerMap) + }) + .catch(error => { + throw new Error( + `Error occured when updating the pull request comment: ${error.message}` + ) + }) } async function getComment() { try { - const response = await octokit.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }) + const response = await octokit.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }) //TODO: check the below regex // using a `string` true or false purposely as github action input cannot have a boolean value if (getUseDcoFlag() === 'true') { - return response.data.find(comment => comment.body.match(/.*DCO Assistant Lite bot.*/m)) + return response.data.find(comment => + comment.body.match(/.*DCO Assistant Lite bot.*/m) + ) } else if (getUseDcoFlag() === 'false') { - return response.data.find(comment => comment.body.match(/.*CLA Assistant Lite bot.*/m)) + return response.data.find(comment => + comment.body.match(/.*CLA Assistant Lite bot.*/m) + ) } } catch (error) { - throw new Error(`Error occured when getting all the comments of the pull request: ${error.message}`) + throw new Error( + `Error occured when getting all the comments of the pull request: ${error.message}` + ) } } @@ -81,20 +120,31 @@ function prepareCommiterMap(committerMap: CommitterMap, reactedCommitters) { ) ) return committerMap - } -function prepareAllSignedCommitters(committerMap: CommitterMap, signedInPrCommitters: CommittersDetails[], committers: CommittersDetails[]): boolean { +function prepareAllSignedCommitters( + committerMap: CommitterMap, + signedInPrCommitters: CommittersDetails[], + committers: CommittersDetails[] +): boolean { let allSignedCommitters = [] as CommittersDetails[] /* * 1) already signed committers in the file 2) signed committers in the PR comment - */ + */ const ids = new Set(signedInPrCommitters.map(committer => committer.id)) - allSignedCommitters = [...signedInPrCommitters, ...committerMap.signed!.filter(signedCommitter => !ids.has(signedCommitter.id))] + allSignedCommitters = [ + ...signedInPrCommitters, + ...committerMap.signed!.filter( + signedCommitter => !ids.has(signedCommitter.id) + ) + ] /* - * checking if all the unsigned committers have reacted to the PR comment (this is needed for changing the content of the PR comment to "All committers have signed the CLA") - */ - let allSignedFlag: boolean = committers.every(committer => allSignedCommitters.some(reactedCommitter => committer.id === reactedCommitter.id)) + * checking if all the unsigned committers have reacted to the PR comment (this is needed for changing the content of the PR comment to "All committers have signed the CLA") + */ + let allSignedFlag: boolean = committers.every(committer => + allSignedCommitters.some( + reactedCommitter => committer.id === reactedCommitter.id + ) + ) return allSignedFlag } - diff --git a/src/pullrequest/pullRequestCommentContent.ts b/src/pullrequest/pullRequestCommentContent.ts index 9ef123cf..d5d6a29c 100644 --- a/src/pullrequest/pullRequestCommentContent.ts +++ b/src/pullrequest/pullRequestCommentContent.ts @@ -1,104 +1,129 @@ -import { - CommitterMap -} from '../interfaces' +import { CommitterMap } from '../interfaces' import * as input from '../shared/getInputs' import { getPrSignComment } from '../shared/pr-sign-comment' -export function commentContent(signed: boolean, committerMap: CommitterMap): string { - // using a `string` true or false purposely as github action input cannot have a boolean value - if (input.getUseDcoFlag() == 'true') { - return dco(signed, committerMap) - } else { - return cla(signed, committerMap) - } +export function commentContent( + signed: boolean, + committerMap: CommitterMap +): string { + // using a `string` true or false purposely as github action input cannot have a boolean value + if (input.getUseDcoFlag() == 'true') { + return dco(signed, committerMap) + } else { + return cla(signed, committerMap) + } } function dco(signed: boolean, committerMap: CommitterMap): string { - - if (signed) { - const line1 = input.getCustomAllSignedPrComment() || `All contributors have signed the DCO ✍️ ✅` - const text = `${line1}
Posted by the ****DCO Assistant Lite bot****.` - return text - } - let committersCount = 1 - - if (committerMap && committerMap.signed && committerMap.notSigned) { - committersCount = committerMap.signed.length + committerMap.notSigned.length - - } - - let you = committersCount > 1 ? `you all` : `you` - let lineOne = (input.getCustomNotSignedPrComment() || `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Developer Certificate of Origin](${input.getPathToDocument()}) before we can accept your contribution. You can sign the DCO by just posting a Pull Request Comment same as the below format.
`).replace('$you', you) - let text = `${lineOne} + if (signed) { + const line1 = + input.getCustomAllSignedPrComment() || + `All contributors have signed the DCO ✍️ ✅` + const text = `${line1}
Posted by the ****DCO Assistant Lite bot****.` + return text + } + let committersCount = 1 + + if (committerMap && committerMap.signed && committerMap.notSigned) { + committersCount = committerMap.signed.length + committerMap.notSigned.length + } + + let you = committersCount > 1 ? `you all` : `you` + let lineOne = ( + input.getCustomNotSignedPrComment() || + `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Developer Certificate of Origin](${input.getPathToDocument()}) before we can accept your contribution. You can sign the DCO by just posting a Pull Request Comment same as the below format.
` + ).replace('$you', you) + let text = `${lineOne} - - - - ${input.getCustomPrSignComment() || "I have read the DCO Document and I hereby sign the DCO"} + ${input.getCustomPrSignComment() || 'I have read the DCO Document and I hereby sign the DCO'} - - - ` - if (committersCount > 1 && committerMap && committerMap.signed && committerMap.notSigned) { - text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the DCO.` - committerMap.signed.forEach(signedCommitter => { text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})` }) - committerMap.notSigned.forEach(unsignedCommitter => { - text += `
:x: \`${unsignedCommitter.name}\`` - }) - text += '
' - } - - if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { - let seem = committerMap.unknown.length > 1 ? "seem" : "seems" - let committerNames = committerMap.unknown.map(committer => committer.name) - text += `**${committerNames.join(", ")}** ${seem} not to be a GitHub user.` - text += ' You need a GitHub account to be able to sign the DCO. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
' - } - - if (input.suggestRecheck() == 'true') { - text += 'You can retrigger this bot by commenting **recheck** in this Pull Request. ' - } - text += 'Posted by the ****DCO Assistant Lite bot****.' - return text + if ( + committersCount > 1 && + committerMap && + committerMap.signed && + committerMap.notSigned + ) { + text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the DCO.` + committerMap.signed.forEach(signedCommitter => { + text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})` + }) + committerMap.notSigned.forEach(unsignedCommitter => { + text += `
:x: \`${unsignedCommitter.name}\`` + }) + text += '
' + } + + if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { + let seem = committerMap.unknown.length > 1 ? 'seem' : 'seems' + let committerNames = committerMap.unknown.map(committer => committer.name) + text += `**${committerNames.join(', ')}** ${seem} not to be a GitHub user.` + text += + ' You need a GitHub account to be able to sign the DCO. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
' + } + + if (input.suggestRecheck() == 'true') { + text += + 'You can retrigger this bot by commenting **recheck** in this Pull Request. ' + } + text += 'Posted by the ****DCO Assistant Lite bot****.' + return text } function cla(signed: boolean, committerMap: CommitterMap): string { - - if (signed) { - const line1 = input.getCustomAllSignedPrComment() || `All contributors have signed the CLA ✍️ ✅` - const text = `${line1}
Posted by the ****CLA Assistant Lite bot****.` - return text - } - let committersCount = 1 - - if (committerMap && committerMap.signed && committerMap.notSigned) { - committersCount = committerMap.signed.length + committerMap.notSigned.length - - } - - let you = committersCount > 1 ? `you all` : `you` - let lineOne = (input.getCustomNotSignedPrComment() || `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Contributor License Agreement](${input.getPathToDocument()}) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.
`).replace('$you', you) - let text = `${lineOne} + if (signed) { + const line1 = + input.getCustomAllSignedPrComment() || + `All contributors have signed the CLA ✍️ ✅` + const text = `${line1}
Posted by the ****CLA Assistant Lite bot****.` + return text + } + let committersCount = 1 + + if (committerMap && committerMap.signed && committerMap.notSigned) { + committersCount = committerMap.signed.length + committerMap.notSigned.length + } + + let you = committersCount > 1 ? `you all` : `you` + let lineOne = ( + input.getCustomNotSignedPrComment() || + `
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that $you sign our [Contributor License Agreement](${input.getPathToDocument()}) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.
` + ).replace('$you', you) + let text = `${lineOne} - - - ${getPrSignComment()} - - - ` - if (committersCount > 1 && committerMap && committerMap.signed && committerMap.notSigned) { - text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the CLA.` - committerMap.signed.forEach(signedCommitter => { text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})` }) - committerMap.notSigned.forEach(unsignedCommitter => { - text += `
:x: \`${unsignedCommitter.name}\`` - }) - text += '
' - } - - if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { - let seem = committerMap.unknown.length > 1 ? "seem" : "seems" - let committerNames = committerMap.unknown.map(committer => committer.name) - text += `**${committerNames.join(", ")}** ${seem} not to be a GitHub user.` - text += ' You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
' - } - - if (input.suggestRecheck() == 'true') { - text += 'You can retrigger this bot by commenting **recheck** in this Pull Request. ' - } - text += 'Posted by the **CLA Assistant Lite bot**.' - return text + if ( + committersCount > 1 && + committerMap && + committerMap.signed && + committerMap.notSigned + ) { + text += `**${committerMap.signed.length}** out of **${committerMap.signed.length + committerMap.notSigned.length}** committers have signed the CLA.` + committerMap.signed.forEach(signedCommitter => { + text += `
:white_check_mark: [${signedCommitter.name}](https://github.com/${signedCommitter.name})` + }) + committerMap.notSigned.forEach(unsignedCommitter => { + text += `
:x: \`${unsignedCommitter.name}\`` + }) + text += '
' + } + + if (committerMap && committerMap.unknown && committerMap.unknown.length > 0) { + let seem = committerMap.unknown.length > 1 ? 'seem' : 'seems' + let committerNames = committerMap.unknown.map(committer => committer.name) + text += `**${committerNames.join(', ')}** ${seem} not to be a GitHub user.` + text += + ' You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please [add the email address used for this commit to your account](https://help.github.com/articles/why-are-my-commits-linked-to-the-wrong-user/#commits-are-not-linked-to-any-user).
' + } + + if (input.suggestRecheck() == 'true') { + text += + 'You can retrigger this bot by commenting **recheck** in this Pull Request. ' + } + text += 'Posted by the **CLA Assistant Lite bot**.' + return text } diff --git a/src/pullrequest/pullRequestLock.ts b/src/pullrequest/pullRequestLock.ts index 28a58d3a..d03933d1 100644 --- a/src/pullrequest/pullRequestLock.ts +++ b/src/pullrequest/pullRequestLock.ts @@ -3,20 +3,18 @@ import * as core from '@actions/core' import { context } from '@actions/github' export async function lockPullRequest() { - core.info('Locking the Pull Request to safe guard the Pull Request CLA Signatures') - const pullRequestNo: number = context.issue.number - try { - await octokit.issues.lock( - { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNo - } - ) - core.info(`successfully locked the pull request ${pullRequestNo}`) - } catch (e) { - core.error(`failed when locking the pull request `) - - } - + core.info( + 'Locking the Pull Request to safe guard the Pull Request CLA Signatures' + ) + const pullRequestNo: number = context.issue.number + try { + await octokit.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNo + }) + core.info(`successfully locked the pull request ${pullRequestNo}`) + } catch (e) { + core.error(`failed when locking the pull request `) + } } diff --git a/src/pullrequest/signatureComment.ts b/src/pullrequest/signatureComment.ts index 5e6d8423..2db0c854 100644 --- a/src/pullrequest/signatureComment.ts +++ b/src/pullrequest/signatureComment.ts @@ -1,73 +1,97 @@ import { octokit } from '../octokit' import { context } from '@actions/github' -import { CommitterMap, CommittersDetails, ReactedCommitterMap } from '../interfaces' +import { + CommitterMap, + CommittersDetails, + ReactedCommitterMap +} from '../interfaces' import { getUseDcoFlag, getCustomPrSignComment } from '../shared/getInputs' import * as core from '@actions/core' -export default async function signatureWithPRComment(committerMap: CommitterMap, committers): Promise { +export default async function signatureWithPRComment( + committerMap: CommitterMap, + committers +): Promise { + let repoId = context.payload.repository!.id + let prResponse = await octokit.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }) + let listOfPRComments = [] as CommittersDetails[] + let filteredListOfPRComments = [] as CommittersDetails[] - let repoId = context.payload.repository!.id - let prResponse = await octokit.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number + prResponse?.data.map(prComment => { + listOfPRComments.push({ + name: prComment.user.login, + id: prComment.user.id, + comment_id: prComment.id, + body: prComment.body.trim().toLowerCase(), + created_at: prComment.created_at, + repoId: repoId, + pullRequestNo: context.issue.number }) - let listOfPRComments = [] as CommittersDetails[] - let filteredListOfPRComments = [] as CommittersDetails[] - - prResponse?.data.map((prComment) => { - listOfPRComments.push({ - name: prComment.user.login, - id: prComment.user.id, - comment_id: prComment.id, - body: prComment.body.trim().toLowerCase(), - created_at: prComment.created_at, - repoId: repoId, - pullRequestNo: context.issue.number - }) - }) - listOfPRComments.map(comment => { - if (isCommentSignedByUser(comment.body || "", comment.name)) { - filteredListOfPRComments.push(comment) - } - }) - for (var i = 0; i < filteredListOfPRComments.length; i++) { - delete filteredListOfPRComments[i].body - } - /* - *checking if the reacted committers are not the signed committers(not in the storage file) and filtering only the unsigned committers - */ - const newSigned = filteredListOfPRComments.filter(commentedCommitter => committerMap.notSigned!.some(notSignedCommitter => commentedCommitter.id === notSignedCommitter.id)) - - /* - * checking if the commented users are only the contributors who has committed in the same PR (This is needed for the PR Comment and changing the status to success when all the contributors has reacted to the PR) - */ - const onlyCommitters = committers.filter(committer => filteredListOfPRComments.some(commentedCommitter => committer.id == commentedCommitter.id)) - const commentedCommitterMap: ReactedCommitterMap = { - newSigned, - onlyCommitters, - allSignedFlag: false + }) + listOfPRComments.map(comment => { + if (isCommentSignedByUser(comment.body || '', comment.name)) { + filteredListOfPRComments.push(comment) } + }) + for (var i = 0; i < filteredListOfPRComments.length; i++) { + delete filteredListOfPRComments[i].body + } + /* + *checking if the reacted committers are not the signed committers(not in the storage file) and filtering only the unsigned committers + */ + const newSigned = filteredListOfPRComments.filter(commentedCommitter => + committerMap.notSigned!.some( + notSignedCommitter => commentedCommitter.id === notSignedCommitter.id + ) + ) - return commentedCommitterMap + /* + * checking if the commented users are only the contributors who has committed in the same PR (This is needed for the PR Comment and changing the status to success when all the contributors has reacted to the PR) + */ + const onlyCommitters = committers.filter(committer => + filteredListOfPRComments.some( + commentedCommitter => committer.id == commentedCommitter.id + ) + ) + const commentedCommitterMap: ReactedCommitterMap = { + newSigned, + onlyCommitters, + allSignedFlag: false + } + return commentedCommitterMap } -function isCommentSignedByUser(comment: string, commentAuthor: string): boolean { - if (commentAuthor === 'github-actions[bot]') { - return false - } - if (getCustomPrSignComment() !== "") { - return getCustomPrSignComment().toLowerCase() === comment - } - // using a `string` true or false purposely as github action input cannot have a boolean value - switch (getUseDcoFlag()) { - case 'true': - return comment.match(/^.*i \s*have \s*read \s*the \s*dco \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*dco.*$/) !== null - case 'false': - return comment.match(/^.*i \s*have \s*read \s*the \s*cla \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*cla.*$/) !== null - default: - return false - } -} \ No newline at end of file +function isCommentSignedByUser( + comment: string, + commentAuthor: string +): boolean { + if (commentAuthor === 'github-actions[bot]') { + return false + } + if (getCustomPrSignComment() !== '') { + return getCustomPrSignComment().toLowerCase() === comment + } + // using a `string` true or false purposely as github action input cannot have a boolean value + switch (getUseDcoFlag()) { + case 'true': + return ( + comment.match( + /^.*i \s*have \s*read \s*the \s*dco \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*dco.*$/ + ) !== null + ) + case 'false': + return ( + comment.match( + /^.*i \s*have \s*read \s*the \s*cla \s*document \s*and \s*i \s*hereby \s*sign \s*the \s*cla.*$/ + ) !== null + ) + default: + return false + } +} diff --git a/src/setStatus.ts b/src/setStatus.ts index 521cdcb7..2f63380c 100644 --- a/src/setStatus.ts +++ b/src/setStatus.ts @@ -1,15 +1,15 @@ -import {getStatusContext} from './shared/getInputs' -import {context, getOctokit} from '@actions/github' +import { getStatusContext } from './shared/getInputs' +import { context, getOctokit } from '@actions/github' import * as core from '@actions/core' core.info(`Using token: ${process.env.GITHUB_TOKEN}`) -const octokit = getOctokit(process.env.GITHUB_TOKEN || "") +const octokit = getOctokit(process.env.GITHUB_TOKEN || '') const pullRequest = { - owner: context.payload.repository?.owner.login || "", - repo: context.payload.repository?.name || "", + owner: context.payload.repository?.owner.login || '', + repo: context.payload.repository?.name || '', pull_number: context.payload.issue?.number || 0, - sha: "" + sha: '' } async function setupManualStatusUpdate() { @@ -20,7 +20,10 @@ async function setupManualStatusUpdate() { pullRequest.sha = response.data.head.sha } -export async function updateStatus(state: "error" | "pending" | "success" | "failure", description: string) { +export async function updateStatus( + state: 'error' | 'pending' | 'success' | 'failure', + description: string +) { if (context.eventName != 'issue_comment') return await setupComplete @@ -29,8 +32,8 @@ export async function updateStatus(state: "error" | "pending" | "success" | "fai ...pullRequest, context: getStatusContext(), state, - description, + description }) } -const setupComplete = setupManualStatusUpdate() \ No newline at end of file +const setupComplete = setupManualStatusUpdate() diff --git a/src/setupClaCheck.ts b/src/setupClaCheck.ts index 4aa69c44..a5c2f11f 100644 --- a/src/setupClaCheck.ts +++ b/src/setupClaCheck.ts @@ -62,48 +62,71 @@ export async function setupClaCheck() { } async function createSuccessSummary(committerMap: CommitterMap): Promise { - const totalCount = (committerMap.signed?.length || 0) + (committerMap.notSigned?.length || 0) + (committerMap.unknown?.length || 0) + const totalCount = + (committerMap.signed?.length || 0) + + (committerMap.notSigned?.length || 0) + + (committerMap.unknown?.length || 0) await core.summary .addHeading('✅ All Contributors Signed') .addRaw(`All ${totalCount} contributor(s) have signed the CLA.`) .addBreak() .addTable([ - [{data: 'Contributor', header: true}, {data: 'Status', header: true}], + [ + { data: 'Contributor', header: true }, + { data: 'Status', header: true } + ], ...(committerMap.signed || []).map(c => [c.name, '✅ Signed']) ]) .write() } async function createFailureSummary(committerMap: CommitterMap): Promise { - const totalCount = (committerMap.signed?.length || 0) + committerMap.notSigned.length + (committerMap.unknown?.length || 0) + const totalCount = + (committerMap.signed?.length || 0) + + committerMap.notSigned.length + + (committerMap.unknown?.length || 0) const docUrl = input.getPathToDocument() await core.summary .addHeading('❌ CLA Signature Required') - .addRaw(`${committerMap.notSigned.length} of ${totalCount} contributors need to sign the CLA.`) + .addRaw( + `${committerMap.notSigned.length} of ${totalCount} contributors need to sign the CLA.` + ) .addBreak() .addHeading('Unsigned Contributors', 3) - .addList(committerMap.notSigned.map(c => `@${c.name}${c.email ? ` (${c.email})` : ''}`)) + .addList( + committerMap.notSigned.map( + c => `@${c.name}${c.email ? ` (${c.email})` : ''}` + ) + ) .addBreak() .addRaw(`📝 View CLA Document`) .addBreak() - .addRaw('To sign: Comment on this PR with "I have read the CLA Document and I hereby sign the CLA"') + .addRaw( + 'To sign: Comment on this PR with "I have read the CLA Document and I hereby sign the CLA"' + ) .write() // Add annotations for each unsigned contributor committerMap.notSigned.forEach(c => { - core.warning(`@${c.name}${c.email ? ` (${c.email})` : ''} has not signed the CLA`, { - title: '📝 CLA Signature Required' - }) + core.warning( + `@${c.name}${c.email ? ` (${c.email})` : ''} has not signed the CLA`, + { + title: '📝 CLA Signature Required' + } + ) }) // Add info about unknown users if any if (committerMap.unknown && committerMap.unknown.length > 0) { committerMap.unknown.forEach(c => { - core.notice(`@${c.name} appears to be committing without a linked GitHub account`, { - title: '⚠️ Unknown GitHub User' - }) + core.notice( + `@${c.name} appears to be committing without a linked GitHub account`, + { + title: '⚠️ Unknown GitHub User' + } + ) }) } } @@ -125,7 +148,7 @@ async function getCLAFileContentandSHA( try { result = await getFileContent() } catch (error) { - if (error.status === "404") { + if (error.status === '404') { return createClaFileAndPRComment(committers, committerMap) } else { throw new Error( @@ -179,10 +202,14 @@ function prepareCommiterMap( committerMap.notSigned = committers.filter( committer => - !(claFileContent?.signedContributors || []).some(cla => committer.id === cla.id) + !(claFileContent?.signedContributors || []).some( + cla => committer.id === cla.id + ) ) committerMap.signed = committers.filter(committer => - (claFileContent?.signedContributors || []).some(cla => committer.id === cla.id) + (claFileContent?.signedContributors || []).some( + cla => committer.id === cla.id + ) ) committers.map(committer => { if (!committer.id) { diff --git a/src/shared/getInputs.ts b/src/shared/getInputs.ts index 7a290e47..304a93bf 100644 --- a/src/shared/getInputs.ts +++ b/src/shared/getInputs.ts @@ -54,4 +54,4 @@ export const suggestRecheck = (): string => core.getInput('suggest-recheck', { required: false }) export const getStatusContext = (): string => - core.getInput('status-context', { required: false }) \ No newline at end of file + core.getInput('status-context', { required: false }) diff --git a/src/shared/pr-sign-comment.ts b/src/shared/pr-sign-comment.ts index 7c4b3e33..6a93b60e 100644 --- a/src/shared/pr-sign-comment.ts +++ b/src/shared/pr-sign-comment.ts @@ -1,5 +1,8 @@ import * as input from './getInputs' export function getPrSignComment() { - return input.getCustomPrSignComment() || "I have read the CLA Document and I hereby sign the CLA" + return ( + input.getCustomPrSignComment() || + 'I have read the CLA Document and I hereby sign the CLA' + ) } diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 00000000..55ff6f50 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"], + "noEmit": true + }, + "include": [ + "./**/*.test.ts", + "./**/*.ts" + ], + "exclude": ["../node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json index 8b901ec1..9e22aade 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "rootDir": "./src", "strict": true, "noImplicitAny": false, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true, + "types": ["node", "jest"] }, "exclude": ["node_modules", "**/*.test.ts"] } From 71ec199c4a28111ca27f69485423a43bc4b1534d Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:52:11 +0100 Subject: [PATCH 2/7] ci: align manual and codeql workflows --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/manual-test.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 87f3f4fc..36b6df12 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index f1870984..d9d104d6 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -32,12 +32,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24.x' - name: Install dependencies run: npm ci From 52028d3283fefd1fe40f1267fe4e220d6b402298 Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:52:12 +0100 Subject: [PATCH 3/7] ci: refine node 24 workflow matrix --- .github/workflows/nodejs.yml | 89 ++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index e9f0c09e..48c15498 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,33 +1,84 @@ -name: build +name: CI on: push: - branches: - - '*' - tags: - - '*' + branches: + - '*' + tags: + - '*' pull_request: - branches: - - master + branches: + - master permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + static-checks: + name: Static checks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint --if-present + - name: Type check + run: npx tsc --noEmit + + unit-tests: + name: Unit tests (Node ${{ matrix.node-version }}) + needs: static-checks runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [20.x, 24.x] + node-version: [22.x, 24.x] steps: - - name: "Checkout repository" - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - name: Npm install - run: npm ci - - name: Npm build - run: npm run build --if-present + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Unit tests + run: npm test -- --runInBand --watchAll=false + bundle: + name: Bundle action + needs: static-checks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist-node24 + path: dist + if-no-files-found: error + retention-days: 7 From 21ea08400b04353142471779f5bc363e167f1ca4 Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:24:26 +0100 Subject: [PATCH 4/7] test: move checkAllowList.test.ts back to __tests__ and fix import paths --- {src => __tests__}/checkAllowList.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename {src => __tests__}/checkAllowList.test.ts (98%) diff --git a/src/checkAllowList.test.ts b/__tests__/checkAllowList.test.ts similarity index 98% rename from src/checkAllowList.test.ts rename to __tests__/checkAllowList.test.ts index 50dbed8b..fd86d02b 100644 --- a/src/checkAllowList.test.ts +++ b/__tests__/checkAllowList.test.ts @@ -1,10 +1,10 @@ -import { checkAllowList } from './checkAllowList' -import * as input from './shared/getInputs' -import { CommittersDetails } from './interfaces' -import { getFileContent } from './persistence/persistence' +import { checkAllowList } from '../src/checkAllowList' +import * as input from '../src/shared/getInputs' +import { CommittersDetails } from '../src/interfaces' +import { getFileContent } from '../src/persistence/persistence' -jest.mock('./shared/getInputs') -jest.mock('./persistence/persistence') +jest.mock('../src/shared/getInputs') +jest.mock('../src/persistence/persistence') const mockedGetUsernameAllowList = jest.mocked(input.getUsernameAllowList) const mockedGetDomainAllowList = jest.mocked(input.getDomainAllowList) From df4eae6c3da0dfdfd53adc505cd97b0ad5002627 Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:27:26 +0100 Subject: [PATCH 5/7] ci: upgrade codeql-action v3 to v4 --- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/manual-test.yml | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 36b6df12..9dbb3e02 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,7 +39,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -50,7 +50,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -64,4 +64,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index d9d104d6..518c7324 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -33,24 +33,24 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 - + - name: Setup Node uses: actions/setup-node@v6 with: node-version: '24.x' - + - name: Install dependencies run: npm ci - + - name: Build action run: npm run build - + - name: Display test scenario run: | echo "Testing scenario: ${{ inputs.test_scenario }}" echo "PR number: ${{ inputs.pr_number || 'Using workflow context' }}" echo "Test repo: ${{ inputs.test_repo }}" - + - name: Run CLA check uses: ./ env: From bbd08d1c3e6342549202a2a863b19106eaaca44c Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:27:31 +0100 Subject: [PATCH 6/7] ci: upgrade upload-artifact v4 to v7 --- .github/workflows/nodejs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 48c15498..9b1694f2 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -76,7 +76,7 @@ jobs: - name: Build run: npm run build - name: Upload dist artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist-node24 path: dist From 00510f50cfe7b2224e467dc452ce00f0f2bb0b18 Mon Sep 17 00:00:00 2001 From: Alan Ryan <20208488+Alan-Ryan@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:27:45 +0100 Subject: [PATCH 7/7] test: reformat claValidation.test.ts for consistent style --- __tests__/claValidation.test.ts | 118 ++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/__tests__/claValidation.test.ts b/__tests__/claValidation.test.ts index 34031870..51a6f210 100644 --- a/__tests__/claValidation.test.ts +++ b/__tests__/claValidation.test.ts @@ -119,7 +119,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -127,7 +129,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) }) @@ -147,7 +151,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -179,7 +185,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -210,7 +218,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -223,7 +233,9 @@ describe('CLA Validation - Full Integration Flow', () => { if (mockedSetFailed.mock.calls.length > 0) { console.log('Failure message:', mockedSetFailed.mock.calls[0][0]) } - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) }) @@ -247,7 +259,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -255,7 +269,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) }) @@ -279,7 +295,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -310,7 +328,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -345,7 +365,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -353,7 +375,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) }) @@ -378,7 +402,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -409,11 +435,15 @@ describe('CLA Validation - Full Integration Flow', () => { ] } - mockedGetDomainAllowList.mockReturnValue('@bot.example.com, @automation.io') + mockedGetDomainAllowList.mockReturnValue( + '@bot.example.com, @automation.io' + ) mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -421,7 +451,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) }) @@ -442,7 +474,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -479,7 +513,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -487,7 +523,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - dependabot and ci-bot filtered by username, bot-service by domain - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) @@ -511,7 +549,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -537,7 +577,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -545,7 +587,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - no one to check - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) @@ -564,7 +608,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -572,7 +618,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - all filtered by allowlist - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) @@ -584,14 +632,16 @@ describe('CLA Validation - Full Integration Flow', () => { const signaturesFile = { signedContributors: [ { name: 'user1', id: 101, created_at: '2026-01-01' }, - { name: 'user1', id: 101, created_at: '2026-01-02' }, // Duplicate + { name: 'user1', id: 101, created_at: '2026-01-02' } // Duplicate ] } mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -599,7 +649,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should still succeed - user is signed (duplicates don't matter) - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) @@ -617,7 +669,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any) @@ -625,7 +679,9 @@ describe('CLA Validation - Full Integration Flow', () => { await setupClaCheck() // Should succeed - matched by ID - expect(mockedInfo).toHaveBeenCalledWith(expect.stringContaining('All contributors have signed')) + expect(mockedInfo).toHaveBeenCalledWith( + expect.stringContaining('All contributors have signed') + ) expect(mockedSetFailed).not.toHaveBeenCalled() }) @@ -639,7 +695,9 @@ describe('CLA Validation - Full Integration Flow', () => { mockedGetCommitters.mockResolvedValue(committers) mockedGetFileContent.mockResolvedValue({ data: { - content: Buffer.from(JSON.stringify(signaturesFile)).toString('base64'), + content: Buffer.from(JSON.stringify(signaturesFile)).toString( + 'base64' + ), sha: 'file-sha-123' } } as any)