@aritslimited/commitlint
Version:
A commit linting commitizen adapter & branch naming convention tool tailored for ARITS Limited with Jira Issue & Project Tracking Software; to track commits to Jira issues and transition them to the next stage of development workflow automatically.
467 lines (466 loc) • 20 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
require("./dotenv.config.js");
const child_process_1 = require("child_process");
const branchlint_config_js_1 = require("./branchlint.config.js");
const LimitedInputPrompt_1 = require("./Components/LimitedInputPrompt");
const requiredEnvVariables = [
"JIRA_BASE_URL",
"JIRA_PROJECT",
"JIRA_ISSUE_FILTERS",
"JIRA_ISSUE_TRANSITION_FILTERS",
"JIRA_API_USER",
"JIRA_API_TOKEN",
];
const env = tslib_1.__rest(Object.fromEntries(requiredEnvVariables.map((varName) => {
const value = process.env[varName];
if (value === undefined) {
throw new Error(`Environment variable '${varName}' is not defined.`);
}
return [varName, value];
})), []);
const MAX_SUMMARY_LENGTH = 50;
const MAX_TITLE_WIDTH = 120;
const base64Credentials = Buffer.from(`${env.JIRA_API_USER}:${env.JIRA_API_TOKEN}`).toString("base64");
const fetchJiraIssues = () => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
const status = env.JIRA_ISSUE_FILTERS.split(", ")
.map((e) => `"${e.replace(/\[|\]/g, "")}"`)
.join(", ");
const jqlQuery = `project = "${env.JIRA_PROJECT}" AND status IN (${status}) AND assignee = currentUser() ORDER BY updated DESC`;
const jiraIssues = [
{ value: undefined, name: "Undefined issue! Task first, commit next!" },
];
try {
const response = yield fetch(`${env.JIRA_BASE_URL}/search?jql=${encodeURIComponent(jqlQuery)}`, {
method: "GET",
headers: {
Authorization: `Basic ${base64Credentials}`,
"Content-Type": "application/json",
Accept: "application/json",
},
});
if (response.ok) {
const { issues } = yield response.json();
if (issues) {
jiraIssues.unshift(issues.map((issue) => ({
value: issue.key,
priority: issue.fields.priority.name,
name: `${issue.key} — ${issue.fields.issuetype.name} [${issue.fields.priority.name}]: ${issue.fields.summary.length > MAX_SUMMARY_LENGTH
? issue.fields.summary.slice(0, MAX_SUMMARY_LENGTH) + "..."
: issue.fields.summary}`,
id: issue.id,
})));
return jiraIssues.flat();
}
}
throw new Error(`Error fetching Jira issues: ${response.status}`);
}
catch (error) {
console.error(error);
}
return jiraIssues;
});
const fetchJiraIssueTransitions = (jiraIssueKey) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
const jiraIssueTransitionFilters = env.JIRA_ISSUE_TRANSITION_FILTERS.split(", ").map((e) => e.replace(/\[|\]/g, ""));
const jiraIssueTransitions = [{ value: undefined, name: "Keep issue status unchanged!" }];
try {
const response = yield fetch(`${env.JIRA_BASE_URL}/issue/${jiraIssueKey}/transitions`, {
method: "GET",
headers: {
Authorization: `Basic ${base64Credentials}`,
"Content-Type": "application/json",
Accept: "application/json",
},
});
if (response.ok) {
const { transitions } = yield response.json();
if (transitions) {
const transition = transitions.map((transition) => {
var _a;
if (jiraIssueTransitionFilters.includes(transition.name)) {
return {
value: transition.id,
name: `${transition.name} [${(_a = transition.to.self.name) !== null && _a !== void 0 ? _a : transition.to.name}]`,
};
}
return null;
});
jiraIssueTransitions.unshift(transition.filter(Boolean));
return jiraIssueTransitions.flat();
}
}
throw new Error(`Error fetching Jira issue transitions: ${response.status}`);
}
catch (error) {
console.error(error);
}
return jiraIssueTransitions;
});
const updateJiraIssueStatus = (jiraIssueKey, transitionId) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
const { green } = (yield import("chalk")).default;
const response = yield fetch(`${env.JIRA_BASE_URL}/issue/${jiraIssueKey}/transitions`, {
method: "POST",
headers: {
Authorization: `Basic ${base64Credentials}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
transition: {
id: transitionId,
},
}),
});
if (response.ok) {
return green("\t✓ Issue status updated successfully!");
}
throw new Error(`Error updating Jira issue status for ${jiraIssueKey}: ${response.status}`);
});
const updateJiraIssueTimeTracking = (jiraIssueKey, timeSpent) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
const { green } = (yield import("chalk")).default;
const response = yield fetch(`${env.JIRA_BASE_URL}/issue/${jiraIssueKey}/worklog`, {
method: "POST",
headers: {
Authorization: `Basic ${base64Credentials}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
timeSpent,
}),
});
if (response.ok) {
return green("\t✓ Work log updated successfully!");
}
throw new Error(`Error updating Jira issue time tracking for ${jiraIssueKey}: ${response.status}`);
});
const handleUpdateJiraIssue = (JIRA_ISSUE_KEYS, ISSUE_TRANSITION_IDs, TIME_TO_ACCOMPLISH_TASKS) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
const { blue } = (yield import("chalk")).default;
JIRA_ISSUE_KEYS.filter(Boolean).map((jiraIssueKey) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
if (jiraIssueKey !== undefined &&
Object.keys(ISSUE_TRANSITION_IDs).includes(jiraIssueKey) &&
Object.keys(TIME_TO_ACCOMPLISH_TASKS).includes(jiraIssueKey)) {
console.log(`\nUpdating status & work log of ${blue(jiraIssueKey)}...\n`, yield updateJiraIssueStatus(jiraIssueKey, ISSUE_TRANSITION_IDs[jiraIssueKey]), "\n", yield updateJiraIssueTimeTracking(jiraIssueKey, TIME_TO_ACCOMPLISH_TASKS[jiraIssueKey]));
}
}));
});
import("inquirer/lib/prompts/input.js").then((m) => m.default);
const prompter = (cz, commit) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
var _a, _b;
process.env.VALID_BRANCH_NAMES &&
(yield (0, branchlint_config_js_1.branchLint)(process.env.VALID_BRANCH_NAMES));
cz.registerPrompt("limitedInput", yield (0, LimitedInputPrompt_1.LimitedInputPrompt)());
const binaryChoices = [
{ value: true, name: "Yes" },
{ value: false, name: "No" },
];
const validBranchNames = (validBranchNameString) => validBranchNameString.split(/ /g).map((branch) => {
branch = branch.replace(/[-_]/g, " ");
return branch
.split(/\s+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
});
const multiChoiceSteps = {
commitTypes: {
question: "Select the type of update that you're committing:",
choices: [
{ value: "feat", name: "feat: A new feature" },
{ value: "fix", name: "fix: A bug fix" },
{
value: "chore",
name: "chore: Continuation of an incomplete feature/other changes",
},
{ value: "docs", name: "docs: Documentation only changes" },
{ value: "style", name: "style: CSS style changes" },
{
value: "refactor",
name: "refactor: A code update that neither fixes a bug nor adds a feature",
},
{
value: "perf",
name: "perf: A code update that improves performance",
},
{ value: "test", name: "test: Adding test script" },
{
value: "build",
name: "build: Changes that affect the build system or external dependencies",
},
{
value: "ci",
name: "ci: Changes that affect the continuous integration system",
},
{ value: "revert", name: "revert: Reverts a previous commit" },
],
},
jiraIssueKeys: {
question: "Select the Jira issue key(s) that you're committing:",
choices: yield fetchJiraIssues(),
},
commitScopes: {
question: "Select the scope of your changes:",
choices: process.env.VALID_BRANCH_NAMES
? process.env.VALID_BRANCH_NAMES.split(/[\s,]+/).map((validBranch) => {
return {
value: validBranch,
name: validBranchNames(validBranch),
};
})
: (0, child_process_1.execSync)(`git for-each-ref --format="%(refname:short)" refs/heads/`)
.toString()
.trim()
.split("\n")
.map((vb) => {
return {
value: vb,
name: validBranchNames(vb),
};
}),
},
isBreakingUpdate: {
question: "Are there any breaking changes?",
choices: binaryChoices,
},
commitConfirmation: {
question: "Are you sure you want to proceed with the commit?",
choices: binaryChoices,
},
pushCommitConfirmation: {
question: "Do you want to push the commit to remote?",
choices: binaryChoices,
},
};
const getFormattedTitlePrefix = ({ type, scope, isBreaking, jiraIssueKey, }) => `${type}(${scope})${isBreaking ? "!" : ""}: `;
const jiraIssueTransitions = {};
const { COMMIT_TYPE, JIRA_ISSUE_KEYS, } = yield cz.prompt([
{
type: "list",
name: "COMMIT_TYPE",
message: multiChoiceSteps.commitTypes.question,
choices: multiChoiceSteps.commitTypes.choices,
when: true,
default: (_a = multiChoiceSteps.commitTypes.choices.find((choice) => choice.value === "chore")) === null || _a === void 0 ? void 0 : _a.value,
pageSize: 15,
},
{
type: "checkbox",
name: "JIRA_ISSUE_KEYS",
message: multiChoiceSteps.jiraIssueKeys.question,
when: ({ COMMIT_TYPE, }) => {
return (COMMIT_TYPE !== "build" &&
COMMIT_TYPE !== "ci" &&
COMMIT_TYPE !== "revert");
},
choices: multiChoiceSteps.jiraIssueKeys.choices,
validate: (input) => {
if (input.length < 1)
return "You must select at least one issue key";
return true;
},
},
]);
if (JIRA_ISSUE_KEYS && JIRA_ISSUE_KEYS.length > 0) {
JIRA_ISSUE_KEYS.filter(Boolean).map((jiraIssueKey) => tslib_1.__awaiter(void 0, void 0, void 0, function* () {
Object.assign(jiraIssueTransitions, {
[jiraIssueKey]: yield fetchJiraIssueTransitions(jiraIssueKey),
});
}));
}
const TIME_TO_ACCOMPLISH_TASKS = tslib_1.__rest(yield cz.prompt(JIRA_ISSUE_KEYS && JIRA_ISSUE_KEYS.length > 0
? JIRA_ISSUE_KEYS.filter(Boolean).map((jiraIssueKey) => ({
type: "input",
name: `${jiraIssueKey}`,
message: `Time taken for ${jiraIssueKey} (required):\n`,
suffix: "{1w 2d 3h 4m}",
when: ({ COMMIT_TYPE, }) => {
return (COMMIT_TYPE !== "build" &&
COMMIT_TYPE !== "ci" &&
COMMIT_TYPE !== "revert");
},
validate(input) {
if (input.length < 2) {
return `The time must have at least ${2} characters`;
}
const unitOrder = { w: 1, d: 2, h: 3, m: 4 };
const units = input.match(/\d+[wdhm]/g);
if (!units || (units === null || units === void 0 ? void 0 : units.length) === 0) {
return "The time must contain at least one unit (w, d, h, m)";
}
const seenUnits = new Set();
let prevUnitOrder = 0;
for (const unit of units) {
const unitType = unit.charAt(unit.length - 1);
const unitValue = parseInt(unit, 10);
if (unitValue === 0) {
return "The time must not contain leading zero values";
}
if (seenUnits.has(unitType)) {
return "The time must not contain duplicate units";
}
if (unitOrder[unitType] <= prevUnitOrder) {
return "The time must be in descending order of units";
}
seenUnits.add(unitType);
prevUnitOrder = unitOrder[unitType];
}
return true;
},
filter(time) {
const units = time.match(/\d+[wdhm]/g);
if (!units || (units === null || units === void 0 ? void 0 : units.length) === 0) {
throw Error("Invalid time format");
}
return units.join(" ");
},
}))
: []), []);
const ISSUE_TRANSITION_IDs = tslib_1.__rest(yield cz.prompt(JIRA_ISSUE_KEYS && JIRA_ISSUE_KEYS.length > 0
? JIRA_ISSUE_KEYS.filter(Boolean).map((jiraIssueKey) => ({
type: "list",
name: `${jiraIssueKey}`,
message: `Update status of ${jiraIssueKey} to:`,
choices: jiraIssueTransitions[jiraIssueKey],
default: 0,
}))
: []), []);
const { COMMIT_SCOPE, COMMIT_TITLE, COMMIT_BODY, IS_BREAKING_UPDATE, } = yield cz.prompt([
{
type: "list",
name: "COMMIT_SCOPE",
message: multiChoiceSteps.commitScopes.question,
choices: multiChoiceSteps.commitScopes.choices,
when: true,
default: () => {
const currentBranch = (0, child_process_1.execSync)("git branch --show-current")
.toString()
.trim();
const defaultSelectedIndex = multiChoiceSteps.commitScopes.choices.findIndex((choice) => currentBranch.includes(choice.value));
if (defaultSelectedIndex !== -1) {
return defaultSelectedIndex;
}
return 0;
},
},
{
type: "list",
name: "IS_BREAKING_UPDATE",
message: multiChoiceSteps.isBreakingUpdate.question,
choices: multiChoiceSteps.isBreakingUpdate.choices,
when: ({ COMMIT_TYPE, }) => {
return (COMMIT_TYPE !== "build" &&
COMMIT_TYPE !== "ci" &&
COMMIT_TYPE !== "revert");
},
default: (_b = multiChoiceSteps.isBreakingUpdate.choices.find((choice) => !choice.value)) === null || _b === void 0 ? void 0 : _b.value,
},
{
type: "limitedInput",
name: "COMMIT_TITLE",
message: "Commit title — a short, imperative tense description (required):\n",
when: true,
maxLength: MAX_TITLE_WIDTH,
leadingLabel: ({ COMMIT_SCOPE, IS_BREAKING_UPDATE, }) => {
return getFormattedTitlePrefix({
type: COMMIT_TYPE,
scope: COMMIT_SCOPE,
isBreaking: IS_BREAKING_UPDATE,
jiraIssueKey: JIRA_ISSUE_KEYS,
});
},
validate: (input) => input.length >= 5 || `The title must have at least ${5} characters`,
filter: (title) => {
title = title.trim();
title = title.replace(/\s+$/, "");
return title;
},
},
{
type: "editor",
name: "COMMIT_BODY",
message: ({ IS_BREAKING_UPDATE, }) => {
return IS_BREAKING_UPDATE
? "Provide a longer description of the breaking changes: (press Enter, write, then [Esc] + [:] + [wq] to save)\n"
: "Provide a longer description: (press Enter, then [Esc] + [:] + [q] to skip)\n";
},
when: ({ COMMIT_TYPE, IS_BREAKING_UPDATE, }) => {
return ((COMMIT_TYPE !== "build" &&
COMMIT_TYPE !== "ci" &&
COMMIT_TYPE !== "revert") ||
IS_BREAKING_UPDATE);
},
validate: (input, { IS_BREAKING_UPDATE }) => {
if (IS_BREAKING_UPDATE) {
return (input.length >= 10 || `The body must have at least ${10} characters`);
}
return true;
},
filter: (body) => {
body = body.trim();
return body.length > 0 ? body : "";
},
},
]);
const bodyString = (COMMIT_BODY === null || COMMIT_BODY === void 0 ? void 0 : COMMIT_BODY.length)
? `\n\n\n${IS_BREAKING_UPDATE ? "BREAKING CHANGE:" : "DESCRIPTION:"}\n` +
COMMIT_BODY +
"\n"
: "\n\n\n";
const contentModificationsString = `CONTENT MODIFICATIONS:\n${(0, child_process_1.execSync)("git diff --cached --name-status")
.toString()
.split("\n")
.map((line) => `\t${line.trim()}`)
.join("\n")}`;
const timeString = Object.keys(TIME_TO_ACCOMPLISH_TASKS).length
? `\nTIME:\n${Object.entries(TIME_TO_ACCOMPLISH_TASKS)
.map(([jiraIssueKey, time], index) => `\t${jiraIssueKey}\t[${time}]`)
.join("\n")}`
: "";
const commitMessage = `${getFormattedTitlePrefix({
type: COMMIT_TYPE,
scope: COMMIT_SCOPE,
isBreaking: IS_BREAKING_UPDATE,
jiraIssueKey: JIRA_ISSUE_KEYS,
})}${COMMIT_TITLE}` +
bodyString +
contentModificationsString +
timeString;
const boxen = (yield import("boxen")).default;
const boxenOptions = {
padding: 1,
margin: 1,
title: "Commit Message Preview",
titleAlignment: "center",
borderColor: "yellow",
borderStyle: {
topLeft: "",
topRight: "",
bottomLeft: "",
bottomRight: "",
top: "-",
bottom: "-",
left: "",
right: "",
},
float: "center",
};
console.log(boxen(commitMessage.trim(), boxenOptions));
const { confirm } = yield cz.prompt([
{
type: "list",
name: "confirm",
message: multiChoiceSteps.commitConfirmation.question,
choices: multiChoiceSteps.commitConfirmation.choices,
default: true,
},
]);
if (!confirm)
process.exit(1);
else {
commit(commitMessage.trim());
if (JIRA_ISSUE_KEYS && ISSUE_TRANSITION_IDs && TIME_TO_ACCOMPLISH_TASKS) {
yield handleUpdateJiraIssue(JIRA_ISSUE_KEYS, ISSUE_TRANSITION_IDs, TIME_TO_ACCOMPLISH_TASKS);
}
}
});
exports.default = { prompter };
;