Skip to content

Commit 357b1fa

Browse files
authored
feat(cli): add option semverTag to history command (#626)
1 parent 94ffd45 commit 357b1fa

27 files changed

+1013
-349
lines changed

package-lock.json

+3-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"esbuild": "^0.19.12",
5858
"multi-progress-bars": "^5.0.3",
5959
"parse-lcov": "^1.0.4",
60+
"semver": "^7.6.0",
6061
"simple-git": "^3.20.0",
6162
"vscode-material-icons": "^0.1.0",
6263
"yargs": "^17.7.2",

packages/cli/docs/custom-plugins.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ We will extend the file-size example from above to calculate the score based on
450450

451451
Let's extend the options object with a `budget` property and use it in the runner config:
452452

453-
**file-size plugin form section [RunnerFunction](#RunnerFunction)**
453+
**file-size plugin from section [RunnerFunction](#RunnerFunction)**
454454

455455
```typescript
456456
// file-size.plugin.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,63 @@
11
import chalk from 'chalk';
2-
import { ArgumentsCamelCase, CommandModule } from 'yargs';
3-
import { HistoryOptions, getHashes, history } from '@code-pushup/core';
4-
import { getCurrentBranchOrTag, safeCheckout, ui } from '@code-pushup/utils';
2+
import { CommandModule } from 'yargs';
3+
import { HistoryOptions, history } from '@code-pushup/core';
4+
import {
5+
LogResult,
6+
getCurrentBranchOrTag,
7+
getHashes,
8+
getSemverTags,
9+
safeCheckout,
10+
ui,
11+
} from '@code-pushup/utils';
512
import { CLI_NAME } from '../constants';
613
import { yargsOnlyPluginsOptionsDefinition } from '../implementation/only-plugins.options';
714
import { HistoryCliOptions } from './history.model';
815
import { yargsHistoryOptionsDefinition } from './history.options';
16+
import { normalizeHashOptions } from './utils';
17+
18+
const command = 'history';
19+
async function handler(args: unknown) {
20+
ui().logger.info(chalk.bold(CLI_NAME));
21+
ui().logger.info(chalk.gray(`Run ${command}`));
22+
23+
const currentBranch = await getCurrentBranchOrTag();
24+
const { targetBranch: rawTargetBranch, ...opt } = args as HistoryCliOptions &
25+
HistoryOptions;
26+
const {
27+
targetBranch,
28+
from,
29+
to,
30+
maxCount,
31+
onlySemverTags,
32+
...historyOptions
33+
} = await normalizeHashOptions({
34+
...opt,
35+
targetBranch: rawTargetBranch ?? currentBranch,
36+
});
37+
38+
const filterOptions = { targetBranch, from, to, maxCount };
39+
const results: LogResult[] = onlySemverTags
40+
? await getSemverTags(filterOptions)
41+
: await getHashes(filterOptions);
42+
43+
try {
44+
// run history logic
45+
const reports = await history(
46+
{
47+
targetBranch,
48+
...historyOptions,
49+
},
50+
results.map(({ hash }) => hash),
51+
);
52+
53+
ui().logger.log(`Reports: ${reports.length}`);
54+
} finally {
55+
// go back to initial branch
56+
await safeCheckout(currentBranch);
57+
}
58+
}
959

1060
export function yargsHistoryCommandObject() {
11-
const command = 'history';
1261
return {
1362
command,
1463
describe: 'Collect reports for commit history',
@@ -23,38 +72,6 @@ export function yargsHistoryCommandObject() {
2372
);
2473
return yargs;
2574
},
26-
handler: async <T>(args: ArgumentsCamelCase<T>) => {
27-
ui().logger.info(chalk.bold(CLI_NAME));
28-
ui().logger.info(chalk.gray(`Run ${command}`));
29-
30-
const currentBranch = await getCurrentBranchOrTag();
31-
const {
32-
targetBranch = currentBranch,
33-
forceCleanStatus,
34-
maxCount,
35-
from,
36-
to,
37-
...restOptions
38-
} = args as unknown as HistoryCliOptions & HistoryOptions;
39-
40-
// determine history to walk
41-
const commits: string[] = await getHashes({ maxCount, from, to });
42-
try {
43-
// run history logic
44-
const reports = await history(
45-
{
46-
...restOptions,
47-
targetBranch,
48-
forceCleanStatus,
49-
},
50-
commits,
51-
);
52-
53-
ui().logger.log(`Reports: ${reports.length}`);
54-
} finally {
55-
// go back to initial branch
56-
await safeCheckout(currentBranch);
57-
}
58-
},
75+
handler,
5976
} satisfies CommandModule;
6077
}

packages/cli/src/lib/history/history-command.unit.test.ts

+11-21
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,20 @@ vi.mock('simple-git', async () => {
3737
return {
3838
...actual,
3939
simpleGit: () => ({
40+
branch: () => Promise.resolve('dummy'),
41+
raw: () => Promise.resolve('main'),
42+
tag: () => Promise.resolve(`5\n 4\n 3\n 2\n 1`),
43+
show: ([_, __, tag]: string) =>
44+
Promise.resolve(`release v${tag}\n ${tag}`),
45+
checkout: () => Promise.resolve(),
4046
log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) =>
4147
Promise.resolve({
4248
all: [
4349
{ hash: 'commit-6' },
4450
{ hash: 'commit-5' },
45-
{ hash: 'commit-4' },
51+
{ hash: 'commit-4--release-v2' },
4652
{ hash: 'commit-3' },
47-
{ hash: 'commit-2' },
53+
{ hash: 'commit-2--release-v1' },
4854
{ hash: 'commit-1' },
4955
].slice(-maxCount),
5056
}),
@@ -53,7 +59,7 @@ vi.mock('simple-git', async () => {
5359
});
5460

5561
describe('history-command', () => {
56-
it('should return the last 5 commits', async () => {
62+
it('should pass targetBranch and forceCleanStatus to core history logic', async () => {
5763
await yargsCli(['history', '--config=/test/code-pushup.config.ts'], {
5864
...DEFAULT_CLI_CONFIGURATION,
5965
commands: [yargsHistoryCommandObject()],
@@ -62,27 +68,11 @@ describe('history-command', () => {
6268
expect(history).toHaveBeenCalledWith(
6369
expect.objectContaining({
6470
targetBranch: 'main',
71+
forceCleanStatus: false,
6572
}),
66-
['commit-1', 'commit-2', 'commit-3', 'commit-4', 'commit-5'],
73+
expect.any(Array),
6774
);
6875

6976
expect(safeCheckout).toHaveBeenCalledTimes(1);
7077
});
71-
72-
it('should have 2 commits to crawl in history if maxCount is set to 2', async () => {
73-
await yargsCli(
74-
['history', '--config=/test/code-pushup.config.ts', '--maxCount=2'],
75-
{
76-
...DEFAULT_CLI_CONFIGURATION,
77-
commands: [yargsHistoryCommandObject()],
78-
},
79-
).parseAsync();
80-
81-
expect(history).toHaveBeenCalledWith(expect.any(Object), [
82-
'commit-1',
83-
'commit-2',
84-
]);
85-
86-
expect(safeCheckout).toHaveBeenCalledTimes(1);
87-
});
8878
});

packages/cli/src/lib/history/history.model.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import { HistoryOnlyOptions } from '@code-pushup/core';
33

44
export type HistoryCliOptions = {
55
targetBranch?: string;
6+
onlySemverTags?: boolean;
67
} & Pick<LogOptions, 'maxCount' | 'from' | 'to'> &
78
HistoryOnlyOptions;

packages/cli/src/lib/history/history.options.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export function yargsHistoryOptionsDefinition(): Record<
99
targetBranch: {
1010
describe: 'Branch to crawl history',
1111
type: 'string',
12-
default: 'main',
12+
},
13+
onlySemverTags: {
14+
describe: 'Skip commits not tagged with a semantic version',
15+
type: 'boolean',
16+
default: false,
1317
},
1418
forceCleanStatus: {
1519
describe:

packages/cli/src/lib/history/utils.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { HistoryOptions } from '@code-pushup/core';
2+
import { getHashFromTag, isSemver } from '@code-pushup/utils';
3+
import { HistoryCliOptions } from './history.model';
4+
5+
export async function normalizeHashOptions(
6+
processArgs: HistoryCliOptions & HistoryOptions,
7+
): Promise<HistoryCliOptions & HistoryOptions> {
8+
const {
9+
onlySemverTags,
10+
// overwritten
11+
maxCount,
12+
...opt
13+
} = processArgs;
14+
15+
// eslint-disable-next-line functional/no-let, prefer-const
16+
let { from, to, ...processOptions } = opt;
17+
// if no semver filter is used resolve hash of tags, as hashes are used to collect history
18+
if (!onlySemverTags) {
19+
if (from && isSemver(from)) {
20+
const { hash } = await getHashFromTag(from);
21+
from = hash;
22+
}
23+
if (to && isSemver(to)) {
24+
const { hash } = await getHashFromTag(to);
25+
to = hash;
26+
}
27+
}
28+
29+
return {
30+
...processOptions,
31+
onlySemverTags,
32+
maxCount: maxCount && maxCount > 0 ? maxCount : undefined,
33+
from,
34+
to,
35+
};
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, vi } from 'vitest';
2+
import { type HistoryOptions } from '@code-pushup/core';
3+
import { HistoryCliOptions } from './history.model';
4+
import { normalizeHashOptions } from './utils';
5+
6+
vi.mock('simple-git', async () => {
7+
const actual = await vi.importActual('simple-git');
8+
const orderedTagsHistory = ['2.0.0', '1.0.0'];
9+
return {
10+
...actual,
11+
simpleGit: () => ({
12+
branch: () => Promise.resolve('dummy'),
13+
raw: () => Promise.resolve('main'),
14+
tag: () => Promise.resolve(orderedTagsHistory.join('\n')),
15+
show: ([_, __, tag]: string) =>
16+
orderedTagsHistory.includes(tag || '')
17+
? Promise.resolve(`${tag}\ncommit--release-v${tag}`)
18+
: Promise.reject('NOT FOUND TAG'),
19+
checkout: () => Promise.resolve(),
20+
log: ({ maxCount }: { maxCount: number } = { maxCount: 1 }) =>
21+
Promise.resolve({
22+
all: [
23+
{ hash: 'commit-6' },
24+
{ hash: 'commit-5' },
25+
{ hash: `commit--release-v${orderedTagsHistory.at(0)}` },
26+
{ hash: 'commit-3' },
27+
{ hash: `commit--release-v${orderedTagsHistory.at(1)}` },
28+
{ hash: 'commit-1' },
29+
].slice(-maxCount),
30+
}),
31+
}),
32+
};
33+
});
34+
35+
describe('normalizeHashOptions', () => {
36+
it('should forwards other options', async () => {
37+
await expect(
38+
normalizeHashOptions({
39+
test: 42,
40+
} as unknown as HistoryCliOptions & HistoryOptions),
41+
).resolves.toEqual(
42+
expect.objectContaining({
43+
test: 42,
44+
}),
45+
);
46+
});
47+
48+
it('should set "maxCount" to undefined if "0" is passed', async () => {
49+
await expect(
50+
normalizeHashOptions({ maxCount: 0 } as HistoryCliOptions &
51+
HistoryOptions),
52+
).resolves.toEqual(
53+
expect.objectContaining({
54+
maxCount: undefined,
55+
}),
56+
);
57+
});
58+
59+
it('should forward hashes "from" and "to" as is if "onlySemverTags" is false', async () => {
60+
await expect(
61+
normalizeHashOptions({
62+
from: 'commit-3',
63+
to: 'commit-1',
64+
} as HistoryCliOptions & HistoryOptions),
65+
).resolves.toEqual(
66+
expect.objectContaining({
67+
from: 'commit-3',
68+
to: 'commit-1',
69+
}),
70+
);
71+
});
72+
73+
it('should transform tags "from" and "to" to commit hashes if "onlySemverTags" is false', async () => {
74+
await expect(
75+
normalizeHashOptions({
76+
onlySemverTags: false,
77+
from: '2.0.0',
78+
to: '1.0.0',
79+
} as HistoryCliOptions & HistoryOptions),
80+
).resolves.toEqual(
81+
expect.objectContaining({
82+
onlySemverTags: false,
83+
from: 'commit--release-v2.0.0',
84+
to: 'commit--release-v1.0.0',
85+
}),
86+
);
87+
});
88+
89+
it('should forward tags "from" and "to" if "onlySemverTags" is true', async () => {
90+
await expect(
91+
normalizeHashOptions({
92+
onlySemverTags: true,
93+
from: '2.0.0',
94+
to: '1.0.0',
95+
} as HistoryCliOptions & HistoryOptions),
96+
).resolves.toEqual(
97+
expect.objectContaining({
98+
onlySemverTags: true,
99+
from: '2.0.0',
100+
to: '1.0.0',
101+
}),
102+
);
103+
});
104+
105+
it('should forward hashes "from" and "to" if "onlySemverTags" is true', async () => {
106+
await expect(
107+
normalizeHashOptions({
108+
onlySemverTags: true,
109+
from: 'commit-3',
110+
to: 'commit-1',
111+
} as HistoryCliOptions & HistoryOptions),
112+
).resolves.toEqual(
113+
expect.objectContaining({
114+
onlySemverTags: true,
115+
from: 'commit-3',
116+
to: 'commit-1',
117+
}),
118+
);
119+
});
120+
});

0 commit comments

Comments
 (0)