Skip to content

Commit

Permalink
feat(git-tools): Added custom commit linting logic
Browse files Browse the repository at this point in the history
  • Loading branch information
sullivanpj committed Jan 10, 2025
1 parent 03c9f9f commit 2536402
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 119 deletions.
4 changes: 3 additions & 1 deletion packages/git-tools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@storm-software/git-tools",
"version": "2.79.1",
"type": "module",
"description": "Tools for managing Git repositories within a Nx workspace.",
"repository": {
"type": "github",
Expand Down Expand Up @@ -244,7 +245,7 @@
"tsconfig-paths": "4.2.0"
},
"devDependencies": {
"@commitlint/lint": "^19.6.0",
"@commitlint/rules": "^19.6.0",
"@humanfs/core": "^0.19.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/retry": "0.4.1",
Expand Down Expand Up @@ -272,6 +273,7 @@
"chalk": "catalog:",
"chalk-template": "1.1.0",
"commander": "catalog:",
"conventional-commits-parser": "^6.0.0",
"defu": "catalog:",
"htmlparser2": "10.0.0",
"jsonc-parser": "3.2.1",
Expand Down
17 changes: 17 additions & 0 deletions packages/git-tools/src/commitlint/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,20 @@ export const DEFAULT_COMMIT_RULES: DefaultCommitRulesEnum = {
"scope-case": [RuleConfigSeverity.Error, "always", ["kebab-case"]],
"scope-empty": [RuleConfigSeverity.Error, "never"]
};

export const DEFAULT_COMMITLINT_CONFIG = {
rules: DEFAULT_COMMIT_RULES,
helpUrl: "https://stormsoftware.com/ops/commitlint",
parserOpts: {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/,
headerCorrespondence: ["type", "scope", "subject"],
noteKeywords: ["BREAKING CHANGE", "BREAKING-CHANGE"],
revertPattern:
/^(?:Revert|revert:)\s"?([\s\S]+?)"?\s*This reverts commit (\w*)\./i,
revertCorrespondence: ["header", "hash"],
issuePrefixes: ["#"]
}
};

export type CommitLintOptions = typeof DEFAULT_COMMITLINT_CONFIG;
187 changes: 187 additions & 0 deletions packages/git-tools/src/commitlint/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import defaultRules from "@commitlint/rules";
import { CommitParser } from "conventional-commits-parser";
import util from "util";
import {
CommitLintOutcome,
CommitLintRuleOutcome,
DefaultCommitRulesEnum,
RuleConfigSeverity
} from "../types";
import { CommitLintOptions, DEFAULT_COMMITLINT_CONFIG } from "./config";

interface CommitMessageData {
header: string | null;
body?: string | null;
footer?: string | null;
}

const buildCommitMessage = ({
header,
body,
footer
}: CommitMessageData): string => {
let message = header;

message = body ? `${message}\n\n${body}` : message;
message = footer ? `${message}\n\n${footer}` : message;

return message || "";
};

export default async function lint(
message: string,
rawRulesConfig?: DefaultCommitRulesEnum,
rawOpts?: {
parserOpts?: CommitLintOptions["parserOpts"];
helpUrl?: CommitLintOptions["helpUrl"];
}
): Promise<CommitLintOutcome> {
const rulesConfig = rawRulesConfig || {};

const parser = new CommitParser(
rawOpts?.parserOpts ?? DEFAULT_COMMITLINT_CONFIG.parserOpts
);
const parsed = parser.parse(message);

if (
parsed.header === null &&
parsed.body === null &&
parsed.footer === null
) {
// Commit is empty, skip
return {
valid: true,
errors: [],
warnings: [],
input: message
};
}

const allRules: Map<string, any> = new Map(Object.entries(defaultRules));

// Find invalid rules configs
const missing = Object.keys(rulesConfig).filter(
name => typeof allRules.get(name) !== "function"
);

if (missing.length > 0) {
const names = [...allRules.keys()];
throw new RangeError(
[
`Found rules without implementation: ${missing.join(", ")}.`,
`Supported rules are: ${names.join(", ")}.`
].join("\n")
);
}

const invalid = Object.entries(rulesConfig)
.map(([name, config]) => {
if (!Array.isArray(config)) {
return new Error(
`config for rule ${name} must be array, received ${util.inspect(
config
)} of type ${typeof config}`
);
}

const [level] = config;

if (level === RuleConfigSeverity.Disabled && config.length === 1) {
return null;
}

const [, when] = config;

if (typeof level !== "number" || isNaN(level)) {
return new Error(
`level for rule ${name} must be number, received ${util.inspect(
level
)} of type ${typeof level}`
);
}

if (config.length < 2 || config.length > 3) {
return new Error(
`config for rule ${name} must be 2 or 3 items long, received ${util.inspect(
config
)} of length ${config.length}`
);
}

if (level < 0 || level > 2) {
return new RangeError(
`level for rule ${name} must be between 0 and 2, received ${util.inspect(
level
)}`
);
}

if (typeof when !== "string") {
return new Error(
`condition for rule ${name} must be string, received ${util.inspect(
when
)} of type ${typeof when}`
);
}

if (when !== "never" && when !== "always") {
return new Error(
`condition for rule ${name} must be "always" or "never", received ${util.inspect(
when
)}`
);
}

return null;
})
.filter((item): item is Error => item instanceof Error);

if (invalid.length > 0) {
throw new Error(invalid.map(i => i.message).join("\n"));
}

// Validate against all rules
const pendingResults = Object.entries(rulesConfig as DefaultCommitRulesEnum)
// Level 0 rules are ignored
.filter(([, config]) => !!config && config.length && config[0] > 0)
.map(async entry => {
const [name, config] = entry;
const [level, when, value] = config!; //

const rule = allRules.get(name);

if (!rule) {
throw new Error(`Could not find rule implementation for ${name}`);
}

const executableRule = rule as any;
const [valid, message] = await executableRule(parsed, when, value);

return {
level,
valid,
name,
message
};
});

const results = (await Promise.all(pendingResults)).filter(
(result): result is CommitLintRuleOutcome => result !== null
);

const errors = results.filter(
result => result.level === RuleConfigSeverity.Error && !result.valid
);
const warnings = results.filter(
result => result.level === RuleConfigSeverity.Warning && !result.valid
);

const valid = errors.length === 0;

return {
valid,
errors,
warnings,
input: buildCommitMessage(parsed)
};
}
47 changes: 25 additions & 22 deletions packages/git-tools/src/commitlint/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
writeWarning
} from "@storm-software/config-tools";
import { StormConfig } from "@storm-software/config/types";
import defu from "defu";
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import childProcess from "node:child_process";
import { DEFAULT_COMMIT_TYPES } from "../types";
import { DEFAULT_COMMIT_RULES } from "./config";
import { DEFAULT_COMMITLINT_CONFIG } from "./config";
import lint from "./lint";
import { getNxScopes, getRuleFromScopeEnum } from "./scope";

const COMMIT_EDITMSG_PATH = ".git/COMMIT_EDITMSG";
Expand Down Expand Up @@ -90,45 +92,46 @@ export const runCommitLint = async (

const allowedTypes = Object.keys(DEFAULT_COMMIT_TYPES).join("|");
const allowedScopes = await getNxScopes();

// eslint-disable-next-line no-useless-escape
const commitMsgRegex = `(${allowedTypes})\\((${allowedScopes})\\)!?:\\s(([a-z0-9:\-\s])+)`;

const matchCommit = new RegExp(commitMsgRegex, "g").test(commitMessage);
const matchRevert = /Revert/gi.test(commitMessage);
const matchRelease = /Release/gi.test(commitMessage);

const lint = (await import("@commitlint/lint")).default;
const commitlintConfig = defu(
params.config ?? {},
{ rules: { "scope-enum": getRuleFromScopeEnum(allowedScopes) } },
DEFAULT_COMMITLINT_CONFIG
);

const report = await lint(commitMessage, {
...DEFAULT_COMMIT_RULES,
"scope-enum": getRuleFromScopeEnum(allowedScopes)
} as any);
const report = await lint(commitMessage, commitlintConfig.rules, {
parserOpts: commitlintConfig.parserOpts,
helpUrl: commitlintConfig.helpUrl
});

if (
+!(matchRelease || matchRevert || matchCommit) === 0 ||
report.errors.length ||
report.warnings.length
) {
if (!matchCommit || report.errors.length || report.warnings.length) {
writeSuccess(`Commit was processing completed successfully!`, config);
} else {
let errorMessage =
" Oh no! 😦 Your commit message: \n" +
" Oh no! Your commit message: \n" +
"-------------------------------------------------------------------\n" +
commitMessage +
"\n-------------------------------------------------------------------" +
"\n\n 👉️ Does not follow the commit message convention specified in the CONTRIBUTING.MD file.";
"\n\n Does not follow the commit message convention specified by Storm Software.";
errorMessage += "\ntype(scope): subject \n BLANK LINE \n body";
errorMessage += "\n";
errorMessage += `\npossible types: ${allowedTypes}`;
errorMessage += `\npossible scopes: ${allowedScopes} (if unsure use "core")`;
errorMessage += `\nPossible types: ${allowedTypes}`;
errorMessage += `\nPossible scopes: ${allowedScopes} (if unsure use "monorepo")`;
errorMessage +=
"\nEXAMPLE: \n" +
"feat(nx): add an option to generate lazy-loadable modules\n" +
"fix(core)!: breaking change should have exclamation mark\n";
"\n\nEXAMPLE: \n" +
"feat(my-lib): add an option to generate lazy-loadable modules\n" +
"fix(monorepo)!: breaking change should have exclamation mark\n";
errorMessage += `\n\nCommitLint Errors: ${report.errors.length ? report.errors.map(error => ` - ${error.message}`).join("\n") : "None"}`;
errorMessage += `\nCommitLint Warnings: ${report.warnings.length ? report.warnings.map(warning => ` - ${warning.message}`).join("\n") : "None"}`;
errorMessage += "\n\nPlease fix the commit message and try again.";
errorMessage += "\n\nPlease fix the commit message and rerun storm-commit.";
errorMessage += `\n\nMore details about the Storm Software commit message specification can be found at: ${commitlintConfig.helpUrl}`;

throw new Error(errorMessage);
}

return report.input;
};
40 changes: 31 additions & 9 deletions packages/git-tools/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ export const DEFAULT_COMMIT_TYPES = {
},
revert: {
description: "Revert a previously committed change",
title: "Rollback",
title: "Revert",
emoji: "🗑️ ",
semverBump: "patch",
changelog: {
title: "Rollbacks",
title: "Reverts",
hidden: false
}
},
Expand Down Expand Up @@ -308,6 +308,13 @@ export type CommitSettingsEnum = Record<string, any> & {
format: string;
};

export type RuleConfigCondition = "always" | "never";
export enum RuleConfigSeverity {
Disabled = 0,
Warning = 1,
Error = 2
}

export type CommitRulesEnum = Record<
string,
| [RuleConfigSeverity, RuleConfigCondition]
Expand Down Expand Up @@ -353,13 +360,6 @@ export type CommitConfig<
questions: TCommitQuestionEnum;
};

export type RuleConfigCondition = "always" | "never";
export enum RuleConfigSeverity {
Disabled = 0,
Warning = 1,
Error = 2
}

export type DefaultResolvedCommitRulesEnum = DefaultCommitRulesEnum & {
"scope-enum": (
ctx: any
Expand Down Expand Up @@ -403,6 +403,28 @@ export type CommitState<
root: string;
};

export interface CommitLintRuleOutcome {
/** If the commit is considered valid for the rule */
valid: boolean;
/** The "severity" of the rule (1 = warning, 2 = error) */
level: RuleConfigSeverity;
/** The name of the rule */
name: string;
/** The message returned from the rule, if invalid */
message: string;
}

export interface CommitLintOutcome {
/** The linted commit, as string */
input: string;
/** If the linted commit is considered valid */
valid: boolean;
/** All errors, per rule, for the commit */
errors: CommitLintRuleOutcome[];
/** All warnings, per rule, for the commit */
warnings: CommitLintRuleOutcome[];
}

export type ReleaseConfig = any & {
npm: boolean;
github: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/git-tools/tsup.bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ export default defineConfig([
tsconfig: "./tsconfig.json",
shims: true,
skipNodeModulesBundle: false,
noExternal: ["@commitlint/lint", "defu"]
noExternal: ["@commitlint/rules", "conventional-commits-parser", "defu"]
}
]);
Loading

0 comments on commit 2536402

Please sign in to comment.