Skip to content

Commit 10df94c

Browse files
authored
feat(core): add history logic (#541)
1 parent 1405e7d commit 10df94c

File tree

12 files changed

+363
-13
lines changed

12 files changed

+363
-13
lines changed

packages/core/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"@code-pushup/models": "*",
77
"@code-pushup/utils": "*",
88
"@code-pushup/portal-client": "^0.6.1",
9-
"chalk": "^5.3.0"
9+
"chalk": "^5.3.0",
10+
"simple-git": "^3.20.0"
1011
},
1112
"type": "commonjs",
1213
"main": "./index.cjs"

packages/core/src/lib/collect-and-persist.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { GlobalOptions } from './types';
1010

1111
export type CollectAndPersistReportsOptions = Required<
1212
Pick<CoreConfig, 'plugins' | 'categories'>
13-
> & { persist: Required<PersistConfig> } & GlobalOptions;
13+
> & { persist: Required<PersistConfig> } & Partial<GlobalOptions>;
1414

1515
export async function collectAndPersistReports(
1616
options: CollectAndPersistReportsOptions,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { mkdir, rm, writeFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
3+
import { type SimpleGit, simpleGit } from 'simple-git';
4+
import { afterAll, beforeAll, describe, expect } from 'vitest';
5+
import { getHashes } from './history';
6+
7+
describe('getHashes', () => {
8+
const baseDir = join(process.cwd(), 'tmp', 'core-history-git-test');
9+
let gitMock: SimpleGit;
10+
11+
beforeAll(async () => {
12+
await mkdir(baseDir, { recursive: true });
13+
gitMock = simpleGit(baseDir);
14+
await gitMock.init();
15+
await gitMock.addConfig('user.name', 'John Doe');
16+
await gitMock.addConfig('user.email', '[email protected]');
17+
});
18+
19+
afterAll(async () => {
20+
await rm(baseDir, { recursive: true, force: true });
21+
});
22+
23+
describe('without a branch and commits', () => {
24+
it('should throw', async () => {
25+
await expect(getHashes({}, gitMock)).rejects.toThrow(
26+
"your current branch 'master' does not have any commits yet",
27+
);
28+
});
29+
});
30+
31+
describe('with a branch and commits clean', () => {
32+
const commits: string[] = [];
33+
beforeAll(async () => {
34+
await writeFile(join(baseDir, 'README.md'), '# hello-world\n');
35+
await gitMock.add('README.md');
36+
await gitMock.commit('Create README');
37+
// eslint-disable-next-line functional/immutable-data
38+
commits.push((await gitMock.log()).latest!.hash);
39+
40+
await writeFile(join(baseDir, 'README.md'), '# hello-world-1\n');
41+
await gitMock.add('README.md');
42+
await gitMock.commit('Update README 1');
43+
// eslint-disable-next-line functional/immutable-data
44+
commits.push((await gitMock.log()).latest!.hash);
45+
46+
await writeFile(join(baseDir, 'README.md'), '# hello-world-2\n');
47+
await gitMock.add('README.md');
48+
await gitMock.commit('Update README 2');
49+
// eslint-disable-next-line functional/immutable-data
50+
commits.push((await gitMock.log()).latest!.hash);
51+
52+
await gitMock.branch(['feature-branch']);
53+
await gitMock.checkout(['master']);
54+
});
55+
56+
afterAll(async () => {
57+
await gitMock.checkout(['master']);
58+
await gitMock.deleteLocalBranch('feature-branch');
59+
});
60+
61+
it('getHashes should get all commits from log if no option is passed', async () => {
62+
await expect(getHashes({}, gitMock)).resolves.toStrictEqual(commits);
63+
});
64+
65+
it('getHashes should get last 2 commits from log if maxCount is set to 2', async () => {
66+
await expect(getHashes({ maxCount: 2 }, gitMock)).resolves.toStrictEqual([
67+
commits.at(-2),
68+
commits.at(-1),
69+
]);
70+
});
71+
72+
it('getHashes should get commits from log based on "from"', async () => {
73+
await expect(
74+
getHashes({ from: commits.at(0) }, gitMock),
75+
).resolves.toEqual([commits.at(-2), commits.at(-1)]);
76+
});
77+
78+
it('getHashes should get commits from log based on "from" and "to"', async () => {
79+
await expect(
80+
getHashes({ from: commits.at(-1), to: commits.at(0) }, gitMock),
81+
).resolves.toEqual([commits.at(-2), commits.at(-1)]);
82+
});
83+
84+
it('getHashes should get commits from log based on "from" and "to" and "maxCount"', async () => {
85+
await expect(
86+
getHashes(
87+
{ from: commits.at(-1), to: commits.at(0), maxCount: 1 },
88+
gitMock,
89+
),
90+
).resolves.toEqual([commits.at(-1)]);
91+
});
92+
93+
it('getHashes should throw if "from" is undefined but "to" is defined', async () => {
94+
await expect(
95+
getHashes({ from: undefined, to: 'a' }, gitMock),
96+
).rejects.toThrow(
97+
'git log command needs the "from" option defined to accept the "to" option.',
98+
);
99+
});
100+
});
101+
});

packages/core/src/lib/history.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { LogOptions, LogResult, simpleGit } from 'simple-git';
2+
import { CoreConfig, PersistConfig, UploadConfig } from '@code-pushup/models';
3+
import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils';
4+
import { collectAndPersistReports } from './collect-and-persist';
5+
import { GlobalOptions } from './types';
6+
import { upload } from './upload';
7+
8+
export type HistoryOnlyOptions = {
9+
targetBranch?: string;
10+
skipUploads?: boolean;
11+
forceCleanStatus?: boolean;
12+
};
13+
export type HistoryOptions = Required<
14+
Pick<CoreConfig, 'plugins' | 'categories'>
15+
> & {
16+
persist: Required<PersistConfig>;
17+
upload?: Required<UploadConfig>;
18+
} & HistoryOnlyOptions &
19+
Partial<GlobalOptions>;
20+
21+
export async function history(
22+
config: HistoryOptions,
23+
commits: string[],
24+
): Promise<string[]> {
25+
const initialBranch: string = await getCurrentBranchOrTag();
26+
27+
const { skipUploads = false, forceCleanStatus, persist } = config;
28+
29+
const reports: string[] = [];
30+
// eslint-disable-next-line functional/no-loop-statements
31+
for (const commit of commits) {
32+
console.info(`Collect ${commit}`);
33+
await safeCheckout(commit, forceCleanStatus);
34+
35+
const currentConfig: HistoryOptions = {
36+
...config,
37+
persist: {
38+
...persist,
39+
format: ['json'],
40+
filename: `${commit}-report`,
41+
},
42+
};
43+
44+
await collectAndPersistReports(currentConfig);
45+
46+
if (skipUploads) {
47+
console.warn('Upload is skipped because skipUploads is set to true.');
48+
} else {
49+
if (currentConfig.upload) {
50+
await upload(currentConfig);
51+
} else {
52+
console.warn('Upload is skipped because upload config is undefined.');
53+
}
54+
}
55+
56+
// eslint-disable-next-line functional/immutable-data
57+
reports.push(currentConfig.persist.filename);
58+
}
59+
60+
await safeCheckout(initialBranch, forceCleanStatus);
61+
62+
return reports;
63+
}
64+
65+
export async function getHashes(
66+
options: LogOptions,
67+
git = simpleGit(),
68+
): Promise<string[]> {
69+
const { from, to } = options;
70+
71+
// validate that if to is given also from needs to be given
72+
if (to && !from) {
73+
throw new Error(
74+
'git log command needs the "from" option defined to accept the "to" option.',
75+
);
76+
}
77+
78+
const logs = await git.log(options);
79+
return prepareHashes(logs);
80+
}
81+
82+
export function prepareHashes(logs: LogResult): string[] {
83+
return (
84+
logs.all
85+
.map(({ hash }) => hash)
86+
// sort from oldest to newest
87+
.reverse()
88+
);
89+
}
+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, vi } from 'vitest';
2+
import { MINIMAL_HISTORY_CONFIG_MOCK } from '@code-pushup/test-utils';
3+
import { getCurrentBranchOrTag, safeCheckout } from '@code-pushup/utils';
4+
import { collectAndPersistReports } from './collect-and-persist';
5+
import { HistoryOptions, history, prepareHashes } from './history';
6+
import { upload } from './upload';
7+
8+
vi.mock('@code-pushup/utils', async () => {
9+
const utils: object = await vi.importActual('@code-pushup/utils');
10+
return {
11+
...utils,
12+
safeCheckout: vi.fn(),
13+
getCurrentBranchOrTag: vi.fn().mockReturnValue('main'),
14+
};
15+
});
16+
17+
vi.mock('./collect-and-persist', () => ({
18+
collectAndPersistReports: vi.fn(),
19+
}));
20+
21+
vi.mock('./upload', () => ({
22+
upload: vi.fn(),
23+
}));
24+
25+
describe('history', () => {
26+
it('should check out all passed commits and reset to initial branch or tag', async () => {
27+
await history(MINIMAL_HISTORY_CONFIG_MOCK, ['abc', 'def']);
28+
29+
expect(getCurrentBranchOrTag).toHaveBeenCalledTimes(1);
30+
31+
expect(safeCheckout).toHaveBeenCalledTimes(3);
32+
// walk commit history
33+
expect(safeCheckout).toHaveBeenNthCalledWith(1, 'abc', undefined);
34+
expect(safeCheckout).toHaveBeenNthCalledWith(2, 'def', undefined);
35+
// reset
36+
expect(safeCheckout).toHaveBeenNthCalledWith(3, 'main', undefined);
37+
});
38+
39+
it('should return correct number of results', async () => {
40+
const historyOptions: HistoryOptions = MINIMAL_HISTORY_CONFIG_MOCK;
41+
42+
const results = await history(historyOptions, ['abc', 'def']);
43+
44+
expect(results).toStrictEqual(['abc-report', 'def-report']);
45+
});
46+
47+
it('should call collect with correct filename and format', async () => {
48+
const historyOptions: HistoryOptions = MINIMAL_HISTORY_CONFIG_MOCK;
49+
50+
await history(historyOptions, ['abc']);
51+
expect(collectAndPersistReports).toHaveBeenCalledTimes(1);
52+
expect(collectAndPersistReports).toHaveBeenNthCalledWith(
53+
1,
54+
expect.objectContaining({
55+
persist: expect.objectContaining({
56+
filename: 'abc-report',
57+
format: ['json'],
58+
}),
59+
}),
60+
);
61+
});
62+
63+
it('should call upload by default', async () => {
64+
const historyOptions: HistoryOptions = {
65+
...MINIMAL_HISTORY_CONFIG_MOCK,
66+
upload: {
67+
server: 'https://server.com/api',
68+
project: 'cli',
69+
apiKey: '1234',
70+
organization: 'code-pushup',
71+
timeout: 4000,
72+
},
73+
};
74+
await history(historyOptions, ['abc']);
75+
76+
expect(upload).toHaveBeenCalledTimes(1);
77+
expect(upload).toHaveBeenCalledWith(
78+
expect.objectContaining({
79+
persist: expect.objectContaining({ filename: 'abc-report' }),
80+
}),
81+
);
82+
});
83+
84+
it('should not call upload if skipUploads is set to false', async () => {
85+
const historyOptions: HistoryOptions = {
86+
...MINIMAL_HISTORY_CONFIG_MOCK,
87+
upload: {
88+
server: 'https://server.com/api',
89+
project: 'cli',
90+
apiKey: '1234',
91+
organization: 'code-pushup',
92+
timeout: 4000,
93+
},
94+
skipUploads: true,
95+
};
96+
await history(historyOptions, ['abc']);
97+
98+
expect(upload).not.toHaveBeenCalled();
99+
});
100+
101+
it('should not call upload if upload config is not given', async () => {
102+
await history(MINIMAL_HISTORY_CONFIG_MOCK, ['abc']);
103+
104+
expect(upload).not.toHaveBeenCalled();
105+
});
106+
});
107+
108+
describe('prepareHashes', () => {
109+
it('should return commit hashes in reverse order', () => {
110+
expect(
111+
prepareHashes({
112+
all: [
113+
{
114+
hash: '22287eb716a84f82b5d59e7238ffcae7147f707a',
115+
date: 'Thu Mar 7 20:13:33 2024 +0100',
116+
message:
117+
'test: change test reported to basic in order to work on Windows',
118+
refs: 'string',
119+
body: '',
120+
author_name: 'John Doe',
121+
author_email: '[email protected]',
122+
},
123+
{
124+
hash: '111b284e48ddf464a498dcf22426a9ce65e2c01c',
125+
date: 'Thu Mar 7 20:13:34 2024 +0100',
126+
message: 'chore: exclude fixtures from ESLint',
127+
refs: 'string',
128+
body: '',
129+
author_name: 'Jane Doe',
130+
author_email: '[email protected]',
131+
},
132+
],
133+
total: 2,
134+
latest: {
135+
hash: '22287eb716a84f82b5d59e7238ffcae7147f707a',
136+
date: 'Thu Mar 7 20:13:33 2024 +0100',
137+
message:
138+
'test: change test reported to basic in order to work on Windows',
139+
refs: 'string',
140+
body: '',
141+
author_name: 'John Doe',
142+
author_email: '[email protected]',
143+
},
144+
}),
145+
).toStrictEqual([
146+
'111b284e48ddf464a498dcf22426a9ce65e2c01c',
147+
'22287eb716a84f82b5d59e7238ffcae7147f707a',
148+
]);
149+
});
150+
});

packages/core/src/lib/implementation/collect.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { executePlugins } from './execute-plugin';
77
export type CollectOptions = Required<
88
Pick<CoreConfig, 'plugins' | 'categories'>
99
> &
10-
GlobalOptions;
10+
Partial<GlobalOptions>;
1111

1212
/**
1313
* Run audits, collect plugin output and aggregate it into a JSON object

packages/core/src/lib/implementation/execute-plugin.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export async function executePlugin(
118118
*/
119119
export async function executePlugins(
120120
plugins: PluginConfig[],
121-
options?: { progress: boolean },
121+
options?: { progress?: boolean },
122122
): Promise<PluginReport[]> {
123123
const { progress = false } = options ?? {};
124124

packages/core/src/lib/upload.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { GlobalOptions } from './types';
99

1010
export type UploadOptions = { upload?: UploadConfig } & {
1111
persist: Required<PersistConfig>;
12-
} & GlobalOptions;
12+
} & Partial<GlobalOptions>;
1313

1414
/**
1515
* Uploads collected audits to the portal

packages/utils/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export {
4040
getGitRoot,
4141
getLatestCommit,
4242
toGitPath,
43+
getCurrentBranchOrTag,
44+
safeCheckout,
4345
} from './lib/git';
4446
export { groupByStatus } from './lib/group-by-status';
4547
export {

0 commit comments

Comments
 (0)