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

Annotation of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


Revision 54 - (hide 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 rakinar2 11 #!/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 rakinar2 15 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 rakinar2 11 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 rakinar2 15 } catch {
72 rakinar2 11 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 rakinar2 15 const [, hash] = lines[i++].split(" ");
88 rakinar2 11 const headerProps = {};
89    
90 rakinar2 15 while (
91     i < lines.length &&
92     lines[i].trim() !== "" &&
93     !/^\s/.test(lines[i])
94     ) {
95 rakinar2 11 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 rakinar2 15
111 rakinar2 11 messageLines.push(lineToPush);
112     }
113    
114     let mindex = messageLines.length - 1;
115     const footerProps = {};
116 rakinar2 15 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 rakinar2 11 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 rakinar2 15
134 rakinar2 11 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 rakinar2 15 } else {
143 rakinar2 11 footerProps[name] = value;
144     }
145    
146     messageLines.splice(mindex - 1, 1);
147     }
148    
149     const message = messageLines.join("\n");
150 rakinar2 15
151 rakinar2 11 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 rakinar2 15 createdAt: new Date(headerProps["date"]),
160 rakinar2 11 });
161     }
162    
163     return commits;
164     }
165    
166 rakinar2 54 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 rakinar2 11 function generateChangelog(commits) {
202     let output = "";
203 rakinar2 15
204 rakinar2 11 const grouppedCommitsByDate = {};
205 rakinar2 15
206 rakinar2 11 for (const commit of commits) {
207 rakinar2 15 const key = `${commit.createdAt
208     .getUTCDate()
209     .toString()
210 rakinar2 34 .padStart(2, 0)}-${(commit.createdAt.getUTCMonth() + 1)
211 rakinar2 15 .toString()
212     .padStart(2, "0")}-${commit.createdAt.getUTCFullYear()}::${
213     Array.isArray(commit.author)
214     ? commit.author.join(":")
215     : commit.author
216     }`;
217 rakinar2 11 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 rakinar2 15 output += `${date} ${
227     Array.isArray(commits[0].author)
228     ? commits[0].author.join(", ")
229     : commits[0].author
230     }\n\n`;
231 rakinar2 11
232     for (const commit of commits) {
233 rakinar2 15 output += ` ${commit.message.replaceAll(
234     "\n",
235     "\n "
236     )}\n\n`;
237 rakinar2 11 }
238     }
239 rakinar2 15
240 rakinar2 11 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 rakinar2 54 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 rakinar2 11 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 rakinar2 20 console.log("to <[email protected]>.");
261 rakinar2 11 }
262    
263     function printVersion() {
264     console.log("Copyright (C) 2024 OSN, Inc.");
265 rakinar2 15 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 rakinar2 11 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 rakinar2 15 alias: "h",
288 rakinar2 11 },
289     version: {
290     type: "boolean",
291 rakinar2 15 alias: "v",
292 rakinar2 11 },
293     output: {
294     type: "string",
295 rakinar2 15 short: "o",
296 rakinar2 11 },
297     "no-overwrite": {
298 rakinar2 15 type: "boolean",
299     },
300 rakinar2 54 format: {
301     type: "string",
302     short: "f"
303     }
304 rakinar2 15 },
305 rakinar2 11 }).values;
306 rakinar2 15 } catch (error) {
307 rakinar2 11 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 rakinar2 54 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 rakinar2 11 if (!options.output && options["no-overwrite"]) {
327 rakinar2 15 perror(
328     "option `--no-overwrite' without `--output` does not make sense"
329     );
330 rakinar2 11 exit(1);
331     }
332 rakinar2 15
333     if (
334     options.output &&
335     options["no-overwrite"] &&
336     existsSync(options.output)
337     ) {
338 rakinar2 11 perror(`${options.output}: cannot write changelog: File exists`);
339     exit(1);
340     }
341 rakinar2 15
342 rakinar2 11 const gitPath = checkForGit();
343     const gitLog = getGitLog(gitPath);
344     const commits = parseGitLog(gitLog);
345 rakinar2 15 const filteredCommits = commits.filter(
346     (commit) =>
347     !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
348     commit.message
349     )
350     );
351 rakinar2 54 const changelog = options.format === "markdown" ? generateMarkdownChangelog(filteredCommits) : generateChangelog(filteredCommits);
352 rakinar2 11
353 rakinar2 15 if (options.output) {
354 rakinar2 11 try {
355     await writeFile(options.output, changelog);
356 rakinar2 15 } catch (error) {
357     perror(
358     `${options.output}: failed to write changelog: ${
359     error?.message ?? error
360     }`
361     );
362 rakinar2 11 exit(1);
363     }
364    
365     print(`wrote generated changelog to ${options.output}`);
366 rakinar2 15 } else {
367 rakinar2 11 console.log(changelog);
368     }
369     }
370    
371     main();

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26