diff --git a/package-lock.json b/package-lock.json index 5e1051211..5c6ca00b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@code-pushup/portal-client": "^0.8.0", + "@code-pushup/portal-client": "^0.9.0", "@isaacs/cliui": "^8.0.2", "@nx/devkit": "17.3.2", "@poppinss/cliui": "^6.4.0", @@ -2125,9 +2125,9 @@ } }, "node_modules/@code-pushup/portal-client": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.8.0.tgz", - "integrity": "sha512-JzmpFqkbyypN9VNzbfZ7QheYPnQhgBMWUfXGuKfCiTZbG9GCP3PurNpKkCxNJRVmSpd/czS8pP6A5soa1pkByQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.9.0.tgz", + "integrity": "sha512-ABQlc6x24UflEm7uO7Tt9KlU0NnhbmbAj7lUv7H9dgxgJ0fcK5R3rSp7hYab3npawb2nX+6+aG2OaPSiYsFPBQ==", "dependencies": { "graphql": "^16.6.0", "graphql-request": "^6.1.0", diff --git a/package.json b/package.json index 60d8a97e9..786fa0162 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "node": ">=18.16" }, "dependencies": { - "@code-pushup/portal-client": "^0.8.0", + "@code-pushup/portal-client": "^0.9.0", "@isaacs/cliui": "^8.0.2", "@nx/devkit": "17.3.2", "@poppinss/cliui": "^6.4.0", diff --git a/packages/cli/src/lib/compare/compare-command.ts b/packages/cli/src/lib/compare/compare-command.ts index 0c19b0bea..880f6c53c 100644 --- a/packages/cli/src/lib/compare/compare-command.ts +++ b/packages/cli/src/lib/compare/compare-command.ts @@ -1,10 +1,10 @@ import { bold, gray } from 'ansis'; import { CommandModule } from 'yargs'; import { compareReportFiles } from '@code-pushup/core'; -import { PersistConfig } from '@code-pushup/models'; +import { PersistConfig, UploadConfig } from '@code-pushup/models'; import { ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants'; -import type { CompareOptions } from '../implementation/compare.model'; +import { CompareOptions } from '../implementation/compare.model'; import { yargsCompareOptionsDefinition } from '../implementation/compare.options'; export function yargsCompareCommandObject() { @@ -19,11 +19,16 @@ export function yargsCompareCommandObject() { const options = args as CompareOptions & { persist: Required; + upload?: UploadConfig; }; - const { before, after, persist } = options; + const { before, after, persist, upload } = options; - const outputPaths = await compareReportFiles({ before, after }, persist); + const outputPaths = await compareReportFiles( + { before, after }, + persist, + upload, + ); ui().logger.info( `Reports diff written to ${outputPaths diff --git a/packages/cli/src/lib/compare/compare-command.unit.test.ts b/packages/cli/src/lib/compare/compare-command.unit.test.ts index a633fd0d1..b2ece7b42 100644 --- a/packages/cli/src/lib/compare/compare-command.unit.test.ts +++ b/packages/cli/src/lib/compare/compare-command.unit.test.ts @@ -43,6 +43,7 @@ describe('compare-command', () => { filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, }, + expect.any(Object), ); }); diff --git a/packages/core/package.json b/packages/core/package.json index 4d793c5d0..06f3f3692 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,7 @@ "dependencies": { "@code-pushup/models": "0.48.0", "@code-pushup/utils": "0.48.0", - "@code-pushup/portal-client": "^0.8.0", + "@code-pushup/portal-client": "^0.9.0", "ansis": "^3.3.0" }, "type": "commonjs", diff --git a/packages/core/src/lib/compare.ts b/packages/core/src/lib/compare.ts index f048e02e4..d06be5c9d 100644 --- a/packages/core/src/lib/compare.ts +++ b/packages/core/src/lib/compare.ts @@ -1,10 +1,15 @@ import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { + PortalOperationError, + getPortalComparisonLink, +} from '@code-pushup/portal-client'; import { type Format, type PersistConfig, Report, ReportsDiff, + type UploadConfig, reportSchema, } from '@code-pushup/models'; import { @@ -14,6 +19,7 @@ import { generateMdReportsDiff, readJsonFile, scoreReport, + ui, } from '@code-pushup/utils'; import { name as packageName, version } from '../../package.json'; import { @@ -26,6 +32,7 @@ import { export async function compareReportFiles( inputPaths: Diff, persistConfig: Required, + uploadConfig: UploadConfig | undefined, ): Promise { const { outputDir, filename, format } = persistConfig; @@ -40,10 +47,15 @@ export async function compareReportFiles( const reportsDiff = compareReports(reports); + const portalUrl = + uploadConfig && reportsDiff.commits && format.includes('md') + ? await fetchPortalComparisonLink(uploadConfig, reportsDiff.commits) + : undefined; + return Promise.all( format.map(async fmt => { const outputPath = join(outputDir, `${filename}-diff.${fmt}`); - const content = reportsDiffToFileContent(reportsDiff, fmt); + const content = reportsDiffToFileContent(reportsDiff, fmt, portalUrl); await ensureDirectoryExists(outputDir); await writeFile(outputPath, content); return outputPath; @@ -86,11 +98,39 @@ export function compareReports(reports: Diff): ReportsDiff { function reportsDiffToFileContent( reportsDiff: ReportsDiff, format: Format, + portalUrl: string | undefined, ): string { switch (format) { case 'json': return JSON.stringify(reportsDiff, null, 2); case 'md': - return generateMdReportsDiff(reportsDiff); + return generateMdReportsDiff(reportsDiff, portalUrl ?? undefined); + } +} + +async function fetchPortalComparisonLink( + uploadConfig: UploadConfig, + commits: NonNullable, +): Promise { + const { server, apiKey, organization, project } = uploadConfig; + try { + return await getPortalComparisonLink({ + server, + apiKey, + parameters: { + organization, + project, + before: commits.before.hash, + after: commits.after.hash, + }, + }); + } catch (error) { + if (error instanceof PortalOperationError) { + ui().logger.warning( + `Failed to fetch portal comparison link - ${error.message}`, + ); + return undefined; + } + throw error; } } diff --git a/packages/core/src/lib/compare.unit.test.ts b/packages/core/src/lib/compare.unit.test.ts index 05ab40580..72015ab6d 100644 --- a/packages/core/src/lib/compare.unit.test.ts +++ b/packages/core/src/lib/compare.unit.test.ts @@ -1,5 +1,7 @@ import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { getPortalComparisonLink } from '@code-pushup/portal-client'; import { Commit, Report, reportsDiffSchema } from '@code-pushup/models'; import { COMMIT_ALT_MOCK, @@ -13,6 +15,11 @@ import { Diff, fileExists, readJsonFile } from '@code-pushup/utils'; import { compareReportFiles, compareReports } from './compare'; describe('compareReportFiles', () => { + const commitShas = { + before: MINIMAL_REPORT_MOCK.commit!.hash, + after: REPORT_MOCK.commit!.hash, + }; + beforeEach(() => { vol.fromJSON( { @@ -30,6 +37,7 @@ describe('compareReportFiles', () => { after: join(MEMFS_VOLUME, 'target-report.json'), }, { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json'] }, + undefined, ); const reportsDiffPromise = readJsonFile( @@ -48,6 +56,7 @@ describe('compareReportFiles', () => { after: join(MEMFS_VOLUME, 'target-report.json'), }, { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json', 'md'] }, + undefined, ); await expect( @@ -57,6 +66,116 @@ describe('compareReportFiles', () => { fileExists(join(MEMFS_VOLUME, 'report-diff.md')), ).resolves.toBeTruthy(); }); + + it('should include portal link (fetched using upload config) in Markdown file', async () => { + await compareReportFiles( + { + before: join(MEMFS_VOLUME, 'source-report.json'), + after: join(MEMFS_VOLUME, 'target-report.json'), + }, + { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json', 'md'] }, + { + server: 'https://api.code-pushup.dev/graphql', + apiKey: 'cp_XXXXX', + organization: 'dunder-mifflin', + project: 'website', + }, + ); + + await expect( + readFile(join(MEMFS_VOLUME, 'report-diff.md'), 'utf8'), + ).resolves.toContain( + `[🕵️ See full comparison in Code PushUp portal 🔍](https://code-pushup.example.com/portal/dunder-mifflin/website/comparison/${commitShas.before}/${commitShas.after})`, + ); + + expect(getPortalComparisonLink).toHaveBeenCalledWith< + Parameters + >({ + server: 'https://api.code-pushup.dev/graphql', + apiKey: 'cp_XXXXX', + parameters: { + organization: 'dunder-mifflin', + project: 'website', + before: commitShas.before, + after: commitShas.after, + }, + }); + }); + + it('should not include portal link in Markdown if upload config is missing', async () => { + await compareReportFiles( + { + before: join(MEMFS_VOLUME, 'source-report.json'), + after: join(MEMFS_VOLUME, 'target-report.json'), + }, + { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json', 'md'] }, + undefined, + ); + + await expect( + readFile(join(MEMFS_VOLUME, 'report-diff.md'), 'utf8'), + ).resolves.not.toContain( + '[🕵️ See full comparison in Code PushUp portal 🔍]', + ); + + expect(getPortalComparisonLink).not.toHaveBeenCalled(); + }); + + it('should not include portal link in Markdown if report has no associated commits', async () => { + vol.fromJSON( + { + 'source-report.json': JSON.stringify({ + ...MINIMAL_REPORT_MOCK, + commit: null, + } satisfies Report), + 'target-report.json': JSON.stringify(REPORT_MOCK), + }, + MEMFS_VOLUME, + ); + await compareReportFiles( + { + before: join(MEMFS_VOLUME, 'source-report.json'), + after: join(MEMFS_VOLUME, 'target-report.json'), + }, + { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json', 'md'] }, + { + server: 'https://api.code-pushup.dev/graphql', + apiKey: 'cp_XXXXX', + organization: 'dunder-mifflin', + project: 'website', + }, + ); + + await expect( + readFile(join(MEMFS_VOLUME, 'report-diff.md'), 'utf8'), + ).resolves.not.toContain( + '[🕵️ See full comparison in Code PushUp portal 🔍]', + ); + + expect(getPortalComparisonLink).not.toHaveBeenCalled(); + }); + + it('should not fetch portal link if Markdown not included in formats', async () => { + await compareReportFiles( + { + before: join(MEMFS_VOLUME, 'source-report.json'), + after: join(MEMFS_VOLUME, 'target-report.json'), + }, + { outputDir: MEMFS_VOLUME, filename: 'report', format: ['json'] }, + { + server: 'https://api.code-pushup.dev/graphql', + apiKey: 'cp_XXXXX', + organization: 'dunder-mifflin', + project: 'website', + }, + ); + + expect(getPortalComparisonLink).not.toHaveBeenCalled(); + + await expect( + fileExists(join(MEMFS_VOLUME, 'report-diff.md')), + ).resolves.toBeFalsy(); + }); }); describe('compareReports', () => { diff --git a/testing/test-setup/src/lib/portal-client.mock.ts b/testing/test-setup/src/lib/portal-client.mock.ts index c7b062121..54dec2d05 100644 --- a/testing/test-setup/src/lib/portal-client.mock.ts +++ b/testing/test-setup/src/lib/portal-client.mock.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import type { + PortalComparisonLinkArgs, PortalUploadArgs, ReportFragment, } from '@code-pushup/portal-client'; @@ -15,5 +16,9 @@ vi.mock('@code-pushup/portal-client', async () => { url: `https://code-pushup.example.com/portal/${data.organization}/${data.project}/commit/${data.commit}`, }), ), + getPortalComparisonLink: vi.fn( + async ({ parameters }: PortalComparisonLinkArgs) => + `https://code-pushup.example.com/portal/${parameters.organization}/${parameters.project}/comparison/${parameters.before}/${parameters.after}`, + ), }; });