main.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // TODO: Refactor this action
  2. const {execSync} = require('child_process');
  3. /**
  4. * Gets the value of an input. The value is also trimmed.
  5. *
  6. * @param name name of the input to get
  7. * @param options optional. See InputOptions.
  8. * @returns string
  9. */
  10. function getInput(name, options) {
  11. const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || '';
  12. if (options && options.required && !val) {
  13. throw new Error(`Input required and not supplied: ${name}`);
  14. }
  15. return val.trim();
  16. }
  17. const START_FROM = getInput('from');
  18. const END_TO = getInput('to');
  19. const INCLUDE_COMMIT_BODY = getInput('include-commit-body') === 'true';
  20. const INCLUDE_ABBREVIATED_COMMIT = getInput('include-abbreviated-commit') === 'true';
  21. /**
  22. * @typedef {Object} ICommit
  23. * @property {string | undefined} abbreviated_commit
  24. * @property {string | undefined} subject
  25. * @property {string | undefined} body
  26. */
  27. /**
  28. * @typedef {ICommit & {type: string | undefined, scope: string | undefined}} ICommitExtended
  29. */
  30. /**
  31. * Any unique string that is guaranteed not to be used in committee text.
  32. * Used to split data in the commit line
  33. * @type {string}
  34. */
  35. const commitInnerSeparator = '~~~~';
  36. /**
  37. * Any unique string that is guaranteed not to be used in committee text.
  38. * Used to split each commit line
  39. * @type {string}
  40. */
  41. const commitOuterSeparator = '₴₴₴₴';
  42. /**
  43. * Commit data to be obtained.
  44. * @type {Map<string, string>}
  45. *
  46. * @see https://git-scm.com/docs/git-log#Documentation/git-log.txt-emnem
  47. */
  48. const commitDataMap = new Map([
  49. ['subject', '%s'], // Required
  50. ]);
  51. if (INCLUDE_COMMIT_BODY) {
  52. commitDataMap.set('body', '%b');
  53. }
  54. if (INCLUDE_ABBREVIATED_COMMIT) {
  55. commitDataMap.set('abbreviated_commit', '%h');
  56. }
  57. /**
  58. * The type used to group commits that do not comply with the convention
  59. * @type {string}
  60. */
  61. const fallbackType = 'other';
  62. /**
  63. * List of all desired commit groups and in what order to display them.
  64. * @type {string[]}
  65. */
  66. const supportedTypes = [
  67. 'feat',
  68. 'fix',
  69. 'perf',
  70. 'refactor',
  71. 'style',
  72. 'docs',
  73. 'test',
  74. 'build',
  75. 'ci',
  76. 'chore',
  77. 'revert',
  78. 'deps',
  79. fallbackType,
  80. ];
  81. /**
  82. * @param {string} commitString
  83. * @returns {ICommit}
  84. */
  85. function parseCommit(commitString) {
  86. /** @type {ICommit} */
  87. const commitDataObj = {};
  88. const commitDataArray =
  89. commitString
  90. .split(commitInnerSeparator)
  91. .map(s => s.trim());
  92. for (const [key] of commitDataMap) {
  93. commitDataObj[key] = commitDataArray.shift();
  94. }
  95. return commitDataObj;
  96. }
  97. /**
  98. * Returns an array of commits since the last git tag
  99. * @return {ICommit[]}
  100. */
  101. function getCommits() {
  102. const format = Array.from(commitDataMap.values()).join(commitInnerSeparator) + commitOuterSeparator;
  103. const logs = String(execSync(`git --no-pager log ${START_FROM}..${END_TO} --pretty=format:"${format}" --reverse`));
  104. return logs
  105. .trim()
  106. .split(commitOuterSeparator)
  107. .filter(r => !!r.trim()) // Skip empty lines
  108. .map(parseCommit);
  109. }
  110. /**
  111. *
  112. * @param {ICommit} commit
  113. * @return {ICommitExtended}
  114. */
  115. function setCommitTypeAndScope(commit) {
  116. const matchRE = new RegExp(`^(?:(${supportedTypes.join('|')})(?:\\((\\S+)\\))?:)?(.*)`, 'i');
  117. let [, type, scope, clearSubject] = commit.subject.match(matchRE);
  118. /**
  119. * Additional rules for checking committees that do not comply with the convention, but for which it is possible to determine the type.
  120. */
  121. // Commits like `revert something`
  122. if (type === undefined && commit.subject.startsWith('revert')) {
  123. type = 'revert';
  124. }
  125. return {
  126. ...commit,
  127. type: (type || fallbackType).toLowerCase().trim(),
  128. scope: (scope || '').toLowerCase().trim(),
  129. subject: (clearSubject || commit.subject).trim(),
  130. };
  131. }
  132. class CommitGroup {
  133. constructor() {
  134. this.scopes = new Map;
  135. this.commits = [];
  136. }
  137. /**
  138. *
  139. * @param {ICommitExtended[]} array
  140. * @param {ICommitExtended} commit
  141. */
  142. static _pushOrMerge(array, commit) {
  143. const similarCommit = array.find(c => c.subject === commit.subject);
  144. if (similarCommit) {
  145. if (commit.abbreviated_commit !== undefined) {
  146. similarCommit.abbreviated_commit += `, ${commit.abbreviated_commit}`;
  147. }
  148. } else {
  149. array.push(commit);
  150. }
  151. }
  152. /**
  153. * @param {ICommitExtended} commit
  154. */
  155. push(commit) {
  156. if (!commit.scope) {
  157. CommitGroup._pushOrMerge(this.commits, commit);
  158. return;
  159. }
  160. const scope = this.scopes.get(commit.scope) || {commits: []};
  161. CommitGroup._pushOrMerge(scope.commits, commit);
  162. this.scopes.set(commit.scope, scope);
  163. }
  164. get isEmpty() {
  165. return this.commits.length === 0 && this.scopes.size === 0;
  166. }
  167. }
  168. /**
  169. * Groups all commits by type and scopes
  170. * @param {ICommit[]} commits
  171. * @returns {Map<string, CommitGroup>}
  172. */
  173. function getGroupedCommits(commits) {
  174. const parsedCommits = commits.map(setCommitTypeAndScope);
  175. const types = new Map(
  176. supportedTypes.map(id => ([id, new CommitGroup()])),
  177. );
  178. for (const parsedCommit of parsedCommits) {
  179. const typeId = parsedCommit.type;
  180. const type = types.get(typeId);
  181. type.push(parsedCommit);
  182. }
  183. return types;
  184. }
  185. /**
  186. * Return markdown list with commits
  187. * @param {ICommitExtended[]} commits
  188. * @param {string} pad
  189. * @returns {string}
  190. */
  191. function getCommitsList(commits, pad = '') {
  192. let changelog = '';
  193. for (const commit of commits) {
  194. changelog += `${pad}- ${commit.subject}.`;
  195. if (commit.abbreviated_commit !== undefined) {
  196. changelog += ` (${commit.abbreviated_commit})`;
  197. }
  198. changelog += '\r\n';
  199. if (commit.body === undefined) {
  200. continue;
  201. }
  202. const body = commit.body.replace('[skip ci]', '').trim();
  203. if (body !== '') {
  204. changelog += `${
  205. body
  206. .split(/\r*\n+/)
  207. .filter(s => !!s.trim())
  208. .map(s => `${pad} ${s}`)
  209. .join('\r\n')
  210. }${'\r\n'}`;
  211. }
  212. }
  213. return changelog;
  214. }
  215. function replaceHeader(str) {
  216. switch (str) {
  217. case 'feat':
  218. return 'New Features';
  219. case 'fix':
  220. return 'Bug Fixes';
  221. case 'docs':
  222. return 'Documentation Changes';
  223. case 'build':
  224. return 'Build System';
  225. case 'chore':
  226. return 'Chores';
  227. case 'ci':
  228. return 'Continuous Integration';
  229. case 'refactor':
  230. return 'Refactors';
  231. case 'style':
  232. return 'Code Style Changes';
  233. case 'test':
  234. return 'Tests';
  235. case 'perf':
  236. return 'Performance improvements';
  237. case 'revert':
  238. return 'Reverts';
  239. case 'deps':
  240. return 'Dependency updates';
  241. case 'other':
  242. return 'Other Changes';
  243. default:
  244. return str;
  245. }
  246. }
  247. /**
  248. * Return markdown string with changelog
  249. * @param {Map<string, CommitGroup>} groups
  250. */
  251. function getChangeLog(groups) {
  252. let changelog = '';
  253. for (const [typeId, group] of groups) {
  254. if (group.isEmpty) {
  255. continue;
  256. }
  257. changelog += `### ${replaceHeader(typeId)}${'\r\n'}`;
  258. for (const [scopeId, scope] of group.scopes) {
  259. if (scope.commits.length) {
  260. changelog += `- #### ${replaceHeader(scopeId)}${'\r\n'}`;
  261. changelog += getCommitsList(scope.commits, ' ');
  262. }
  263. }
  264. if (group.commits.length) {
  265. changelog += getCommitsList(group.commits);
  266. }
  267. changelog += ('\r\n' + '\r\n');
  268. }
  269. return changelog.trim();
  270. }
  271. function escapeData(s) {
  272. return String(s)
  273. .replace(/%/g, '%25')
  274. .replace(/\r/g, '%0D')
  275. .replace(/\n/g, '%0A');
  276. }
  277. try {
  278. const commits = getCommits();
  279. const grouped = getGroupedCommits(commits);
  280. const changelog = getChangeLog(grouped);
  281. process.stdout.write('::set-output name=release-note::' + escapeData(changelog) + '\r\n');
  282. // require('fs').writeFileSync('../CHANGELOG.md', changelog, {encoding: 'utf-8'})
  283. } catch (e) {
  284. console.error(e);
  285. process.exit(1);
  286. }