Skip to content

Commit 393e844

Browse files
committed
feat(one-release-commit): first implementation.
1 parent 3e0b663 commit 393e844

File tree

3 files changed

+296
-7
lines changed

3 files changed

+296
-7
lines changed

plugins/one-release-commit/README.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ yarn add -D @auto-it/one-release-commit
1717
```json
1818
{
1919
"plugins": [
20-
"one-release-commit"
21-
// other plugins
20+
[
21+
"one-release-commit",
22+
{
23+
// Release commit message
24+
"commitMessage": ":rocket: New release is on the way :rocket:"
25+
}
26+
]
2227
]
2328
}
2429
```
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,220 @@
11
import Auto from '@auto-it/core';
2+
import { dummyLog } from '@auto-it/core/dist/utils/logger';
3+
import { makeHooks } from '@auto-it/core/dist/utils/make-hooks';
24
import OneReleaseCommit from '../src';
35

6+
const exec = jest.fn();
7+
const getGitLog = jest.fn();
8+
9+
jest.mock("../../../packages/core/dist/utils/get-current-branch", () => ({
10+
getCurrentBranch: () => "main",
11+
}));
12+
jest.mock(
13+
"../../../packages/core/dist/utils/exec-promise",
14+
() => (...args: any[]) => exec(...args)
15+
);
16+
17+
const setup = (mockGit?: any) => {
18+
const plugin = new OneReleaseCommit({});
19+
const hooks = makeHooks();
20+
21+
plugin.apply(({
22+
hooks,
23+
git: mockGit,
24+
remote: "origin",
25+
baseBranch: "main",
26+
logger: dummyLog(),
27+
} as unknown) as Auto.Auto);
28+
29+
return { plugin, hooks };
30+
};
31+
432
describe('One-Release-Commit Plugin', () => {
5-
test('should do something', async () => {
33+
const headCommitHash = "dd53ea5d7b151306ba6275a332ee333800fb39e8";
34+
35+
beforeEach(() => {
36+
exec.mockReset();
37+
exec.mockResolvedValueOnce(`${headCommitHash} refs/heads/main`);
38+
getGitLog.mockReset();
39+
getGitLog.mockResolvedValueOnce([{hash: "c2241048"},{hash: "c2241049"}]);
40+
});
41+
42+
function expectListGitHistoryCalled() {
43+
expect(exec).toHaveBeenCalled();
44+
expect(exec.mock.calls[0]).toMatchObject(["git",["ls-remote", "--heads", "origin", "main"]]);
45+
expect(getGitLog).toHaveBeenCalledTimes(1);
46+
expect(getGitLog.mock.calls[0]).toMatchObject([headCommitHash]);
47+
}
48+
49+
function expectLookingForGitTagOnCommit(callIdx: number, commitSha: string) {
50+
expect(exec.mock.calls.length >= callIdx).toBe(true);
51+
expect(exec.mock.calls[callIdx]).toMatchObject(["git",["describe", "--tags", "--exact-match", commitSha]]);
52+
}
53+
54+
function expectResetAndRecreateANewReleaseCommit(callIdx: number) {
55+
expect(exec.mock.calls.length > callIdx).toBe(true);
56+
expect(exec.mock.calls[callIdx]).toMatchObject(["git",["reset", "--soft", headCommitHash]]);
57+
expect(exec.mock.calls[callIdx+1]).toMatchObject(["git",["commit", "-m", '"Release version v1.2.3 [skip ci]"', "--no-verify"]]);
58+
}
59+
60+
test("should setup hooks", () => {
61+
const {hooks} = setup();
62+
63+
expect(hooks.validateConfig.isUsed()).toBe(true);
64+
expect(hooks.afterVersion.isUsed()).toBe(true);
65+
});
66+
67+
describe("validateConfig", () => {
68+
test('should validate the configuration', async () => {
69+
const {hooks, plugin} = setup();
70+
await expect(hooks.validateConfig.promise("not-me", {})).resolves.toBeUndefined();
71+
await expect(hooks.validateConfig.promise(plugin.name, {})).resolves.toStrictEqual([]);
72+
73+
const res = await hooks.validateConfig.promise(plugin.name, {invalidKey: "value"});
74+
expect(res).toHaveLength(1);
75+
expect(res[0]).toContain(plugin.name);
76+
expect(res[0]).toContain("Found unknown configuration keys:");
77+
expect(res[0]).toContain("invalidKey");
78+
79+
await expect(hooks.validateConfig.promise(plugin.name, {commitMessage: -1})).resolves.toMatchObject([{
80+
expectedType: '"string"',
81+
path: "one-release-commit.commitMessage",
82+
value: -1,
83+
}]);
84+
});
85+
});
86+
87+
describe("afterVersion", () => {
88+
test('should do nothing on dryRun', async () => {
89+
const {hooks} = setup();
90+
await expect(hooks.afterVersion.promise({dryRun: true})).resolves.toBeUndefined();
91+
expect(exec).not.toHaveBeenCalled();
92+
});
93+
94+
test('should do nothing without version', async () => {
95+
const {hooks} = setup();
96+
await expect(hooks.afterVersion.promise({})).resolves.toBeUndefined();
97+
expect(exec).not.toHaveBeenCalled();
98+
});
99+
100+
test('should do nothing without git', async () => {
101+
const {hooks} = setup();
102+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
103+
expect(exec).not.toHaveBeenCalled();
104+
});
105+
106+
test('should be executed in a less priority group', async () => {
107+
getGitLog.mockReset();
108+
getGitLog.mockResolvedValueOnce([]);
109+
110+
const {hooks} = setup({ getGitLog });
111+
hooks.afterVersion.tapPromise("dummy", async () => {
112+
expect(exec).not.toHaveBeenCalled();
113+
expect(getGitLog).not.toHaveBeenCalled();
114+
});
115+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
116+
117+
expectListGitHistoryCalled();
118+
});
119+
120+
test('should do nothing when there no release commits', async () => {
121+
getGitLog.mockReset();
122+
getGitLog.mockResolvedValueOnce([]);
123+
124+
const {hooks} = setup({ getGitLog });
125+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
126+
127+
expectListGitHistoryCalled();
128+
});
129+
130+
test('should create a single release commit when there is one existing commit', async () => {
131+
getGitLog.mockReset();
132+
getGitLog.mockResolvedValueOnce([{hash: "c2241048"}]);
133+
134+
const {hooks} = setup({ getGitLog });
135+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
136+
137+
expectListGitHistoryCalled();
138+
139+
expect(exec).toHaveBeenCalledTimes(4);
140+
expectLookingForGitTagOnCommit(1, "c2241048");
141+
expectResetAndRecreateANewReleaseCommit(2);
142+
});
143+
144+
test('should create a single release commit when there is multiple existing commit', async () => {
145+
const {hooks} = setup({ getGitLog });
146+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
147+
148+
expectListGitHistoryCalled();
149+
150+
expect(exec).toHaveBeenCalledTimes(5);
151+
expectLookingForGitTagOnCommit(1, "c2241048");
152+
expectLookingForGitTagOnCommit(2, "c2241049");
153+
expectResetAndRecreateANewReleaseCommit(3);
154+
});
155+
156+
test('should recreate all existing tags', async () => {
157+
exec.mockResolvedValueOnce('v1.2.4')
158+
.mockResolvedValueOnce('submobule-v1.2.4')
159+
.mockResolvedValueOnce(' Tag message for v1.2.4 ')
160+
.mockResolvedValueOnce(' Another multiline\ntag message\n');
161+
162+
const {hooks} = setup({ getGitLog });
163+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
164+
165+
expectListGitHistoryCalled();
166+
167+
expect(exec).toHaveBeenCalledTimes(9);
168+
expectLookingForGitTagOnCommit(1, "c2241048");
169+
expectLookingForGitTagOnCommit(2, "c2241049");
170+
expect(exec.mock.calls[3]).toMatchObject(["git",["tag", "v1.2.4", "-l", '--format="%(contents)"']]);
171+
expect(exec.mock.calls[4]).toMatchObject(["git",["tag", "submobule-v1.2.4", "-l", '--format="%(contents)"']]);
172+
expectResetAndRecreateANewReleaseCommit(5);
173+
});
174+
175+
test('should not failed when there is no tag on commit', async () => {
176+
exec.mockResolvedValueOnce('v1.2.4')
177+
.mockRejectedValueOnce(new Error('no tag exactly matches xyz'))
178+
.mockResolvedValueOnce(' Tag message for v1.2.4 ');
179+
180+
const {hooks} = setup({ getGitLog });
181+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
182+
183+
expectListGitHistoryCalled();
184+
185+
expect(exec).toHaveBeenCalledTimes(7);
186+
expectLookingForGitTagOnCommit(1, "c2241048");
187+
expectLookingForGitTagOnCommit(2, "c2241049");
188+
expect(exec.mock.calls[3]).toMatchObject(["git",["tag", "v1.2.4", "-l", '--format="%(contents)"']]);
189+
expectResetAndRecreateANewReleaseCommit(4);
190+
});
191+
192+
test.each([
193+
[new Error('unknown failure')],
194+
['not an error'],
195+
])( 'should failed when retrieving tags failed with : %p', async (cause) => {
196+
exec.mockRejectedValueOnce(cause);
197+
198+
const {hooks} = setup({ getGitLog });
199+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).rejects.toBe(cause);
200+
201+
expectListGitHistoryCalled();
202+
203+
expect(exec).toHaveBeenCalledTimes(3);
204+
expectLookingForGitTagOnCommit(1, "c2241048");
205+
expectLookingForGitTagOnCommit(2, "c2241049");
206+
});
207+
208+
test('should failed when not remote head found', async () => {
209+
exec.mockReset();
210+
exec.mockResolvedValueOnce('');
211+
212+
const {hooks} = setup({ getGitLog });
213+
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).rejects.toStrictEqual(new Error('No remote found for branch : "main"'));
214+
215+
expect(exec).toHaveBeenCalledTimes(1);
216+
expect(exec.mock.calls[0]).toMatchObject(["git",["ls-remote", "--heads", "origin", "main"]]);
217+
expect(getGitLog).not.toHaveBeenCalled();
218+
});
6219
});
7220
});

plugins/one-release-commit/src/index.ts

+75-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,41 @@
1-
import { Auto, IPlugin, validatePluginConfiguration } from '@auto-it/core';
1+
import { Auto, getCurrentBranch, IPlugin, validatePluginConfiguration, execPromise } from '@auto-it/core';
22
import * as t from "io-ts";
33

44
const pluginOptions = t.partial({
5+
/** Release commit message */
6+
commitMessage: t.string,
57
});
68

9+
interface ITag {
10+
/** Name */
11+
name: string;
12+
/** Message */
13+
message: string;
14+
}
15+
16+
/**
17+
* Get Tag (and his message) for a commit
18+
* or return undefined if no tag present on this commit
19+
*/
20+
async function getTag(sha: string) : Promise<ITag | undefined> {
21+
let tag: string|undefined;
22+
try{
23+
tag = await execPromise("git", ["describe", "--tags", "--exact-match", sha])
24+
} catch (error) {
25+
if (!error.message?.includes("no tag exactly matches")) {
26+
throw error;
27+
}
28+
}
29+
30+
if (tag === undefined){
31+
return undefined;
32+
}
33+
34+
const message = await execPromise("git", ["tag", tag, "-l", '--format="%(contents)"']);
35+
36+
return { name: tag, message: message.trim() };
37+
}
38+
739
export type IOneReleaseCommitPluginOptions = t.TypeOf<typeof pluginOptions>;
840

941
/** Allow to squash release commit in a single one */
@@ -21,11 +53,50 @@ export default class OneReleaseCommitPlugin implements IPlugin {
2153

2254
/** Tap into auto plugin points. */
2355
apply(auto: Auto) {
24-
auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => {
25-
// If it's a string thats valid config
26-
if (name === this.name && typeof options !== "string") {
56+
auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => {
57+
if (name === this.name || name === `@auto-it/${this.name}`) {
2758
return validatePluginConfiguration(this.name, pluginOptions, options);
2859
}
2960
});
61+
62+
auto.hooks.afterVersion.tapPromise({
63+
name: this.name,
64+
// Include this plugin in a less priority stage in order to be mostly often after others plugins
65+
stage: 1,
66+
}, async ({ dryRun, version }) => {
67+
if (!auto.git || dryRun || !version) {
68+
return;
69+
}
70+
71+
const heads = await execPromise("git", [
72+
"ls-remote",
73+
"--heads",
74+
auto.remote,
75+
getCurrentBranch(),
76+
]);
77+
const baseBranchHeadRef = new RegExp(
78+
`^(\\w+)\\s+refs/heads/${auto.baseBranch}$`
79+
);
80+
const [, remoteHead] = heads.match(baseBranchHeadRef) || [];
81+
82+
if (!remoteHead) {
83+
throw new Error(`No remote found for branch : "${auto.baseBranch}"`);
84+
}
85+
86+
const commits = await auto.git.getGitLog(remoteHead);
87+
const tags: ITag[] = (await Promise.all(commits.map(commit => getTag(commit.hash)))).filter(tag => tag !== undefined) as ITag[];
88+
89+
auto.logger.log.info(`Rewrote ${commits.length} release commits into a single commit for version [${version}] with tags: [${tags.map(tag => tag.name).join(", ")}]`);
90+
91+
if (commits.length > 0) {
92+
await execPromise("git", ["reset", "--soft", remoteHead]);
93+
await execPromise("git", ["commit", "-m", this.options.commitMessage || `"Release version ${version} [skip ci]"`, "--no-verify"]);
94+
95+
await Promise.all(tags.map(tag => execPromise("git", [
96+
"tag", "--annotate", "--force", tag.name,
97+
"-m", tag.message,
98+
])));
99+
}
100+
});
30101
}
31102
}

0 commit comments

Comments
 (0)