Skip to content

Commit

Permalink
feat(one-release-commit): first implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
jBouyoud committed Mar 22, 2022
1 parent 4562fbb commit 6c46da3
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 7 deletions.
9 changes: 7 additions & 2 deletions plugins/one-release-commit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ yarn add -D @auto-it/one-release-commit
```json
{
"plugins": [
"one-release-commit"
// other plugins
[
"one-release-commit",
{
// Release commit message
"commitMessage": ":rocket: New release is on the way :rocket:"
}
]
]
}
```
215 changes: 214 additions & 1 deletion plugins/one-release-commit/__tests__/one-release-commit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,220 @@
import Auto from '@auto-it/core';
import { dummyLog } from '@auto-it/core/dist/utils/logger';
import { makeHooks } from '@auto-it/core/dist/utils/make-hooks';
import OneReleaseCommit from '../src';

const exec = jest.fn();
const getGitLog = jest.fn();

jest.mock("../../../packages/core/dist/utils/get-current-branch", () => ({
getCurrentBranch: () => "main",
}));
jest.mock(
"../../../packages/core/dist/utils/exec-promise",
() => (...args: any[]) => exec(...args)
);

const setup = (mockGit?: any) => {
const plugin = new OneReleaseCommit({});
const hooks = makeHooks();

plugin.apply(({
hooks,
git: mockGit,
remote: "origin",
baseBranch: "main",
logger: dummyLog(),
} as unknown) as Auto.Auto);

return { plugin, hooks };
};

describe('One-Release-Commit Plugin', () => {
test('should do something', async () => {
const headCommitHash = "dd53ea5d7b151306ba6275a332ee333800fb39e8";

beforeEach(() => {
exec.mockReset();
exec.mockResolvedValueOnce(`${headCommitHash} refs/heads/main`);
getGitLog.mockReset();
getGitLog.mockResolvedValueOnce([{hash: "c2241048"},{hash: "c2241049"}]);
});

function expectListGitHistoryCalled() {
expect(exec).toHaveBeenCalled();
expect(exec.mock.calls[0]).toMatchObject(["git",["ls-remote", "--heads", "origin", "main"]]);
expect(getGitLog).toHaveBeenCalledTimes(1);
expect(getGitLog.mock.calls[0]).toMatchObject([headCommitHash]);
}

function expectLookingForGitTagOnCommit(callIdx: number, commitSha: string) {
expect(exec.mock.calls.length >= callIdx).toBe(true);
expect(exec.mock.calls[callIdx]).toMatchObject(["git",["describe", "--tags", "--exact-match", commitSha]]);
}

function expectResetAndRecreateANewReleaseCommit(callIdx: number) {
expect(exec.mock.calls.length > callIdx).toBe(true);
expect(exec.mock.calls[callIdx]).toMatchObject(["git",["reset", "--soft", headCommitHash]]);
expect(exec.mock.calls[callIdx+1]).toMatchObject(["git",["commit", "-m", '"Release version v1.2.3 [skip ci]"', "--no-verify"]]);
}

test("should setup hooks", () => {
const {hooks} = setup();

expect(hooks.validateConfig.isUsed()).toBe(true);
expect(hooks.afterVersion.isUsed()).toBe(true);
});

describe("validateConfig", () => {
test('should validate the configuration', async () => {
const {hooks, plugin} = setup();
await expect(hooks.validateConfig.promise("not-me", {})).resolves.toBeUndefined();
await expect(hooks.validateConfig.promise(plugin.name, {})).resolves.toStrictEqual([]);

const res = await hooks.validateConfig.promise(plugin.name, {invalidKey: "value"});
expect(res).toHaveLength(1);
expect(res[0]).toContain(plugin.name);
expect(res[0]).toContain("Found unknown configuration keys:");
expect(res[0]).toContain("invalidKey");

await expect(hooks.validateConfig.promise(plugin.name, {commitMessage: -1})).resolves.toMatchObject([{
expectedType: '"string"',
path: "one-release-commit.commitMessage",
value: -1,
}]);
});
});

describe("afterVersion", () => {
test('should do nothing on dryRun', async () => {
const {hooks} = setup();
await expect(hooks.afterVersion.promise({dryRun: true})).resolves.toBeUndefined();
expect(exec).not.toHaveBeenCalled();
});

test('should do nothing without version', async () => {
const {hooks} = setup();
await expect(hooks.afterVersion.promise({})).resolves.toBeUndefined();
expect(exec).not.toHaveBeenCalled();
});

test('should do nothing without git', async () => {
const {hooks} = setup();
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();
expect(exec).not.toHaveBeenCalled();
});

test('should be executed in a less priority group', async () => {
getGitLog.mockReset();
getGitLog.mockResolvedValueOnce([]);

const {hooks} = setup({ getGitLog });
hooks.afterVersion.tapPromise("dummy", async () => {
expect(exec).not.toHaveBeenCalled();
expect(getGitLog).not.toHaveBeenCalled();
});
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();
});

test('should do nothing when there no release commits', async () => {
getGitLog.mockReset();
getGitLog.mockResolvedValueOnce([]);

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();
});

test('should create a single release commit when there is one existing commit', async () => {
getGitLog.mockReset();
getGitLog.mockResolvedValueOnce([{hash: "c2241048"}]);

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();

expect(exec).toHaveBeenCalledTimes(4);
expectLookingForGitTagOnCommit(1, "c2241048");
expectResetAndRecreateANewReleaseCommit(2);
});

test('should create a single release commit when there is multiple existing commit', async () => {
const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();

expect(exec).toHaveBeenCalledTimes(5);
expectLookingForGitTagOnCommit(1, "c2241048");
expectLookingForGitTagOnCommit(2, "c2241049");
expectResetAndRecreateANewReleaseCommit(3);
});

test('should recreate all existing tags', async () => {
exec.mockResolvedValueOnce('v1.2.4')
.mockResolvedValueOnce('submobule-v1.2.4')
.mockResolvedValueOnce(' Tag message for v1.2.4 ')
.mockResolvedValueOnce(' Another multiline\ntag message\n');

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();

expect(exec).toHaveBeenCalledTimes(9);
expectLookingForGitTagOnCommit(1, "c2241048");
expectLookingForGitTagOnCommit(2, "c2241049");
expect(exec.mock.calls[3]).toMatchObject(["git",["tag", "v1.2.4", "-l", '--format="%(contents)"']]);
expect(exec.mock.calls[4]).toMatchObject(["git",["tag", "submobule-v1.2.4", "-l", '--format="%(contents)"']]);
expectResetAndRecreateANewReleaseCommit(5);
});

test('should not failed when there is no tag on commit', async () => {
exec.mockResolvedValueOnce('v1.2.4')
.mockRejectedValueOnce(new Error('no tag exactly matches xyz'))
.mockResolvedValueOnce(' Tag message for v1.2.4 ');

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).resolves.toBeUndefined();

expectListGitHistoryCalled();

expect(exec).toHaveBeenCalledTimes(7);
expectLookingForGitTagOnCommit(1, "c2241048");
expectLookingForGitTagOnCommit(2, "c2241049");
expect(exec.mock.calls[3]).toMatchObject(["git",["tag", "v1.2.4", "-l", '--format="%(contents)"']]);
expectResetAndRecreateANewReleaseCommit(4);
});

test.each([
[new Error('unknown failure')],
['not an error'],
])( 'should failed when retrieving tags failed with : %p', async (cause) => {
exec.mockRejectedValueOnce(cause);

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).rejects.toBe(cause);

expectListGitHistoryCalled();

expect(exec).toHaveBeenCalledTimes(3);
expectLookingForGitTagOnCommit(1, "c2241048");
expectLookingForGitTagOnCommit(2, "c2241049");
});

test('should failed when not remote head found', async () => {
exec.mockReset();
exec.mockResolvedValueOnce('');

const {hooks} = setup({ getGitLog });
await expect(hooks.afterVersion.promise({version: 'v1.2.3'})).rejects.toStrictEqual(new Error('No remote found for branch : "main"'));

expect(exec).toHaveBeenCalledTimes(1);
expect(exec.mock.calls[0]).toMatchObject(["git",["ls-remote", "--heads", "origin", "main"]]);
expect(getGitLog).not.toHaveBeenCalled();
});
});
});
79 changes: 75 additions & 4 deletions plugins/one-release-commit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import { Auto, IPlugin, validatePluginConfiguration } from '@auto-it/core';
import { Auto, getCurrentBranch, IPlugin, validatePluginConfiguration, execPromise } from '@auto-it/core';
import * as t from "io-ts";

const pluginOptions = t.partial({
/** Release commit message */
commitMessage: t.string,
});

interface ITag {
/** Name */
name: string;
/** Message */
message: string;
}

/**
* Get Tag (and his message) for a commit
* or return undefined if no tag present on this commit
*/
async function getTag(sha: string) : Promise<ITag | undefined> {
let tag: string|undefined;
try{
tag = await execPromise("git", ["describe", "--tags", "--exact-match", sha])
} catch (error) {
if (!error.message?.includes("no tag exactly matches")) {
throw error;
}
}

if (tag === undefined){
return undefined;
}

const message = await execPromise("git", ["tag", tag, "-l", '--format="%(contents)"']);

return { name: tag, message: message.trim() };
}

export type IOneReleaseCommitPluginOptions = t.TypeOf<typeof pluginOptions>;

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

/** Tap into auto plugin points. */
apply(auto: Auto) {
auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => {
// If it's a string thats valid config
if (name === this.name && typeof options !== "string") {
auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => {
if (name === this.name || name === `@auto-it/${this.name}`) {
return validatePluginConfiguration(this.name, pluginOptions, options);
}
});

auto.hooks.afterVersion.tapPromise({
name: this.name,
// Include this plugin in a less priority stage in order to be mostly often after others plugins
stage: 1,
}, async ({ dryRun, version }) => {
if (!auto.git || dryRun || !version) {
return;
}

const heads = await execPromise("git", [
"ls-remote",
"--heads",
auto.remote,
getCurrentBranch(),
]);
const baseBranchHeadRef = new RegExp(
`^(\\w+)\\s+refs/heads/${auto.baseBranch}$`
);
const [, remoteHead] = heads.match(baseBranchHeadRef) || [];

if (!remoteHead) {
throw new Error(`No remote found for branch : "${auto.baseBranch}"`);
}

const commits = await auto.git.getGitLog(remoteHead);
const tags: ITag[] = (await Promise.all(commits.map(commit => getTag(commit.hash)))).filter(tag => tag !== undefined) as ITag[];

auto.logger.log.info(`Rewrote ${commits.length} release commits into a single commit for version [${version}] with tags: [${tags.map(tag => tag.name).join(", ")}]`);

if (commits.length > 0) {
await execPromise("git", ["reset", "--soft", remoteHead]);
await execPromise("git", ["commit", "-m", this.options.commitMessage || `"Release version ${version} [skip ci]"`, "--no-verify"]);

await Promise.all(tags.map(tag => execPromise("git", [
"tag", "--annotate", "--force", tag.name,
"-m", tag.message,
])));
}
});
}
}

0 comments on commit 6c46da3

Please sign in to comment.