Skip to content

Commit

Permalink
feat(core): add history logic (#541)
Browse files Browse the repository at this point in the history
  • Loading branch information
BioPhoton committed Mar 11, 2024
1 parent 1405e7d commit 10df94c
Show file tree
Hide file tree
Showing 12 changed files with 363 additions and 13 deletions.
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/collect-and-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { GlobalOptions } from './types';

export type CollectAndPersistReportsOptions = Required<
Pick<CoreConfig, 'plugins' | 'categories'>
> & { persist: Required<PersistConfig> } & GlobalOptions;
> & { persist: Required<PersistConfig> } & Partial<GlobalOptions>;

export async function collectAndPersistReports(
options: CollectAndPersistReportsOptions,
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/lib/history.integration.test.ts
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', '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.',
);
});
});
});
89 changes: 89 additions & 0 deletions packages/core/src/lib/history.ts
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()
);
}
150 changes: 150 additions & 0 deletions packages/core/src/lib/history.unit.test.ts
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: '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',
]);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/lib/implementation/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { executePlugins } from './execute-plugin';
export type CollectOptions = Required<
Pick<CoreConfig, 'plugins' | 'categories'>
> &
GlobalOptions;
Partial<GlobalOptions>;

/**
* Run audits, collect plugin output and aggregate it into a JSON object
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/implementation/execute-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function executePlugin(
*/
export async function executePlugins(
plugins: PluginConfig[],
options?: { progress: boolean },
options?: { progress?: boolean },
): Promise<PluginReport[]> {
const { progress = false } = options ?? {};

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { GlobalOptions } from './types';

export type UploadOptions = { upload?: UploadConfig } & {
persist: Required<PersistConfig>;
} & GlobalOptions;
} & Partial<GlobalOptions>;

/**
* Uploads collected audits to the portal
Expand Down
2 changes: 2 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export {
getGitRoot,
getLatestCommit,
toGitPath,
getCurrentBranchOrTag,
safeCheckout,
} from './lib/git';
export { groupByStatus } from './lib/group-by-status';
export {
Expand Down
Loading

0 comments on commit 10df94c

Please sign in to comment.