-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add history logic (#541)
- Loading branch information
Showing
12 changed files
with
363 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', '[email protected]'); | ||
}); | ||
|
||
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.', | ||
); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<CoreConfig, 'plugins' | 'categories'> | ||
> & { | ||
persist: Required<PersistConfig>; | ||
upload?: Required<UploadConfig>; | ||
} & HistoryOnlyOptions & | ||
Partial<GlobalOptions>; | ||
|
||
export async function history( | ||
config: HistoryOptions, | ||
commits: string[], | ||
): Promise<string[]> { | ||
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<string[]> { | ||
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() | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: '[email protected]', | ||
}, | ||
{ | ||
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: '[email protected]', | ||
}, | ||
], | ||
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: '[email protected]', | ||
}, | ||
}), | ||
).toStrictEqual([ | ||
'111b284e48ddf464a498dcf22426a9ce65e2c01c', | ||
'22287eb716a84f82b5d59e7238ffcae7147f707a', | ||
]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.