diff --git a/README.md b/README.md index a865b7c..1722f6b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ The `forcedotcom/run-code-analyzer@v2` GitHub Action is based on [Salesforce Cod * When this action is run against a pull request, you can provide a GitHub token, which is used to create a review of the pull request. The review specifies how many violations were found (both in the project as a whole and in changed files) and links to the action summary page. * This token must have write permissions for pull requests. * You can use the default GitHub token stored as the `GITHUB_TOKEN` secret, as long as you also use the job-level `permissions` property to give that token write access for pull requests. +* `changed-files-only` *(Default: `false`)* + * When set to `true`, only violations in files that were changed in the pull request will be: + * Shown in the summary + * Counted in the output variables (`num-violations`, `num-sev1-violations`, etc.) + * Violations in unchanged files will be completely excluded from both the summary and the counts. + * This option only has effect when running on a pull request with a `github-token` provided. + * **Use Case:** Enable quality gates that only fail on violations in files changed by the PR, rather than all violations in the entire codebase. This is useful when introducing code analysis to a legacy codebase with existing violations. ## v2 Outputs * `exit-code` @@ -107,6 +114,19 @@ The [Salesforce Code Analyzer v5.x](https://developer.salesforce.com/docs/platfo steps.run-code-analyzer.outputs.num-violations > 10 run: exit 1 +## Example v2 Usage with Changed Files Only + +If you want to focus only on violations in files that were changed in a pull request, use the `changed-files-only` input. When enabled, the output counts will **only include violations from changed files**, making it easy to set up quality gates that don't fail on existing violations in unchanged code. + +**Use Case:** This is ideal when introducing code quality checks to a legacy codebase with existing violations. Instead of blocking all PRs due to pre-existing issues, you can ensure that new code meets quality standards while allowing gradual cleanup of existing code. + +When `changed-files-only: true` is set: +- The action scans the entire workspace for comprehensive analysis +- Violations are filtered to only those in files changed by the PR +- All output counts (`num-violations`, `num-sev1-violations`, etc.) reflect only changed file violations +- Quality gates can evaluate only new violations, not existing codebase issues +- PR reviews show both total violations (for context) and changed file violations (for quality gate decisions) + # Version: v1 The `forcedotcom/run-code-analyzer@v1` GitHub Action is based on [Salesforce Code Analyzer v4.x](https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/code-analyzer-3x.html), which is the original `@salesforce/sfdx-scanner` Salesforce CLI plugin. diff --git a/__tests__/dependencies.test.ts b/__tests__/dependencies.test.ts index f29c512..a57b52a 100644 --- a/__tests__/dependencies.test.ts +++ b/__tests__/dependencies.test.ts @@ -62,11 +62,15 @@ describe('RuntimeDependencies Code Coverage', () => { jest.spyOn(core, 'getInput').mockImplementation((name: string): string => { return `${name} Value` }) + jest.spyOn(core, 'getBooleanInput').mockImplementation((name: string): boolean => { + return name === 'changed-files-only' ? false : true + }) const inputs: Inputs = dependencies.getInputs() expect(inputs).toEqual({ runArguments: 'run-arguments Value', resultsArtifactName: 'results-artifact-name Value', - githubToken: 'github-token Value' + githubToken: 'github-token Value', + changedFilesOnly: false }) }) diff --git a/__tests__/fakes.ts b/__tests__/fakes.ts index 7aa7feb..2ad47d9 100644 --- a/__tests__/fakes.ts +++ b/__tests__/fakes.ts @@ -33,7 +33,8 @@ export class FakeDependencies implements Dependencies { getInputsReturnValue: Inputs = { runArguments: '--view detail --output-file sfca_results.json', resultsArtifactName: 'salesforce-code-analyzer-results', - githubToken: 'dummyToken' + githubToken: 'dummyToken', + changedFilesOnly: false } getInputsCallCount = 0 getInputs(): Inputs { @@ -242,9 +243,9 @@ export class FakeViolationLocation implements ViolationLocation { export class FakeSummarizer implements Summarizer { createSummaryMarkdownReturnValue = 'someSummaryMarkdown' - createSummaryMarkdownCallHistory: { results: Results }[] = [] - createSummaryMarkdown(results: Results): string { - this.createSummaryMarkdownCallHistory.push({ results }) + createSummaryMarkdownCallHistory: { results: Results; changedFiles?: string[]; changedFilesOnly?: boolean }[] = [] + createSummaryMarkdown(results: Results, changedFiles?: string[], changedFilesOnly?: boolean): string { + this.createSummaryMarkdownCallHistory.push({ results, changedFiles, changedFilesOnly }) return this.createSummaryMarkdownReturnValue } } diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 62672d8..023378d 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,7 +1,15 @@ import * as main from '../src/main' -import { FakeCommandExecutor, FakeDependencies, FakeResultsFactory, FakeSummarizer } from './fakes' +import { + FakeCommandExecutor, + FakeDependencies, + FakeResults, + FakeResultsFactory, + FakeSummarizer, + FakeViolationLocation +} from './fakes' import { Inputs } from '../src/types' import { MESSAGE_FCNS, MESSAGES, MIN_CODE_ANALYZER_VERSION_REQUIRED } from '../src/constants' +import { RuntimeViolation } from '../src/results' describe('main run Tests', () => { let dependencies: FakeDependencies @@ -81,7 +89,9 @@ describe('main run Tests', () => { expect(summarizer.createSummaryMarkdownCallHistory).toHaveLength(1) expect(summarizer.createSummaryMarkdownCallHistory).toContainEqual({ - results: resultsFactory.createResultsReturnValue + results: resultsFactory.createResultsReturnValue, + changedFiles: [], + changedFilesOnly: false }) expect(dependencies.writeSummaryCallHistory).toHaveLength(1) @@ -93,7 +103,8 @@ describe('main run Tests', () => { it('Test user supplies non-default inputs with various output files including json', async () => { dependencies.getInputsReturnValue = { runArguments: '-f myFile.html --output-file=another.xml -f=great.json --output-file cool.sarif -w ./src', - resultsArtifactName: 'customArtifactName' + resultsArtifactName: 'customArtifactName', + changedFilesOnly: false } await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -127,7 +138,8 @@ describe('main run Tests', () => { dependencies.getInputsReturnValue = { runArguments: '-f myFile.html --view table', resultsArtifactName: 'salesforce-code-analyzer-results', - githubToken: 'dummyToken' + githubToken: 'dummyToken', + changedFilesOnly: false } dependencies.isPullRequestReturnValue = true dependencies.getChangedFilesCallback = async () => ['fakeFile'] // Match the file from FakeViolationLocation to get resultsInChangedFilesCount > 0 @@ -162,7 +174,8 @@ describe('main run Tests', () => { it('When running on a pull request but missing github token, then a review is not created and an info message is logged', async () => { dependencies.getInputsReturnValue = { runArguments: '-f myFile.html --view table', - resultsArtifactName: 'salesforce-code-analyzer-results' + resultsArtifactName: 'salesforce-code-analyzer-results', + changedFilesOnly: false } dependencies.isPullRequestReturnValue = true await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -173,7 +186,8 @@ describe('main run Tests', () => { expect(resultsFactory.createResultsCallHistory).toHaveLength(1) expect(dependencies.warnCallHistory).toHaveLength(0) - expect(dependencies.infoCallHistory).toHaveLength(2) + expect(dependencies.infoCallHistory).toHaveLength(3) + expect(dependencies.infoCallHistory[0].infoMessage).toContain('Parsed results from') expect(dependencies.infoCallHistory[1].infoMessage).toEqual(MESSAGES.PR_FOUND_WITHOUT_GH_TOKEN) }) @@ -214,7 +228,8 @@ describe('main run Tests', () => { dependencies.getInputsReturnValue = { runArguments: '-f myFile.html --view table', resultsArtifactName: 'salesforce-code-analyzer-results', - githubToken: 'dummyToken' + githubToken: 'dummyToken', + changedFilesOnly: false } dependencies.isPullRequestReturnValue = isPullRequest dependencies.getChangedFilesCallback = getChangedFilesCallback @@ -246,7 +261,8 @@ describe('main run Tests', () => { it('Test user supplies non-default inputs with non-json output file', async () => { dependencies.getInputsReturnValue = { runArguments: '-f myFile.html --view table', - resultsArtifactName: 'salesforce-code-analyzer-results' + resultsArtifactName: 'salesforce-code-analyzer-results', + changedFilesOnly: false } await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -272,7 +288,8 @@ describe('main run Tests', () => { it('Test user supplies non-default inputs with zero output files and no view', async () => { dependencies.getInputsReturnValue = { runArguments: '', - resultsArtifactName: 'salesforce-code-analyzer-results' + resultsArtifactName: 'salesforce-code-analyzer-results', + changedFilesOnly: false } await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -298,7 +315,8 @@ describe('main run Tests', () => { it('Test user supplies non-default inputs with zero output files but supplies a view', async () => { dependencies.getInputsReturnValue = { runArguments: '-c someConfig.yml --view detail', - resultsArtifactName: 'salesforce-code-analyzer-results' + resultsArtifactName: 'salesforce-code-analyzer-results', + changedFilesOnly: false } await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -443,7 +461,8 @@ describe('main run Tests', () => { it('Test when the user output file does not exist after run then we fail', async () => { dependencies.getInputsReturnValue = { runArguments: '-f userResults.xml', - resultsArtifactName: 'customArtifactName' + resultsArtifactName: 'customArtifactName', + changedFilesOnly: false } dependencies.fileExistsReturnValue = false await main.run(dependencies, commandExecutor, resultsFactory, summarizer) @@ -452,4 +471,63 @@ describe('main run Tests', () => { expect(dependencies.failCallHistory).toHaveLength(1) expect(dependencies.failCallHistory[0].failMessage).toContain(MESSAGE_FCNS.FILE_NOT_FOUND('userResults.xml')) }) + + it('When changed-files-only is true, outputs reflect only violations in changed files', async () => { + dependencies.getInputsReturnValue = { + runArguments: '--view detail --output-file sfca_results.json', + resultsArtifactName: 'salesforce-code-analyzer-results', + githubToken: 'dummyToken', + changedFilesOnly: true + } + dependencies.isPullRequestReturnValue = true + dependencies.getChangedFilesCallback = async () => ['changedFile.ts'] // Only one file changed + + // Create violations with specific file locations + const changedFileLocation = new FakeViolationLocation() + changedFileLocation.getFileReturnValue = 'changedFile.ts' + + const unchangedFileLocation1 = new FakeViolationLocation() + unchangedFileLocation1.getFileReturnValue = 'unchangedFile1.ts' + + const unchangedFileLocation2 = new FakeViolationLocation() + unchangedFileLocation2.getFileReturnValue = 'unchangedFile2.ts' + + // Set up results with violations in both changed and unchanged files + const fakeResults = resultsFactory.createResultsReturnValue as FakeResults + fakeResults.getViolationsSortedBySeverityReturnValue = [ + // This violation is in the changed file + new RuntimeViolation(1, 'engine1', 'rule1', undefined, 'message1', 0, [changedFileLocation]), + // These violations are in unchanged files + new RuntimeViolation(1, 'engine1', 'rule2', undefined, 'message2', 0, [unchangedFileLocation1]), + new RuntimeViolation(2, 'engine1', 'rule3', undefined, 'message3', 0, [unchangedFileLocation1]), + new RuntimeViolation(3, 'engine1', 'rule4', undefined, 'message4', 0, [unchangedFileLocation2]) + ] + + await main.run(dependencies, commandExecutor, resultsFactory, summarizer) + + // Outputs should only count the 1 violation in the changed file + expect(dependencies.setOutputCallHistory).toContainEqual({ + name: 'num-violations', + value: '1' + }) + expect(dependencies.setOutputCallHistory).toContainEqual({ + name: 'num-sev1-violations', + value: '1' + }) + expect(dependencies.setOutputCallHistory).toContainEqual({ + name: 'num-sev2-violations', + value: '0' + }) + expect(dependencies.setOutputCallHistory).toContainEqual({ + name: 'num-sev3-violations', + value: '0' + }) + + // Summary should be called with changedFilesOnly=true + expect(summarizer.createSummaryMarkdownCallHistory).toContainEqual({ + results: resultsFactory.createResultsReturnValue, + changedFiles: ['changedFile.ts'], + changedFilesOnly: true + }) + }) }) diff --git a/__tests__/summary.test.ts b/__tests__/summary.test.ts index 11338bc..99c61bb 100644 --- a/__tests__/summary.test.ts +++ b/__tests__/summary.test.ts @@ -178,4 +178,155 @@ describe('RuntimeSummarizer Tests', () => { ) expect(summaryMarkdown).toEqual(expectedSummaryMarkdown) }) + + describe('Changed files only mode', () => { + it('When changedFilesOnly is true, only violations in changed files are shown', () => { + const results: FakeResults = new FakeResults() + results.getTotalViolationCountReturnValue = 6 + results.getSev1ViolationCountReturnValue = 1 + results.getSev2ViolationCountReturnValue = 1 + results.getSev3ViolationCountReturnValue = 2 + results.getSev4ViolationCountReturnValue = 1 + results.getSev5ViolationCountReturnValue = 1 + results.getViolationsSortedBySeverityReturnValue = [] + + // Create violations in changed files + const changedFileLocation1 = new FakeViolationLocation() + changedFileLocation1.getFileReturnValue = 'changed-file1.ts' + changedFileLocation1.toStringReturnValue = 'changed-file1.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(1, 'engine1', 'rule1', undefined, 'message1', 0, [changedFileLocation1]) + ) + + const changedFileLocation2 = new FakeViolationLocation() + changedFileLocation2.getFileReturnValue = 'changed-file2.ts' + changedFileLocation2.toStringReturnValue = 'changed-file2.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(2, 'engine1', 'rule2', undefined, 'message2', 0, [changedFileLocation2]) + ) + + // Create violations in unchanged files + const unchangedFileLocation1 = new FakeViolationLocation() + unchangedFileLocation1.getFileReturnValue = 'unchanged-file1.ts' + unchangedFileLocation1.toStringReturnValue = 'unchanged-file1.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(3, 'engine1', 'rule3', undefined, 'message3', 0, [unchangedFileLocation1]) + ) + + const unchangedFileLocation2 = new FakeViolationLocation() + unchangedFileLocation2.getFileReturnValue = 'unchanged-file2.ts' + unchangedFileLocation2.toStringReturnValue = 'unchanged-file2.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(3, 'engine1', 'rule4', undefined, 'message4', 0, [unchangedFileLocation2]) + ) + + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(4, 'engine1', 'rule5', undefined, 'message5', 0, [unchangedFileLocation1]) + ) + + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(5, 'engine1', 'rule6', undefined, 'message6', 0, [unchangedFileLocation2]) + ) + + const changedFiles = ['changed-file1.ts', 'changed-file2.ts'] + const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles, true) + + // Should only show 2 violations in changed files + expect(summaryMarkdown).toContain('2 Violation(s) Found in Changed Files') + expect(summaryMarkdown).toContain(':black_circle: 1 Critical severity violation(s)') + expect(summaryMarkdown).toContain(':red_circle: 1 High severity violation(s)') + expect(summaryMarkdown).toContain(':orange_circle: 0 Medium severity violation(s)') + expect(summaryMarkdown).toContain(':yellow_circle: 0 Low severity violation(s)') + expect(summaryMarkdown).toContain(':white_circle: 0 Info severity violation(s)') + + // Should show changed file violations + expect(summaryMarkdown).toContain('changed-file1.ts') + expect(summaryMarkdown).toContain('changed-file2.ts') + + // Should NOT show unchanged file violations + expect(summaryMarkdown).not.toContain('unchanged-file1.ts') + expect(summaryMarkdown).not.toContain('unchanged-file2.ts') + + // Should NOT show the collapsible sections + expect(summaryMarkdown).not.toContain('violations in files changed by this pull request') + expect(summaryMarkdown).not.toContain('violations in files unchanged by this pull request') + }) + + it('When changedFilesOnly is true but no changed files have violations, shows zero violations', () => { + const results: FakeResults = new FakeResults() + results.getTotalViolationCountReturnValue = 2 + results.getSev1ViolationCountReturnValue = 0 + results.getSev2ViolationCountReturnValue = 0 + results.getSev3ViolationCountReturnValue = 2 + results.getSev4ViolationCountReturnValue = 0 + results.getSev5ViolationCountReturnValue = 0 + results.getViolationsSortedBySeverityReturnValue = [] + + // Create violations only in unchanged files + const unchangedFileLocation = new FakeViolationLocation() + unchangedFileLocation.getFileReturnValue = 'unchanged-file.ts' + unchangedFileLocation.toStringReturnValue = 'unchanged-file.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(3, 'engine1', 'rule1', undefined, 'message1', 0, [unchangedFileLocation]) + ) + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(3, 'engine1', 'rule2', undefined, 'message2', 0, [unchangedFileLocation]) + ) + + const changedFiles = ['changed-file1.ts', 'changed-file2.ts'] + const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles, true) + + // Should show zero violations + expect(summaryMarkdown).toContain('0 Violations Found in Changed Files') + expect(summaryMarkdown).not.toContain('unchanged-file.ts') + }) + + it('When changedFilesOnly is false, shows both changed and unchanged file violations', () => { + const results: FakeResults = new FakeResults() + results.getTotalViolationCountReturnValue = 4 + results.getSev1ViolationCountReturnValue = 1 + results.getSev2ViolationCountReturnValue = 1 + results.getSev3ViolationCountReturnValue = 1 + results.getSev4ViolationCountReturnValue = 1 + results.getSev5ViolationCountReturnValue = 0 + results.getViolationsSortedBySeverityReturnValue = [] + + // Create violations in changed files + const changedFileLocation = new FakeViolationLocation() + changedFileLocation.getFileReturnValue = 'changed-file.ts' + changedFileLocation.toStringReturnValue = 'changed-file.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(1, 'engine1', 'rule1', undefined, 'message1', 0, [changedFileLocation]) + ) + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(2, 'engine1', 'rule2', undefined, 'message2', 0, [changedFileLocation]) + ) + + // Create violations in unchanged files + const unchangedFileLocation = new FakeViolationLocation() + unchangedFileLocation.getFileReturnValue = 'unchanged-file.ts' + unchangedFileLocation.toStringReturnValue = 'unchanged-file.ts:1:0' + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(3, 'engine1', 'rule3', undefined, 'message3', 0, [unchangedFileLocation]) + ) + results.getViolationsSortedBySeverityReturnValue.push( + new RuntimeViolation(4, 'engine1', 'rule4', undefined, 'message4', 0, [unchangedFileLocation]) + ) + + const changedFiles = ['changed-file.ts'] + const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles, false) + + // Should show all 4 violations + expect(summaryMarkdown).toContain('4 Violation(s) Found') + expect(summaryMarkdown).not.toContain('in Changed Files') + + // Should show collapsible sections + expect(summaryMarkdown).toContain('2 violations in files changed by this pull request') + expect(summaryMarkdown).toContain('2 violations in files unchanged by this pull request') + + // Should show both changed and unchanged file violations + expect(summaryMarkdown).toContain('changed-file.ts') + expect(summaryMarkdown).toContain('unchanged-file.ts') + }) + }) }) diff --git a/action.yml b/action.yml index ac85482..3f7eec3 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,13 @@ inputs: This token must have write permissions for pull requests. required: false + changed-files-only: + description: |- + When set to true, only show violations in files that were changed in the pull request. Violations in unchanged files will be excluded from the summary and counts. + This option only has effect when running on a pull request with a github-token provided. + Default: false + required: false + default: 'false' outputs: exit-code: diff --git a/dist/index.js b/dist/index.js index 1561a32..dd22265 100644 --- a/dist/index.js +++ b/dist/index.js @@ -102598,7 +102598,8 @@ class RuntimeDependencies { return { runArguments: core.getInput('run-arguments'), resultsArtifactName: core.getInput('results-artifact-name'), - githubToken: core.getInput('github-token') + githubToken: core.getInput('github-token'), + changedFilesOnly: core.getBooleanInput('changed-files-only') }; } async getChangedFiles(githubToken) { @@ -102771,57 +102772,22 @@ async function run(dependencies, commandExecutor, resultsFactory, summarizer) { dependencies.startGroup(constants_1.MESSAGES.STEP_LABELS.ANALYZING_RESULTS); assertFileExists(dependencies, jsonOutputFile); const results = resultsFactory.createResults(jsonOutputFile); - dependencies.setOutput('num-violations', results.getTotalViolationCount().toString()); - dependencies.setOutput('num-sev1-violations', results.getSev1ViolationCount().toString()); - dependencies.setOutput('num-sev2-violations', results.getSev2ViolationCount().toString()); - dependencies.setOutput('num-sev3-violations', results.getSev3ViolationCount().toString()); - dependencies.setOutput('num-sev4-violations', results.getSev4ViolationCount().toString()); - dependencies.setOutput('num-sev5-violations', results.getSev5ViolationCount().toString()); - dependencies.info(`outputs:\n` + - ` exit-code: ${codeAnalyzerOutput.exitCode}\n` + - ` num-violations: ${results.getTotalViolationCount()}\n` + - ` num-sev1-violations: ${results.getSev1ViolationCount()}\n` + - ` num-sev2-violations: ${results.getSev2ViolationCount()}\n` + - ` num-sev3-violations: ${results.getSev3ViolationCount()}\n` + - ` num-sev4-violations: ${results.getSev4ViolationCount()}\n` + - ` num-sev5-violations: ${results.getSev5ViolationCount()}`); + dependencies.info(`Parsed results from ${jsonOutputFile}: found ${results.getTotalViolationCount()} total violations across all files`); dependencies.endGroup(); dependencies.startGroup(constants_1.MESSAGES.STEP_LABELS.CREATING_SUMMARY); + let changedFiles = []; + let couldReadChangedFiles = false; + // Get changed files for PR context if (dependencies.isPullRequest() && inputs.githubToken) { - let changedFiles = []; - let couldReadChangedFiles = true; try { dependencies.info(constants_1.MESSAGES.CALCULATING_CHANGED_FILES); changedFiles = await dependencies.getChangedFiles(inputs.githubToken); + couldReadChangedFiles = true; dependencies.info(constants_1.MESSAGES.CALCULATED_CHANGED_FILES); } catch (error) { - couldReadChangedFiles = false; dependencies.warn(constants_1.MESSAGE_FCNS.FAILED_TO_GET_CHANGED_FILES((0, utils_1.getFullErrorMessage)(error))); } - const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles); - if (couldReadChangedFiles) { - const summaryLink = await dependencies.createActionSummaryLink(inputs.githubToken); - const changedFilesSet = new Set(changedFiles); - const violationsInChangedFilesCount = results - .getViolationsSortedBySeverity() - .filter((v) => v - .getLocations() - .map(l => l.getFile()) - .some(f => f && changedFilesSet.has(f))).length; - const summaryBody = constants_1.MESSAGE_FCNS.REVIEW_BODY(results.getTotalViolationCount(), violationsInChangedFilesCount, summaryLink); - try { - dependencies.info(constants_1.MESSAGES.ATTEMPTING_TO_CREATE_PR_REVIEW); - const reviewId = await dependencies.createPullRequestReview(inputs.githubToken, summaryBody); - dependencies.setOutput('review-id', `${reviewId}`); - dependencies.info(constants_1.MESSAGE_FCNS.CREATED_PR_REVIEW(reviewId)); - } - catch (error) { - dependencies.warn(constants_1.MESSAGE_FCNS.FAILED_TO_CREATE_REVIEW((0, utils_1.getFullErrorMessage)(error))); - } - } - await dependencies.writeSummary(summaryMarkdown); - dependencies.endGroup(); } else { if (dependencies.isPullRequest()) { @@ -102830,15 +102796,81 @@ async function run(dependencies, commandExecutor, resultsFactory, summarizer) { else { dependencies.info(constants_1.MESSAGES.NOT_PR); } - const summaryMarkdown = summarizer.createSummaryMarkdown(results); - await dependencies.writeSummary(summaryMarkdown); - dependencies.endGroup(); } + // Calculate violation counts based on mode + const violationCounts = calculateViolationCounts(results, inputs.changedFilesOnly && couldReadChangedFiles ? changedFiles : undefined); + // Set outputs with final counts + dependencies.setOutput('num-violations', violationCounts.total.toString()); + dependencies.setOutput('num-sev1-violations', violationCounts.sev1.toString()); + dependencies.setOutput('num-sev2-violations', violationCounts.sev2.toString()); + dependencies.setOutput('num-sev3-violations', violationCounts.sev3.toString()); + dependencies.setOutput('num-sev4-violations', violationCounts.sev4.toString()); + dependencies.setOutput('num-sev5-violations', violationCounts.sev5.toString()); + dependencies.info(`outputs:\n` + + ` exit-code: ${codeAnalyzerOutput.exitCode}\n` + + ` num-violations: ${violationCounts.total}\n` + + ` num-sev1-violations: ${violationCounts.sev1}\n` + + ` num-sev2-violations: ${violationCounts.sev2}\n` + + ` num-sev3-violations: ${violationCounts.sev3}\n` + + ` num-sev4-violations: ${violationCounts.sev4}\n` + + ` num-sev5-violations: ${violationCounts.sev5}`); + // Generate summary + const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles, inputs.changedFilesOnly); + // Create PR review if applicable + if (dependencies.isPullRequest() && inputs.githubToken && couldReadChangedFiles) { + const summaryLink = await dependencies.createActionSummaryLink(inputs.githubToken); + const changedFilesSet = new Set(changedFiles); + const violationsInChangedFilesCount = results + .getViolationsSortedBySeverity() + .filter((v) => v + .getLocations() + .map(l => l.getFile()) + .some(f => f && changedFilesSet.has(f))).length; + const summaryBody = constants_1.MESSAGE_FCNS.REVIEW_BODY(results.getTotalViolationCount(), violationsInChangedFilesCount, summaryLink); + try { + dependencies.info(constants_1.MESSAGES.ATTEMPTING_TO_CREATE_PR_REVIEW); + const reviewId = await dependencies.createPullRequestReview(inputs.githubToken, summaryBody); + dependencies.setOutput('review-id', `${reviewId}`); + dependencies.info(constants_1.MESSAGE_FCNS.CREATED_PR_REVIEW(reviewId)); + } + catch (error) { + dependencies.warn(constants_1.MESSAGE_FCNS.FAILED_TO_CREATE_REVIEW((0, utils_1.getFullErrorMessage)(error))); + } + } + await dependencies.writeSummary(summaryMarkdown); + dependencies.endGroup(); } catch (error) { dependencies.fail(`${constants_1.MESSAGES.UNEXPECTED_ERROR}\n\n${(0, utils_1.getFullErrorMessage)(error)}`); } } +/** + * Calculate violation counts, optionally filtered by changed files + * @param results - The full results from the code analyzer + * @param changedFiles - Optional array of changed file paths to filter by + * @returns Object containing counts for each severity level and total + */ +function calculateViolationCounts(results, changedFiles) { + let violations; + // Use all violations + violations = results.getViolationsSortedBySeverity(); + if (changedFiles && changedFiles.length > 0) { + // Filter to only violations in changed files + const changedFilesSet = new Set(changedFiles); + violations = results.getViolationsSortedBySeverity().filter((v) => v + .getLocations() + .map(l => l.getFile()) + .some(f => f && changedFilesSet.has(f))); + } + return { + total: violations.length, + sev1: violations.filter(v => v.getSeverity() === 1).length, + sev2: violations.filter(v => v.getSeverity() === 2).length, + sev3: violations.filter(v => v.getSeverity() === 3).length, + sev4: violations.filter(v => v.getSeverity() === 4).length, + sev5: violations.filter(v => v.getSeverity() === 5).length + }; +} async function installSalesforceCliIfNeeded(dependencies, commandExecutor) { if (!(await commandExecutor.isSalesforceCliInstalled())) { dependencies.warn(constants_1.MESSAGES.SF_CLI_NOT_INSTALLED); @@ -103088,25 +103120,13 @@ const SEVERITY_EMOJIS = new Map([ [5, ':white_circle:'] ]); class RuntimeSummarizer { - createSummaryMarkdown(results, changedFiles = []) { + createSummaryMarkdown(results, changedFiles = [], changedFilesOnly = false) { let summary = `## Salesforce Code Analyzer Results${os_1.EOL}`; - if (results.getTotalViolationCount() === 0) { - summary += `### :white_check_mark: 0 Violations Found${os_1.EOL}`; - return summary; - } - summary += - `### :warning: ${results.getTotalViolationCount()} Violation(s) Found${os_1.EOL}` + - `
${os_1.EOL}` + - `${SEVERITY_EMOJIS.get(1)} ${results.getSev1ViolationCount()} Critical severity violation(s)
${os_1.EOL}` + - `${SEVERITY_EMOJIS.get(2)} ${results.getSev2ViolationCount()} High severity violation(s)
${os_1.EOL}` + - `${SEVERITY_EMOJIS.get(3)} ${results.getSev3ViolationCount()} Medium severity violation(s)
${os_1.EOL}` + - `${SEVERITY_EMOJIS.get(4)} ${results.getSev4ViolationCount()} Low severity violation(s)
${os_1.EOL}` + - `${SEVERITY_EMOJIS.get(5)} ${results.getSev5ViolationCount()} Info severity violation(s)${os_1.EOL}` + - `
${os_1.EOL}`; const violations = results.getViolationsSortedBySeverity(); const changedFilesSet = new Set(changedFiles); const violationsInChangedFiles = []; const violationsOutsideChangedFiles = []; + // Separate violations by whether they're in changed files for (const violation of violations) { const hasLocationInChangedFile = violation .getLocations() @@ -103119,7 +103139,44 @@ class RuntimeSummarizer { violationsOutsideChangedFiles.push(violation); } } - if (violationsInChangedFiles.length > 0 && violationsOutsideChangedFiles.length > 0) { + // Determine which violations to show based on changedFilesOnly flag + const violationsToShow = changedFilesOnly && changedFiles.length > 0 ? violationsInChangedFiles : violations; + // Calculate counts for the violations we're showing + const totalCount = violationsToShow.length; + const sev1Count = violationsToShow.filter(v => v.getSeverity() === 1).length; + const sev2Count = violationsToShow.filter(v => v.getSeverity() === 2).length; + const sev3Count = violationsToShow.filter(v => v.getSeverity() === 3).length; + const sev4Count = violationsToShow.filter(v => v.getSeverity() === 4).length; + const sev5Count = violationsToShow.filter(v => v.getSeverity() === 5).length; + if (totalCount === 0) { + if (changedFilesOnly && changedFiles.length > 0) { + summary += `### :white_check_mark: 0 Violations Found in Changed Files${os_1.EOL}`; + } + else { + summary += `### :white_check_mark: 0 Violations Found${os_1.EOL}`; + } + return summary; + } + if (changedFilesOnly && changedFiles.length > 0) { + summary += `### :warning: ${totalCount} Violation(s) Found in Changed Files${os_1.EOL}`; + } + else { + summary += `### :warning: ${totalCount} Violation(s) Found${os_1.EOL}`; + } + summary += + `
${os_1.EOL}` + + `${SEVERITY_EMOJIS.get(1)} ${sev1Count} Critical severity violation(s)
${os_1.EOL}` + + `${SEVERITY_EMOJIS.get(2)} ${sev2Count} High severity violation(s)
${os_1.EOL}` + + `${SEVERITY_EMOJIS.get(3)} ${sev3Count} Medium severity violation(s)
${os_1.EOL}` + + `${SEVERITY_EMOJIS.get(4)} ${sev4Count} Low severity violation(s)
${os_1.EOL}` + + `${SEVERITY_EMOJIS.get(5)} ${sev5Count} Info severity violation(s)${os_1.EOL}` + + `
${os_1.EOL}`; + // Show violations in appropriate format + if (!changedFilesOnly && + changedFiles.length > 0 && + violationsInChangedFiles.length > 0 && + violationsOutsideChangedFiles.length > 0) { + // Show both sections when not filtering and both exist const violationsInsideFilesTable = createTable(violationsInChangedFiles, TABLE_ROWS_CHAR_LIMIT); const violationsOutsideFilesTable = createTable(violationsOutsideChangedFiles, TABLE_ROWS_CHAR_LIMIT - violationsInsideFilesTable.length); summary += @@ -103134,7 +103191,8 @@ class RuntimeSummarizer { `${os_1.EOL}`; } else { - summary += createTable(violations, TABLE_ROWS_CHAR_LIMIT); + // Show only the filtered violations + summary += createTable(violationsToShow, TABLE_ROWS_CHAR_LIMIT); } return summary; } diff --git a/src/dependencies.ts b/src/dependencies.ts index 97898d1..ed148cf 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -73,7 +73,8 @@ export class RuntimeDependencies implements Dependencies { return { runArguments: core.getInput('run-arguments'), resultsArtifactName: core.getInput('results-artifact-name'), - githubToken: core.getInput('github-token') + githubToken: core.getInput('github-token'), + changedFilesOnly: core.getBooleanInput('changed-files-only') } } diff --git a/src/main.ts b/src/main.ts index 2a60ab0..2a69022 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,82 +61,130 @@ export async function run( dependencies.startGroup(MESSAGES.STEP_LABELS.ANALYZING_RESULTS) assertFileExists(dependencies, jsonOutputFile) const results: Results = resultsFactory.createResults(jsonOutputFile) - dependencies.setOutput('num-violations', results.getTotalViolationCount().toString()) - dependencies.setOutput('num-sev1-violations', results.getSev1ViolationCount().toString()) - dependencies.setOutput('num-sev2-violations', results.getSev2ViolationCount().toString()) - dependencies.setOutput('num-sev3-violations', results.getSev3ViolationCount().toString()) - dependencies.setOutput('num-sev4-violations', results.getSev4ViolationCount().toString()) - dependencies.setOutput('num-sev5-violations', results.getSev5ViolationCount().toString()) dependencies.info( - `outputs:\n` + - ` exit-code: ${codeAnalyzerOutput.exitCode}\n` + - ` num-violations: ${results.getTotalViolationCount()}\n` + - ` num-sev1-violations: ${results.getSev1ViolationCount()}\n` + - ` num-sev2-violations: ${results.getSev2ViolationCount()}\n` + - ` num-sev3-violations: ${results.getSev3ViolationCount()}\n` + - ` num-sev4-violations: ${results.getSev4ViolationCount()}\n` + - ` num-sev5-violations: ${results.getSev5ViolationCount()}` + `Parsed results from ${jsonOutputFile}: found ${results.getTotalViolationCount()} total violations across all files` ) dependencies.endGroup() dependencies.startGroup(MESSAGES.STEP_LABELS.CREATING_SUMMARY) + let changedFiles: string[] = [] + let couldReadChangedFiles = false + + // Get changed files for PR context if (dependencies.isPullRequest() && inputs.githubToken) { - let changedFiles: string[] = [] - let couldReadChangedFiles = true try { dependencies.info(MESSAGES.CALCULATING_CHANGED_FILES) changedFiles = await dependencies.getChangedFiles(inputs.githubToken) + couldReadChangedFiles = true dependencies.info(MESSAGES.CALCULATED_CHANGED_FILES) } catch (error) { - couldReadChangedFiles = false dependencies.warn(MESSAGE_FCNS.FAILED_TO_GET_CHANGED_FILES(getFullErrorMessage(error))) } - - const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles) - - if (couldReadChangedFiles) { - const summaryLink: string = await dependencies.createActionSummaryLink(inputs.githubToken) - const changedFilesSet: Set = new Set(changedFiles) - const violationsInChangedFilesCount: number = results - .getViolationsSortedBySeverity() - .filter((v: Violation): boolean => - v - .getLocations() - .map(l => l.getFile()) - .some(f => f && changedFilesSet.has(f)) - ).length - const summaryBody = MESSAGE_FCNS.REVIEW_BODY( - results.getTotalViolationCount(), - violationsInChangedFilesCount, - summaryLink - ) - try { - dependencies.info(MESSAGES.ATTEMPTING_TO_CREATE_PR_REVIEW) - const reviewId: number = await dependencies.createPullRequestReview(inputs.githubToken, summaryBody) - dependencies.setOutput('review-id', `${reviewId}`) - dependencies.info(MESSAGE_FCNS.CREATED_PR_REVIEW(reviewId)) - } catch (error) { - dependencies.warn(MESSAGE_FCNS.FAILED_TO_CREATE_REVIEW(getFullErrorMessage(error))) - } - } - - await dependencies.writeSummary(summaryMarkdown) - dependencies.endGroup() } else { if (dependencies.isPullRequest()) { dependencies.info(MESSAGES.PR_FOUND_WITHOUT_GH_TOKEN) } else { dependencies.info(MESSAGES.NOT_PR) } - const summaryMarkdown = summarizer.createSummaryMarkdown(results) - await dependencies.writeSummary(summaryMarkdown) - dependencies.endGroup() } + + // Calculate violation counts based on mode + const violationCounts = calculateViolationCounts( + results, + inputs.changedFilesOnly && couldReadChangedFiles ? changedFiles : undefined + ) + + // Set outputs with final counts + dependencies.setOutput('num-violations', violationCounts.total.toString()) + dependencies.setOutput('num-sev1-violations', violationCounts.sev1.toString()) + dependencies.setOutput('num-sev2-violations', violationCounts.sev2.toString()) + dependencies.setOutput('num-sev3-violations', violationCounts.sev3.toString()) + dependencies.setOutput('num-sev4-violations', violationCounts.sev4.toString()) + dependencies.setOutput('num-sev5-violations', violationCounts.sev5.toString()) + dependencies.info( + `outputs:\n` + + ` exit-code: ${codeAnalyzerOutput.exitCode}\n` + + ` num-violations: ${violationCounts.total}\n` + + ` num-sev1-violations: ${violationCounts.sev1}\n` + + ` num-sev2-violations: ${violationCounts.sev2}\n` + + ` num-sev3-violations: ${violationCounts.sev3}\n` + + ` num-sev4-violations: ${violationCounts.sev4}\n` + + ` num-sev5-violations: ${violationCounts.sev5}` + ) + + // Generate summary + const summaryMarkdown = summarizer.createSummaryMarkdown(results, changedFiles, inputs.changedFilesOnly) + + // Create PR review if applicable + if (dependencies.isPullRequest() && inputs.githubToken && couldReadChangedFiles) { + const summaryLink: string = await dependencies.createActionSummaryLink(inputs.githubToken) + const changedFilesSet: Set = new Set(changedFiles) + const violationsInChangedFilesCount: number = results + .getViolationsSortedBySeverity() + .filter((v: Violation): boolean => + v + .getLocations() + .map(l => l.getFile()) + .some(f => f && changedFilesSet.has(f)) + ).length + + const summaryBody = MESSAGE_FCNS.REVIEW_BODY( + results.getTotalViolationCount(), + violationsInChangedFilesCount, + summaryLink + ) + try { + dependencies.info(MESSAGES.ATTEMPTING_TO_CREATE_PR_REVIEW) + const reviewId: number = await dependencies.createPullRequestReview(inputs.githubToken, summaryBody) + dependencies.setOutput('review-id', `${reviewId}`) + dependencies.info(MESSAGE_FCNS.CREATED_PR_REVIEW(reviewId)) + } catch (error) { + dependencies.warn(MESSAGE_FCNS.FAILED_TO_CREATE_REVIEW(getFullErrorMessage(error))) + } + } + + await dependencies.writeSummary(summaryMarkdown) + dependencies.endGroup() } catch (error) { dependencies.fail(`${MESSAGES.UNEXPECTED_ERROR}\n\n${getFullErrorMessage(error)}`) } } +/** + * Calculate violation counts, optionally filtered by changed files + * @param results - The full results from the code analyzer + * @param changedFiles - Optional array of changed file paths to filter by + * @returns Object containing counts for each severity level and total + */ +function calculateViolationCounts( + results: Results, + changedFiles?: string[] +): { total: number; sev1: number; sev2: number; sev3: number; sev4: number; sev5: number } { + let violations: Violation[] + + // Use all violations + violations = results.getViolationsSortedBySeverity() + + if (changedFiles && changedFiles.length > 0) { + // Filter to only violations in changed files + const changedFilesSet = new Set(changedFiles) + violations = results.getViolationsSortedBySeverity().filter((v: Violation): boolean => + v + .getLocations() + .map(l => l.getFile()) + .some(f => f && changedFilesSet.has(f)) + ) + } + return { + total: violations.length, + sev1: violations.filter(v => v.getSeverity() === 1).length, + sev2: violations.filter(v => v.getSeverity() === 2).length, + sev3: violations.filter(v => v.getSeverity() === 3).length, + sev4: violations.filter(v => v.getSeverity() === 4).length, + sev5: violations.filter(v => v.getSeverity() === 5).length + } +} + async function installSalesforceCliIfNeeded( dependencies: Dependencies, commandExecutor: CommandExecutor diff --git a/src/summary.ts b/src/summary.ts index 5592d25..83f5e68 100644 --- a/src/summary.ts +++ b/src/summary.ts @@ -14,31 +14,19 @@ const SEVERITY_EMOJIS: Map = new Map([ ]) export interface Summarizer { - createSummaryMarkdown(results: Results, changedFiles?: string[]): string + createSummaryMarkdown(results: Results, changedFiles?: string[], changedFilesOnly?: boolean): string } export class RuntimeSummarizer implements Summarizer { - createSummaryMarkdown(results: Results, changedFiles: string[] = []): string { + createSummaryMarkdown(results: Results, changedFiles: string[] = [], changedFilesOnly = false): string { let summary = `## Salesforce Code Analyzer Results${EOL}` - if (results.getTotalViolationCount() === 0) { - summary += `### :white_check_mark: 0 Violations Found${EOL}` - return summary - } - - summary += - `### :warning: ${results.getTotalViolationCount()} Violation(s) Found${EOL}` + - `
${EOL}` + - `${SEVERITY_EMOJIS.get(1)} ${results.getSev1ViolationCount()} Critical severity violation(s)
${EOL}` + - `${SEVERITY_EMOJIS.get(2)} ${results.getSev2ViolationCount()} High severity violation(s)
${EOL}` + - `${SEVERITY_EMOJIS.get(3)} ${results.getSev3ViolationCount()} Medium severity violation(s)
${EOL}` + - `${SEVERITY_EMOJIS.get(4)} ${results.getSev4ViolationCount()} Low severity violation(s)
${EOL}` + - `${SEVERITY_EMOJIS.get(5)} ${results.getSev5ViolationCount()} Info severity violation(s)${EOL}` + - `
${EOL}` const violations: Violation[] = results.getViolationsSortedBySeverity() const changedFilesSet = new Set(changedFiles) const violationsInChangedFiles: Violation[] = [] const violationsOutsideChangedFiles: Violation[] = [] + + // Separate violations by whether they're in changed files for (const violation of violations) { const hasLocationInChangedFile: boolean = violation .getLocations() @@ -51,7 +39,50 @@ export class RuntimeSummarizer implements Summarizer { } } - if (violationsInChangedFiles.length > 0 && violationsOutsideChangedFiles.length > 0) { + // Determine which violations to show based on changedFilesOnly flag + const violationsToShow: Violation[] = + changedFilesOnly && changedFiles.length > 0 ? violationsInChangedFiles : violations + + // Calculate counts for the violations we're showing + const totalCount = violationsToShow.length + const sev1Count = violationsToShow.filter(v => v.getSeverity() === 1).length + const sev2Count = violationsToShow.filter(v => v.getSeverity() === 2).length + const sev3Count = violationsToShow.filter(v => v.getSeverity() === 3).length + const sev4Count = violationsToShow.filter(v => v.getSeverity() === 4).length + const sev5Count = violationsToShow.filter(v => v.getSeverity() === 5).length + + if (totalCount === 0) { + if (changedFilesOnly && changedFiles.length > 0) { + summary += `### :white_check_mark: 0 Violations Found in Changed Files${EOL}` + } else { + summary += `### :white_check_mark: 0 Violations Found${EOL}` + } + return summary + } + + if (changedFilesOnly && changedFiles.length > 0) { + summary += `### :warning: ${totalCount} Violation(s) Found in Changed Files${EOL}` + } else { + summary += `### :warning: ${totalCount} Violation(s) Found${EOL}` + } + + summary += + `
${EOL}` + + `${SEVERITY_EMOJIS.get(1)} ${sev1Count} Critical severity violation(s)
${EOL}` + + `${SEVERITY_EMOJIS.get(2)} ${sev2Count} High severity violation(s)
${EOL}` + + `${SEVERITY_EMOJIS.get(3)} ${sev3Count} Medium severity violation(s)
${EOL}` + + `${SEVERITY_EMOJIS.get(4)} ${sev4Count} Low severity violation(s)
${EOL}` + + `${SEVERITY_EMOJIS.get(5)} ${sev5Count} Info severity violation(s)${EOL}` + + `
${EOL}` + + // Show violations in appropriate format + if ( + !changedFilesOnly && + changedFiles.length > 0 && + violationsInChangedFiles.length > 0 && + violationsOutsideChangedFiles.length > 0 + ) { + // Show both sections when not filtering and both exist const violationsInsideFilesTable: string = createTable(violationsInChangedFiles, TABLE_ROWS_CHAR_LIMIT) const violationsOutsideFilesTable: string = createTable( violationsOutsideChangedFiles, @@ -68,7 +99,8 @@ export class RuntimeSummarizer implements Summarizer { violationsOutsideFilesTable + `${EOL}` } else { - summary += createTable(violations, TABLE_ROWS_CHAR_LIMIT) + // Show only the filtered violations + summary += createTable(violationsToShow, TABLE_ROWS_CHAR_LIMIT) } return summary diff --git a/src/types.ts b/src/types.ts index 50019de..6c99f0e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export type Inputs = { runArguments: string resultsArtifactName: string githubToken?: string + changedFilesOnly: boolean } export type EnvironmentVariables = { [key: string]: string }