Commitlint: custom commit message with emojis

on Dragoș Străinu's blog

Commitlint@16.0.2 is the next step on enforcing rules in your JS project after eslint.

Installation and configuration is very simple:

echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

Commitlint suggests Conventional commits which have this format:

type(scope?): subject

But what if I want to use a custom format specific to my team?! Let's say I want to use emoji as a type, an optional ticket, and then the subject, like:

type [ticket]? subject

To change the header format I need to change headerPattern from parserOpts config:

First I need to find a RegExp that will match "βœ… [T-4605] Add tests", also we need to add at least one rule so let's add type-enum that is provided by commitlint to set allowed emojis

// commitlint.config.js
// emojis like "βœ… ", "πŸ˜‚ ", ...
const matchAnyEmojiWithSpaceAfter =
  /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])\s/;
const matchOptionalTicketNumberWithSpaceAfter = /(?:\[(T-\d+)\]\s)?/; // "[T-4605] ", "[T-1]"
const subjectThatDontStartWithBracket = /([^\[].+)/; // "Add tests" but don't allow "[ Add tests"

module.exports = {
  parserPreset: {
    parserOpts: {
      headerPattern: new RegExp(
        "^" +
          matchAnyEmojiWithSpaceAfter.source +
          matchOptionalTicketNumberWithSpaceAfter.source +
          subjectThatDontStartWithBracket.source +
          "$"
      ),
      headerCorrespondence: ["type", "ticket", "subject"],
    },
  },
  rules: {
    "type-enum": [2, "always", ["⭐️", "🐞", "βœ…", "🚧", "♻️", "πŸ“"]],
  },
};

Testing locally:

> echo "βœ… [T-4605] Add tests" | commitlint # passes
> echo "βœ… Add tests" | commitlint # passes
> echo "Something else" | commitlint # should fail but still passes πŸ€”

The problem is that there is no rule to make sure that the header matched our RegExp. I can add 2 other rules from commitlint:

"type-empty": [2, "never"],
"subject-empty": [2, "never"],

but what if I have other variables names, emoji instead of type, desc instead of subject?

I need to create a custom rule using Commitlint plugins. Let's name the rule header-match-team-pattern and also use emoji instead of type. In the rule, we check if all variables are null and return a message

...
      headerCorrespondence: ["emoji", "ticket", "subject"],
    },
  },
  plugins: [
    {
      rules: {
        "header-match-team-pattern": (parsed) => {
          const { emoji, ticket, subject } = parsed;
          
          if (emoji === null && ticket === null && subject === null) {
            return [
              false,
              "header must be in format 'βœ… [T-4605] Add tests' or 'βœ… Add tests'",
            ];
          }
          return [true, ""];
        },
      },
    },
  ],
  rules: {
    "header-match-team-pattern": [2, "always"],
...

You can console.log({ parsed }), for debugging

Now let's create a better type-enum rule, explained-emoji-enum:

...
        "explained-emoji-enum": (parsed, _when, emojisObject) => {
          const { emoji } = parsed;
          if (emoji && !Object.keys(emojisObject).includes(emoji)) {
            return [
              false,
              `emoji must be one of:
${Object.keys(emojisObject)
                .map((emojiType) => `${emojiType} - ${emojisObject[emojiType]}`)
                .join("\n")}`,
            ];
          }
          return [true, ""];
        },
      },
    },
  ],
  rules: {
    ...
    "explained-emoji-enum": [
      2,
      "always",
      {
        "⭐️": "New feature",
        "🐞": "Bugfix",
        "βœ…": "Add, update tests",
        "🚧": "Work in progress",
        "♻️": "Refactor",
        "πŸ“": "Documentation update",
      },
    ],
  },
...

And when the engineer will set a wrong emoji it will have a error like:

> echo "πŸ˜‚ Add tests" | commitlint                                                                                                                                 
β§—   input: πŸ˜‚ Add tests
βœ–   emoji must be one of:
⭐️ - New feature
🐞 - Bugfix
βœ… - Add, update tests
🚧 - Work in progress
♻️ - Refactor
πŸ“ - Documentation update [explained-emoji-enum]

βœ–   found 1 problems, 0 warnings

Final result

// commitlint.config.js
// emojis like "βœ… ", "πŸ˜‚ ", ...
const matchAnyEmojiWithSpaceAfter =
  /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])\s/;
const matchOptionalTicketNumberWithSpaceAfter = /(?:\[(T-\d+)\]\s)?/; // "[T-4605] ", "[T-1]"
const subjectThatDontStartWithBracket = /([^\[].+)/; // "Add tests" but don't allow "[ Add tests"

module.exports = {
  parserPreset: {
    parserOpts: {
      headerPattern: new RegExp(
        "^" +
          matchAnyEmojiWithSpaceAfter.source +
          matchOptionalTicketNumberWithSpaceAfter.source +
          subjectThatDontStartWithBracket.source +
          "$"
      ),
      headerCorrespondence: ["emoji", "ticket", "subject"],
    },
  },
  plugins: [
    {
      rules: {
        "header-match-team-pattern": (parsed) => {
          const { emoji, ticket, subject } = parsed;
          if (emoji === null && ticket === null && subject === null) {
            return [
              false,
              "header must be in format 'βœ… [T-4605] Add tests' or 'βœ… Add tests'",
            ];
          }
          return [true, ""];
        },
        "explained-emoji-enum": (parsed, _when, emojisObject) => {
          const { emoji } = parsed;
          if (emoji && !Object.keys(emojisObject).includes(emoji)) {
            return [
              false,
              `emoji must be one of:
${Object.keys(emojisObject)
                .map((emojiType) => `${emojiType} - ${emojisObject[emojiType]}`)
                .join("\n")}`,
            ];
          }
          return [true, ""];
        },
      },
    },
  ],
  rules: {
    "header-match-team-pattern": [2, "always"],
    "explained-emoji-enum": [
      2,
      "always",
      {
        "⭐️": "New feature",
        "🐞": "Bug fix",
        "βœ…": "Add, update tests",
        "🚧": "Work in progress",
        "♻️": "Refactor",
        "πŸ“": "Documentation update",
      },
    ],
  },
};

Next steps

I can add some rules from Commitlint or create other custom ones.
Put the config in a new package.
Add husky and use it in every company repo on the pre-commit hook.