Skip to content

Commit

Permalink
fix: Use classes to validate commits.
Browse files Browse the repository at this point in the history
Closes #17
  • Loading branch information
joberstein committed Jul 30, 2023
1 parent ad3abeb commit 2399349
Show file tree
Hide file tree
Showing 14 changed files with 645 additions and 240 deletions.
10 changes: 9 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ inputs:
default: ${{ github.head_ref }}

target_ref:
description: The commit sha of the event that triggered this workflow (required for push).
description: The commit sha of the event that triggered this workflow.
default: ${{ github.sha }}

ref:
description: The ref (branch or tag) of the event that triggered this workflow (required for pushes).
default: ${{ github.ref }}

ref_type:
description: The ref type of the event that triggered this workflow (required for pushes). Only branch refs are validated.
default: ${{ github.ref_type }}

extra_config:
description: A newline-separated list of commitlint-config npm packages to install.
3 changes: 2 additions & 1 deletion jest.config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"clearMocks": true,
"testPathIgnorePatterns": [ "/dist/", "/node_modules/" ]
"testPathIgnorePatterns": [ "/dist/", "/node_modules/" ],
"modulePaths": ["<rootDir>/src/"]
}
106 changes: 106 additions & 0 deletions src/events/gitEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { execSync } from "child_process";
import * as actions from "@actions/core";
import TestUtils from "../test/util";
import preinstall from "../preinstall";
import * as commitlintExec from "../commitlint";
import GitEvent from "./gitEvent";

describe("src/events/gitEvent", () => {
const commitlint = jest.spyOn(commitlintExec, 'default');
const actionsInfo = jest.spyOn(actions, "info");

const {
createTempDirectory,
intializeGitRepo,
getNthCommitBack,
teardownGitRepo,
teardownTestDirectory,
addInvalidCommit,
addValidCommit,
setupTestDirectory,
options,
} = new TestUtils();

beforeAll(() => {
options.cwd = createTempDirectory();
setupTestDirectory(options.cwd);
preinstall('@joberstein12/commitlint-config', options);
});

beforeEach(() => {
intializeGitRepo();
});

afterEach(() => {
teardownGitRepo();

expect(actionsInfo).toHaveBeenCalled();
expect(actionsInfo).not.toHaveBeenCalledWith(
expect.stringContaining('Skipping commit validation')
);

expect(commitlint).toHaveBeenCalledTimes(1);
});

afterAll(() => {
teardownTestDirectory(options.cwd as string);
});

it("Validates a git event with no target ref", async () => {
const event = new GitEvent({ target: '' }, options);
console.log(execSync('git log', options).toString());

await expect(event.validateCommits()).resolves.not.toThrow();

expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: undefined })
);
});

describe("Validating a git event with a target ref", () => {
it("Successfully validates the target commit", async () => {
const target = getNthCommitBack(1);
const event = new GitEvent({ target }, options);

await expect(event.validateCommits()).resolves.not.toThrow();

expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${target}^` })
);
});

it("Successfully validates commits a merge commit", async () => {
execSync("git checkout -qb other", options);
addValidCommit();

execSync([
'git checkout master',
'git merge --no-ff other',
].join(" && "), options);

const event = new GitEvent({
target: getNthCommitBack(1),
}, options);

await expect(event.validateCommits()).resolves.not.toThrow();

expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${event.target}^` })
);
});

it("Fails when the target commit is invalid", async () => {
addInvalidCommit();

const event = new GitEvent({
target: getNthCommitBack(1)
}, options);

await expect(event.validateCommits()).rejects.toThrow();

expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${event.target}^` })
);
});
});
});
75 changes: 75 additions & 0 deletions src/events/gitEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ExecSyncOptions, execSync } from "child_process";
import commitlint from "commitlint";
import { info } from "@actions/core";

/**
* Represents a base Git event.
*/
class GitEvent {
#args: GitEventArgs;
options?: ExecSyncOptions;

constructor(args: GitEventArgs, options?: ExecSyncOptions) {
this.options = options;
this.#args = args;
}

get target() {
return this.#args.target;
}

/**
* Provide a list of refs to checkout for this Git event.
* @returns A list of refs
*/
getRefsToCheckout(): string[] {
return [this.#args.target];
}

/**
* Perform a checkout on all truthy refs returned from {@link GitEvent#getRefsToCheckout}
*/
performCheckouts(): void {
this.getRefsToCheckout()
.filter(ref => ref)
.forEach(ref => execSync(`git checkout ${ref}`, this.options).toString());
}

/**
* Provide a list of commits for commitlint to start validation from.
* @returns A list of commit hashes
*/
getFromCommits(): string[] {
return [this.#args.target];
}

/**
* Provide a reason to skip commit validation.
* @returns A truthy value explaining why validation is skipped, or a falsey value to proceed with commit validation.
*/
getSkipValidationReason(): string | undefined {
return;
}

/**
* Executes commit validation for this Git event.
*/
async validateCommits(): Promise<void> {
const skipReason = this.getSkipValidationReason();

if (skipReason) {
info(`Skipping commit validation: ${skipReason}`);
return;
}

await Promise.all(
this.getFromCommits()
.map(from => commitlint({
from: from ? `${from}^` : undefined,
cwd: this.options?.cwd?.toString() || undefined,
}))
);
}
}

export default GitEvent;
101 changes: 101 additions & 0 deletions src/events/pullRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { execSync } from "child_process";
import * as actions from "@actions/core";
import TestUtils from "../test/util";
import preinstall from "../preinstall";
import * as commitlintExec from "../commitlint";
import PullRequest from "./pullRequest";

describe("src/events/pullRequest", () => {
const commitlint = jest.spyOn(commitlintExec, 'default');
const actionsInfo = jest.spyOn(actions, "info");

const {
createTempDirectory,
intializeGitRepo,
getNthCommitBack,
teardownGitRepo,
teardownTestDirectory,
addInvalidCommit,
addValidCommit,
setupTestDirectory,
options,
} = new TestUtils();

beforeAll(() => {
options.cwd = createTempDirectory();
setupTestDirectory(options.cwd);
preinstall('@joberstein12/commitlint-config', options);
});

beforeEach(() => {
intializeGitRepo();

execSync("git checkout -qb other", options);
[ ...Array(3).keys() ].forEach(addValidCommit);
});

afterEach(() => {
teardownGitRepo();

expect(actionsInfo).toHaveBeenCalled();
expect(actionsInfo).not.toHaveBeenCalledWith(
expect.stringContaining('Skipping commit validation')
);

expect(commitlint).toHaveBeenCalledTimes(2);
});

afterAll(() => {
teardownTestDirectory(options.cwd as string);
});

it("Successfully completes commit validation with a detached head", async () => {
execSync(`git checkout --detach`, options);
addValidCommit();

const event = new PullRequest({
base_ref: 'master',
head_ref: 'other',
target: getNthCommitBack(1)
}, options);

await expect(event.validateCommits()).resolves.not.toThrow();

[getNthCommitBack(4), event.target].forEach(from => {
expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${from}^` }))
});
});

it("Successfully completes commit validation between two branches", async () => {
const event = new PullRequest({
base_ref: 'master',
head_ref: 'other',
target: getNthCommitBack(1)
}, options);

await expect(event.validateCommits()).resolves.not.toThrow();

[getNthCommitBack(3), event.target].forEach(from => {
expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${from}^` }))
});
});

it("Fails when there's an invalid commit between two branches", async () => {
addInvalidCommit();
addValidCommit();

const event = new PullRequest({
base_ref: 'master',
head_ref: 'other',
target: getNthCommitBack(1),
}, options);

await expect(event.validateCommits()).rejects.toThrow();

expect(commitlint).toHaveBeenCalledWith(
expect.objectContaining({ from: `${getNthCommitBack(5)}^` })
);
});
});
45 changes: 45 additions & 0 deletions src/events/pullRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ExecSyncOptions, execSync } from "child_process";
import GitEvent from "./gitEvent";

/**
* Represents a Git Pull Request event.
*/
export default class PullRequest extends GitEvent {
#args: PullRequestEventArgs;

constructor(args: PullRequestEventArgs, options?: ExecSyncOptions) {
const { target } = args;
super({ target }, options);
this.#args = args;
}

/**
* Provide a list containing the base, head, and target refs for the pull request.
* @returns A list of refs
*/
getRefsToCheckout(): string[] {
const { base_ref, head_ref, target } = this.#args;
return [ base_ref, head_ref, target ];
}

/**
* Provide a list containing the initial commit of the pull request, and the target ref, which may be detached from the pull request head.
* @returns A list of commit hashes
*/
getFromCommits(): string[] {
const { head_ref, base_ref } = this.#args;
const { options, target } = this;

const [commit, ] = execSync(`git rev-list --no-merges --first-parent ${base_ref}..'${head_ref}'`, options)
.toString()
.trim()
.split('\n')
.reverse();

if (!commit) {
throw new Error('Failed to get initial commit in the given range.');
}

return [commit, target];
}
}
Loading

0 comments on commit 2399349

Please sign in to comment.