/[osn-commons]/trunk/git/genchangelog
ViewVC logotype

Contents of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


Revision 54 - (show annotations)
Thu Aug 29 06:17:33 2024 UTC (7 months ago) by rakinar2
File size: 10727 byte(s)
feat(git:genchangelog): now supporting `-f` option

The `-f` or `--format` option can change the output format
of the script. It currently accepts "plain" and "markdown"
as values and defaults to "plain".

1 #!/usr/bin/env node
2
3 /**
4 * Copyright (C) 2024 OSN, Inc.
5 *
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 "use strict";
21
22 const path = require("path");
23 const { existsSync } = require("fs");
24 const { exit } = require("process");
25 const { execSync } = require("child_process");
26 const { writeFile } = require("fs/promises");
27 const { parseArgs } = require("util");
28
29 const ME = path.basename(process.argv[1]);
30
31 function print(...args) {
32 console.log(`${ME}: ${args.join(" ")}`);
33 }
34
35 function perror(...args) {
36 console.error(`\x1b[0m${ME}: error: ${args.join(" ")}`);
37 }
38
39 function findInPath(executable) {
40 for (const segment of process.env.PATH?.split(
41 process.platform === "win32" ? ";" : ":"
42 ) ?? []) {
43 const executablePath = path.join(
44 segment,
45 executable + (process.platform === "win32" ? ".exe" : "")
46 );
47
48 if (existsSync(executablePath)) {
49 return executablePath;
50 }
51 }
52
53 return null;
54 }
55
56 function checkForGit() {
57 const gitPath = findInPath("git");
58
59 if (!gitPath) {
60 perror("could not find git in $PATH");
61 perror("please make sure git is installed and available in $PATH");
62 exit(1);
63 }
64
65 return gitPath;
66 }
67
68 function getGitLog(gitPath) {
69 try {
70 return execSync(gitPath + " --no-pager log", { encoding: "utf8" });
71 } catch {
72 perror("command `git --no-pager log' failed");
73 exit(1);
74 }
75 }
76
77 function parseGitLog(gitLog) {
78 const lines = gitLog.split("\n");
79 const commits = [];
80
81 for (let i = 0; i < lines.length; ) {
82 if (!lines[i].startsWith("commit")) {
83 i++;
84 continue;
85 }
86
87 const [, hash] = lines[i++].split(" ");
88 const headerProps = {};
89
90 while (
91 i < lines.length &&
92 lines[i].trim() !== "" &&
93 !/^\s/.test(lines[i])
94 ) {
95 const colonIndex = lines[i].indexOf(":");
96 const name = lines[i].slice(0, colonIndex).toLowerCase();
97 const value = lines[i].slice(colonIndex + 1).trim();
98 headerProps[name] = value;
99 i++;
100 }
101
102 const messageLines = [];
103
104 while (i < lines.length && !lines[i].startsWith("commit")) {
105 const lineToPush = lines[i++].replace(/^ /, "");
106
107 if (!lineToPush) {
108 continue;
109 }
110
111 messageLines.push(lineToPush);
112 }
113
114 let mindex = messageLines.length - 1;
115 const footerProps = {};
116 const validFooterProps = [
117 "signed-off-by",
118 "co-authored-by",
119 "on-behalf-of",
120 ];
121
122 while (
123 mindex >= 1 &&
124 /^[A-Za-z0-9-]+: /.test(messageLines.at(mindex))
125 ) {
126 const messageLine = messageLines[mindex--];
127 const colonIndex = messageLine.indexOf(":");
128 const name = messageLine.slice(0, colonIndex).toLowerCase();
129
130 if (!validFooterProps.includes(name)) {
131 continue;
132 }
133
134 const value = messageLine.slice(colonIndex + 1).trim();
135
136 if (name in footerProps && !Array.isArray(footerProps[name])) {
137 footerProps[name] = [footerProps[name]];
138 }
139
140 if (Array.isArray(footerProps[name])) {
141 footerProps[name].push(value);
142 } else {
143 footerProps[name] = value;
144 }
145
146 messageLines.splice(mindex - 1, 1);
147 }
148
149 const message = messageLines.join("\n");
150
151 commits.push({
152 hash,
153 message,
154 headerProps,
155 footerProps,
156 signedOffBy: footerProps["signed-off-by"],
157 onBehalfOf: footerProps["on-behalf-of"],
158 author: headerProps["author"],
159 createdAt: new Date(headerProps["date"]),
160 });
161 }
162
163 return commits;
164 }
165
166 function generateMarkdownChangelog(commits) {
167 let output = "# Changelog\n\n";
168
169 const grouppedCommitsByDate = {};
170
171 for (const commit of commits) {
172 const key = `${commit.createdAt
173 .getUTCDate()
174 .toString()
175 .padStart(2, 0)}-${(commit.createdAt.getUTCMonth() + 1)
176 .toString()
177 .padStart(2, "0")}-${commit.createdAt.getUTCFullYear()}::${
178 Array.isArray(commit.author)
179 ? commit.author.join(":")
180 : commit.author
181 }`;
182 grouppedCommitsByDate[key] ??= [];
183 grouppedCommitsByDate[key].push(commit);
184 }
185
186 for (const key in grouppedCommitsByDate) {
187 const [date, author] = key.split("::");
188 output += `### ${date} [${author}]\n\n`;
189
190 for (const commit of grouppedCommitsByDate[key]) {
191 const newLineIndex = commit.message.indexOf("\n");
192 output += `* ${commit.message.slice(0, newLineIndex === -1 ? undefined : newLineIndex)}\n`;
193 }
194
195 output += "\n";
196 }
197
198 return output;
199 }
200
201 function generateChangelog(commits) {
202 let output = "";
203
204 const grouppedCommitsByDate = {};
205
206 for (const commit of commits) {
207 const key = `${commit.createdAt
208 .getUTCDate()
209 .toString()
210 .padStart(2, 0)}-${(commit.createdAt.getUTCMonth() + 1)
211 .toString()
212 .padStart(2, "0")}-${commit.createdAt.getUTCFullYear()}::${
213 Array.isArray(commit.author)
214 ? commit.author.join(":")
215 : commit.author
216 }`;
217 grouppedCommitsByDate[key] ??= [];
218 grouppedCommitsByDate[key].push(commit);
219 }
220
221 for (const key in grouppedCommitsByDate) {
222 const separatorPosition = key.indexOf("::");
223 const date = key.slice(0, separatorPosition);
224 const commits = grouppedCommitsByDate[key];
225
226 output += `${date} ${
227 Array.isArray(commits[0].author)
228 ? commits[0].author.join(", ")
229 : commits[0].author
230 }\n\n`;
231
232 for (const commit of commits) {
233 output += ` ${commit.message.replaceAll(
234 "\n",
235 "\n "
236 )}\n\n`;
237 }
238 }
239
240 return output.trim();
241 }
242
243 function printHelp() {
244 console.log("Usage:");
245 console.log(` ${ME} [OPTION]...`);
246 console.log("Generate a formatted ChangeLog from Git commit logs.");
247 console.log();
248 console.log("Options:");
249 console.log(" -h, --help Show this help and exit.");
250 console.log(" -v, --version Show this script's version.");
251 console.log(" -f, --format= Set the changelog format.");
252 console.log(" Supported formats are: plain,");
253 console.log(" markdown.");
254 console.log(" -o, --output=<FILE> Write the generated changelog to");
255 console.log(" a file instead of standard output.");
256 console.log(" --no-overwrite Disallow overwriting of the output");
257 console.log(" file if it exists already.");
258 console.log();
259 console.log("Send general inquiries, questions and bug reports");
260 console.log("to <[email protected]>.");
261 }
262
263 function printVersion() {
264 console.log("Copyright (C) 2024 OSN, Inc.");
265 console.log(
266 "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>."
267 );
268 console.log(
269 "This is free software: you are free to change and redistribute it."
270 );
271 console.log("There is NO WARRANTY, to the extent permitted by law.");
272 console.log();
273 console.log("Written by Ar Rakin.");
274 }
275
276 async function main() {
277 let options;
278
279 try {
280 options = parseArgs({
281 argv: process.argv.slice(1),
282 strict: true,
283 allowPositionals: false,
284 options: {
285 help: {
286 type: "boolean",
287 alias: "h",
288 },
289 version: {
290 type: "boolean",
291 alias: "v",
292 },
293 output: {
294 type: "string",
295 short: "o",
296 },
297 "no-overwrite": {
298 type: "boolean",
299 },
300 format: {
301 type: "string",
302 short: "f"
303 }
304 },
305 }).values;
306 } catch (error) {
307 perror(`${error?.message ?? error}`);
308 exit(1);
309 }
310
311 if (options.help) {
312 printHelp();
313 exit(0);
314 }
315
316 if (options.version) {
317 printVersion();
318 exit(0);
319 }
320
321 if (options.format && !["markdown", "plain"].includes(options.format)) {
322 perror("option `--format` or `-f` only accepts one of the following: markdown, plain");
323 exit(1);
324 }
325
326 if (!options.output && options["no-overwrite"]) {
327 perror(
328 "option `--no-overwrite' without `--output` does not make sense"
329 );
330 exit(1);
331 }
332
333 if (
334 options.output &&
335 options["no-overwrite"] &&
336 existsSync(options.output)
337 ) {
338 perror(`${options.output}: cannot write changelog: File exists`);
339 exit(1);
340 }
341
342 const gitPath = checkForGit();
343 const gitLog = getGitLog(gitPath);
344 const commits = parseGitLog(gitLog);
345 const filteredCommits = commits.filter(
346 (commit) =>
347 !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
348 commit.message
349 )
350 );
351 const changelog = options.format === "markdown" ? generateMarkdownChangelog(filteredCommits) : generateChangelog(filteredCommits);
352
353 if (options.output) {
354 try {
355 await writeFile(options.output, changelog);
356 } catch (error) {
357 perror(
358 `${options.output}: failed to write changelog: ${
359 error?.message ?? error
360 }`
361 );
362 exit(1);
363 }
364
365 print(`wrote generated changelog to ${options.output}`);
366 } else {
367 console.log(changelog);
368 }
369 }
370
371 main();

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26