From 10df94ccb54f6479367213a42f7e3578c6815c7b Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Mon, 11 Mar 2024 19:57:39 +0100 Subject: [PATCH] feat(core): add history logic (#541) --- packages/core/package.json | 3 +- packages/core/src/lib/collect-and-persist.ts | 2 +- .../core/src/lib/history.integration.test.ts | 101 ++++++++++++ packages/core/src/lib/history.ts | 89 +++++++++++ packages/core/src/lib/history.unit.test.ts | 150 ++++++++++++++++++ .../core/src/lib/implementation/collect.ts | 2 +- .../src/lib/implementation/execute-plugin.ts | 2 +- packages/core/src/lib/upload.ts | 2 +- packages/utils/src/index.ts | 2 + .../utils/src/lib/git.integration.test.ts | 8 +- packages/utils/src/lib/git.ts | 6 +- .../src/lib/utils/minimal-config.mock.ts | 9 ++ 12 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/lib/history.integration.test.ts create mode 100644 packages/core/src/lib/history.ts create mode 100644 packages/core/src/lib/history.unit.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 2a228c939..64ae5c50f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,8 @@ "@code-pushup/models": "*", "@code-pushup/utils": "*", "@code-pushup/portal-client": "^0.6.1", - "chalk": "^5.3.0" + "chalk": "^5.3.0", + "simple-git": "^3.20.0" }, "type": "commonjs", "main": "./index.cjs" diff --git a/packages/core/src/lib/collect-and-persist.ts b/packages/core/src/lib/collect-and-persist.ts index f15a8d5dc..7b9a7cc21 100644 --- a/packages/core/src/lib/collect-and-persist.ts +++ b/packages/core/src/lib/collect-and-persist.ts @@ -10,7 +10,7 @@ import { GlobalOptions } from './types'; export type CollectAndPersistReportsOptions = Required< Pick -> & { persist: Required } & GlobalOptions; +> & { persist: Required } & Partial; export async function collectAndPersistReports( options: CollectAndPersistReportsOptions, diff --git a/packages/core/src/lib/history.integration.test.ts b/packages/core/src/lib/history.integration.test.ts new file mode 100644 index 000000000..d31d412fb --- /dev/null +++ b/packages/core/src/lib/history.integration.test.ts @@ -0,0 +1,101 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type SimpleGit, simpleGit } from 'simple-git'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { getHashes } from './history'; + +describe('getHashes', () => { + const baseDir = join(process.cwd(), 'tmp', 'core-history-git-test'); + let gitMock: SimpleGit; + + beforeAll(async () => { + await mkdir(baseDir, { recursive: true }); + gitMock = simpleGit(baseDir); + await gitMock.init(); + await gitMock.addConfig('user.name', 'John Doe'); + await gitMock.addConfig('user.email', 'john.doe@example.com'); + }); + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }); + }); + + describe('without a branch and commits', () => { + it('should throw', async () => { + await expect(getHashes({}, gitMock)).rejects.toThrow( + "your current branch 'master' does not have any commits yet", + ); + }); + }); + + describe('with a branch and commits clean', () => { + const commits: string[] = []; + beforeAll(async () => { + await writeFile(join(baseDir, 'README.md'), '# hello-world\n'); + await gitMock.add('README.md'); + await gitMock.commit('Create README'); + // eslint-disable-next-line functional/immutable-data + commits.push((await gitMock.log()).latest!.hash); + + await writeFile(join(baseDir, 'README.md'), '# hello-world-1\n'); + await gitMock.add('README.md'); + await gitMock.commit('Update README 1'); + // eslint-disable-next-line functional/immutable-data + commits.push((await gitMock.log()).latest!.hash); + + await writeFile(join(baseDir, 'README.md'), '# hello-world-2\n'); + await gitMock.add('README.md'); + await gitMock.commit('Update README 2'); + // eslint-disable-next-line functional/immutable-data + commits.push((await gitMock.log()).latest!.hash); + + await gitMock.branch(['feature-branch']); + await gitMock.checkout(['master']); + }); + + afterAll(async () => { + await gitMock.checkout(['master']); + await gitMock.deleteLocalBranch('feature-branch'); + }); + + it('getHashes should get all commits from log if no option is passed', async () => { + await expect(getHashes({}, gitMock)).resolves.toStrictEqual(commits); + }); + + it('getHashes should get last 2 commits from log if maxCount is set to 2', async () => { + await expect(getHashes({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([ + commits.at(-2), + commits.at(-1), + ]); + }); + + it('getHashes should get commits from log based on "from"', async () => { + await expect( + getHashes({ from: commits.at(0) }, gitMock), + ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + }); + + it('getHashes should get commits from log based on "from" and "to"', async () => { + await expect( + getHashes({ from: commits.at(-1), to: commits.at(0) }, gitMock), + ).resolves.toEqual([commits.at(-2), commits.at(-1)]); + }); + + it('getHashes should get commits from log based on "from" and "to" and "maxCount"', async () => { + await expect( + getHashes( + { from: commits.at(-1), to: commits.at(0), maxCount: 1 }, + gitMock, + ), + ).resolves.toEqual([commits.at(-1)]); + }); + + it('getHashes should throw if "from" is undefined but "to" is defined', async () => { + await expect( + getHashes({ from: undefined, to: 'a' }, gitMock), + ).rejects.toThrow( + 'git log command needs the "from" option defined to accept the "to" option.', + ); + }); + }); +}); diff --git a/packages/core/src/lib/history.ts b/packages/core/src/lib/history.ts new file mode 100644 index 000000000..4e2a51904 --- /dev/null +++ b/packages/core/src/lib/history.ts @@ -0,0 +1,89 @@ +import { LogOptions, LogResult, simpleGit } from 'simple-git'; +import { CoreConfig, PersistConfig, UploadConfig } from '@code-pushup/models'; +import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils'; +import { collectAndPersistReports } from './collect-and-persist'; +import { GlobalOptions } from './types'; +import { upload } from './upload'; + +export type HistoryOnlyOptions = { + targetBranch?: string; + skipUploads?: boolean; + forceCleanStatus?: boolean; +}; +export type HistoryOptions = Required< + Pick +> & { + persist: Required; + upload?: Required; +} & HistoryOnlyOptions & + Partial; + +export async function history( + config: HistoryOptions, + commits: string[], +): Promise { + const initialBranch: string = await getCurrentBranchOrTag(); + + const { skipUploads = false, forceCleanStatus, persist } = config; + + const reports: string[] = []; + // eslint-disable-next-line functional/no-loop-statements + for (const commit of commits) { + console.info(`Collect ${commit}`); + await safeCheckout(commit, forceCleanStatus); + + const currentConfig: HistoryOptions = { + ...config, + persist: { + ...persist, + format: ['json'], + filename: `${commit}-report`, + }, + }; + + await collectAndPersistReports(currentConfig); + + if (skipUploads) { + console.warn('Upload is skipped because skipUploads is set to true.'); + } else { + if (currentConfig.upload) { + await upload(currentConfig); + } else { + console.warn('Upload is skipped because upload config is undefined.'); + } + } + + // eslint-disable-next-line functional/immutable-data + reports.push(currentConfig.persist.filename); + } + + await safeCheckout(initialBranch, forceCleanStatus); + + return reports; +} + +export async function getHashes( + options: LogOptions, + git = simpleGit(), +): Promise { + const { from, to } = options; + + // validate that if to is given also from needs to be given + if (to && !from) { + throw new Error( + 'git log command needs the "from" option defined to accept the "to" option.', + ); + } + + const logs = await git.log(options); + return prepareHashes(logs); +} + +export function prepareHashes(logs: LogResult): string[] { + return ( + logs.all + .map(({ hash }) => hash) + // sort from oldest to newest + .reverse() + ); +} diff --git a/packages/core/src/lib/history.unit.test.ts b/packages/core/src/lib/history.unit.test.ts new file mode 100644 index 000000000..1abe6d4a3 --- /dev/null +++ b/packages/core/src/lib/history.unit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, vi } from 'vitest'; +import { MINIMAL_HISTORY_CONFIG_MOCK } from '@code-pushup/test-utils'; +import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils'; +import { collectAndPersistReports } from './collect-and-persist'; +import { HistoryOptions, history, prepareHashes } from './history'; +import { upload } from './upload'; + +vi.mock('@code-pushup/utils', async () => { + const utils: object = await vi.importActual('@code-pushup/utils'); + return { + ...utils, + safeCheckout: vi.fn(), + getCurrentBranchOrTag: vi.fn().mockReturnValue('main'), + }; +}); + +vi.mock('./collect-and-persist', () => ({ + collectAndPersistReports: vi.fn(), +})); + +vi.mock('./upload', () => ({ + upload: vi.fn(), +})); + +describe('history', () => { + it('should check out all passed commits and reset to initial branch or tag', async () => { + await history(MINIMAL_HISTORY_CONFIG_MOCK, ['abc', 'def']); + + expect(getCurrentBranchOrTag).toHaveBeenCalledTimes(1); + + expect(safeCheckout).toHaveBeenCalledTimes(3); + // walk commit history + expect(safeCheckout).toHaveBeenNthCalledWith(1, 'abc', undefined); + expect(safeCheckout).toHaveBeenNthCalledWith(2, 'def', undefined); + // reset + expect(safeCheckout).toHaveBeenNthCalledWith(3, 'main', undefined); + }); + + it('should return correct number of results', async () => { + const historyOptions: HistoryOptions = MINIMAL_HISTORY_CONFIG_MOCK; + + const results = await history(historyOptions, ['abc', 'def']); + + expect(results).toStrictEqual(['abc-report', 'def-report']); + }); + + it('should call collect with correct filename and format', async () => { + const historyOptions: HistoryOptions = MINIMAL_HISTORY_CONFIG_MOCK; + + await history(historyOptions, ['abc']); + expect(collectAndPersistReports).toHaveBeenCalledTimes(1); + expect(collectAndPersistReports).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + persist: expect.objectContaining({ + filename: 'abc-report', + format: ['json'], + }), + }), + ); + }); + + it('should call upload by default', async () => { + const historyOptions: HistoryOptions = { + ...MINIMAL_HISTORY_CONFIG_MOCK, + upload: { + server: 'https://server.com/api', + project: 'cli', + apiKey: '1234', + organization: 'code-pushup', + timeout: 4000, + }, + }; + await history(historyOptions, ['abc']); + + expect(upload).toHaveBeenCalledTimes(1); + expect(upload).toHaveBeenCalledWith( + expect.objectContaining({ + persist: expect.objectContaining({ filename: 'abc-report' }), + }), + ); + }); + + it('should not call upload if skipUploads is set to false', async () => { + const historyOptions: HistoryOptions = { + ...MINIMAL_HISTORY_CONFIG_MOCK, + upload: { + server: 'https://server.com/api', + project: 'cli', + apiKey: '1234', + organization: 'code-pushup', + timeout: 4000, + }, + skipUploads: true, + }; + await history(historyOptions, ['abc']); + + expect(upload).not.toHaveBeenCalled(); + }); + + it('should not call upload if upload config is not given', async () => { + await history(MINIMAL_HISTORY_CONFIG_MOCK, ['abc']); + + expect(upload).not.toHaveBeenCalled(); + }); +}); + +describe('prepareHashes', () => { + it('should return commit hashes in reverse order', () => { + expect( + prepareHashes({ + all: [ + { + hash: '22287eb716a84f82b5d59e7238ffcae7147f707a', + date: 'Thu Mar 7 20:13:33 2024 +0100', + message: + 'test: change test reported to basic in order to work on Windows', + refs: 'string', + body: '', + author_name: 'John Doe', + author_email: 'john.doe@gmail.com', + }, + { + hash: '111b284e48ddf464a498dcf22426a9ce65e2c01c', + date: 'Thu Mar 7 20:13:34 2024 +0100', + message: 'chore: exclude fixtures from ESLint', + refs: 'string', + body: '', + author_name: 'Jane Doe', + author_email: 'jane.doe@gmail.com', + }, + ], + total: 2, + latest: { + hash: '22287eb716a84f82b5d59e7238ffcae7147f707a', + date: 'Thu Mar 7 20:13:33 2024 +0100', + message: + 'test: change test reported to basic in order to work on Windows', + refs: 'string', + body: '', + author_name: 'John Doe', + author_email: 'john.doe@gmail.com', + }, + }), + ).toStrictEqual([ + '111b284e48ddf464a498dcf22426a9ce65e2c01c', + '22287eb716a84f82b5d59e7238ffcae7147f707a', + ]); + }); +}); diff --git a/packages/core/src/lib/implementation/collect.ts b/packages/core/src/lib/implementation/collect.ts index 067fd9b06..1ed43c0cd 100644 --- a/packages/core/src/lib/implementation/collect.ts +++ b/packages/core/src/lib/implementation/collect.ts @@ -7,7 +7,7 @@ import { executePlugins } from './execute-plugin'; export type CollectOptions = Required< Pick > & - GlobalOptions; + Partial; /** * Run audits, collect plugin output and aggregate it into a JSON object diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index f3a8b92b0..76a9d6295 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -118,7 +118,7 @@ export async function executePlugin( */ export async function executePlugins( plugins: PluginConfig[], - options?: { progress: boolean }, + options?: { progress?: boolean }, ): Promise { const { progress = false } = options ?? {}; diff --git a/packages/core/src/lib/upload.ts b/packages/core/src/lib/upload.ts index 2144e5335..168566eb0 100644 --- a/packages/core/src/lib/upload.ts +++ b/packages/core/src/lib/upload.ts @@ -9,7 +9,7 @@ import { GlobalOptions } from './types'; export type UploadOptions = { upload?: UploadConfig } & { persist: Required; -} & GlobalOptions; +} & Partial; /** * Uploads collected audits to the portal diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0ac91aa90..1c6ea07bb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -40,6 +40,8 @@ export { getGitRoot, getLatestCommit, toGitPath, + getCurrentBranchOrTag, + safeCheckout, } from './lib/git'; export { groupByStatus } from './lib/group-by-status'; export { diff --git a/packages/utils/src/lib/git.integration.test.ts b/packages/utils/src/lib/git.integration.test.ts index 99e6abb8d..61df82bc3 100644 --- a/packages/utils/src/lib/git.integration.test.ts +++ b/packages/utils/src/lib/git.integration.test.ts @@ -92,7 +92,7 @@ describe('git utils in a git repo', () => { it('safeCheckout should checkout feature-branch in clean state', async () => { await expect( - safeCheckout('feature-branch', {}, emptyGit), + safeCheckout('feature-branch', undefined, emptyGit), ).resolves.toBeUndefined(); await expect(emptyGit.branch()).resolves.toEqual( expect.objectContaining({ current: 'feature-branch' }), @@ -101,7 +101,7 @@ describe('git utils in a git repo', () => { it('safeCheckout should throw if a given branch does not exist', async () => { await expect( - safeCheckout('non-existing-branch', {}, emptyGit), + safeCheckout('non-existing-branch', undefined, emptyGit), ).rejects.toThrow( "pathspec 'non-existing-branch' did not match any file(s) known to git", ); @@ -142,7 +142,7 @@ describe('git utils in a git repo', () => { it('safeCheckout should clean local changes and check out to feature-branch', async () => { await expect( - safeCheckout('feature-branch', { forceCleanStatus: true }, emptyGit), + safeCheckout('feature-branch', true, emptyGit), ).resolves.toBeUndefined(); await expect(emptyGit.branch()).resolves.toEqual( expect.objectContaining({ current: 'feature-branch' }), @@ -153,7 +153,7 @@ describe('git utils in a git repo', () => { }); it('safeCheckout should throw if history is dirty', async () => { - await expect(safeCheckout('master', {}, emptyGit)).rejects.toThrow( + await expect(safeCheckout('master', undefined, emptyGit)).rejects.toThrow( 'Working directory needs to be clean before we you can proceed. Commit your local changes or stash them.', ); }); diff --git a/packages/utils/src/lib/git.ts b/packages/utils/src/lib/git.ts index 330a2203f..1b5e8a7ea 100644 --- a/packages/utils/src/lib/git.ts +++ b/packages/utils/src/lib/git.ts @@ -70,13 +70,11 @@ export async function getCurrentBranchOrTag( export async function safeCheckout( branchOrHash: string, - options: { - forceCleanStatus?: true; - } = {}, + forceCleanStatus = false, git = simpleGit(), ): Promise { // git requires a clean history to check out a branch - if (options.forceCleanStatus) { + if (forceCleanStatus) { await git.raw(['reset', '--hard']); await git.clean(['f', 'd']); // @TODO replace with ui().logger.info diff --git a/testing/test-utils/src/lib/utils/minimal-config.mock.ts b/testing/test-utils/src/lib/utils/minimal-config.mock.ts index 9a14245f0..8416632d4 100644 --- a/testing/test-utils/src/lib/utils/minimal-config.mock.ts +++ b/testing/test-utils/src/lib/utils/minimal-config.mock.ts @@ -49,3 +49,12 @@ export const MINIMAL_PLUGIN_CONFIG_MOCK: PluginConfig = { export const MINIMAL_CONFIG_MOCK: CoreConfig = { plugins: [MINIMAL_PLUGIN_CONFIG_MOCK], }; + +export const MINIMAL_HISTORY_CONFIG_MOCK: CoreConfig = { + persist: { + outputDir: '.code-pushup', + filename: 'history-report', + format: ['json'], + }, + plugins: [MINIMAL_PLUGIN_CONFIG_MOCK], +};