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

Annotation of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


Revision 34 - (hide annotations)
Thu Aug 8 18:23:21 2024 UTC (7 months, 3 weeks ago) by rakinar2
File size: 9074 byte(s)
fix(genchangelog): unexpected zero based month numbers
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     function generateChangelog(commits) {
167     let output = "";
168 rakinar2 15
169 rakinar2 11 const grouppedCommitsByDate = {};
170 rakinar2 15
171 rakinar2 11 for (const commit of commits) {
172 rakinar2 15 const key = `${commit.createdAt
173     .getUTCDate()
174     .toString()
175 rakinar2 34 .padStart(2, 0)}-${(commit.createdAt.getUTCMonth() + 1)
176 rakinar2 15 .toString()
177     .padStart(2, "0")}-${commit.createdAt.getUTCFullYear()}::${
178     Array.isArray(commit.author)
179     ? commit.author.join(":")
180     : commit.author
181     }`;
182 rakinar2 11 grouppedCommitsByDate[key] ??= [];
183     grouppedCommitsByDate[key].push(commit);
184     }
185    
186     for (const key in grouppedCommitsByDate) {
187     const separatorPosition = key.indexOf("::");
188     const date = key.slice(0, separatorPosition);
189     const commits = grouppedCommitsByDate[key];
190    
191 rakinar2 15 output += `${date} ${
192     Array.isArray(commits[0].author)
193     ? commits[0].author.join(", ")
194     : commits[0].author
195     }\n\n`;
196 rakinar2 11
197     for (const commit of commits) {
198 rakinar2 15 output += ` ${commit.message.replaceAll(
199     "\n",
200     "\n "
201     )}\n\n`;
202 rakinar2 11 }
203     }
204 rakinar2 15
205 rakinar2 11 return output.trim();
206     }
207    
208     function printHelp() {
209     console.log("Usage:");
210     console.log(` ${ME} [OPTION]...`);
211     console.log("Generate a formatted ChangeLog from Git commit logs.");
212     console.log();
213     console.log("Options:");
214     console.log(" -h, --help Show this help and exit.");
215     console.log(" -v, --version Show this script's version.");
216     console.log(" -o, --output=[FILE] Write the generated changelog to");
217     console.log(" a file instead of standard output.");
218     console.log(" --no-overwrite Disallow overwriting of the output");
219     console.log(" file if it exists already.");
220     console.log();
221     console.log("Send general inquiries, questions and bug reports");
222 rakinar2 20 console.log("to <[email protected]>.");
223 rakinar2 11 }
224    
225     function printVersion() {
226     console.log("Copyright (C) 2024 OSN, Inc.");
227 rakinar2 15 console.log(
228     "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>."
229     );
230     console.log(
231     "This is free software: you are free to change and redistribute it."
232     );
233 rakinar2 11 console.log("There is NO WARRANTY, to the extent permitted by law.");
234     console.log();
235     console.log("Written by Ar Rakin.");
236     }
237    
238     async function main() {
239     let options;
240    
241     try {
242     options = parseArgs({
243     argv: process.argv.slice(1),
244     strict: true,
245     allowPositionals: false,
246     options: {
247     help: {
248     type: "boolean",
249 rakinar2 15 alias: "h",
250 rakinar2 11 },
251     version: {
252     type: "boolean",
253 rakinar2 15 alias: "v",
254 rakinar2 11 },
255     output: {
256     type: "string",
257 rakinar2 15 short: "o",
258 rakinar2 11 },
259     "no-overwrite": {
260 rakinar2 15 type: "boolean",
261     },
262     },
263 rakinar2 11 }).values;
264 rakinar2 15 } catch (error) {
265 rakinar2 11 perror(`${error?.message ?? error}`);
266     exit(1);
267     }
268    
269     if (options.help) {
270     printHelp();
271     exit(0);
272     }
273    
274     if (options.version) {
275     printVersion();
276     exit(0);
277     }
278    
279     if (!options.output && options["no-overwrite"]) {
280 rakinar2 15 perror(
281     "option `--no-overwrite' without `--output` does not make sense"
282     );
283 rakinar2 11 exit(1);
284     }
285 rakinar2 15
286     if (
287     options.output &&
288     options["no-overwrite"] &&
289     existsSync(options.output)
290     ) {
291 rakinar2 11 perror(`${options.output}: cannot write changelog: File exists`);
292     exit(1);
293     }
294 rakinar2 15
295 rakinar2 11 const gitPath = checkForGit();
296     const gitLog = getGitLog(gitPath);
297     const commits = parseGitLog(gitLog);
298 rakinar2 15 const filteredCommits = commits.filter(
299     (commit) =>
300     !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
301     commit.message
302     )
303     );
304     const changelog = generateChangelog(filteredCommits);
305 rakinar2 11
306 rakinar2 15 if (options.output) {
307 rakinar2 11 try {
308     await writeFile(options.output, changelog);
309 rakinar2 15 } catch (error) {
310     perror(
311     `${options.output}: failed to write changelog: ${
312     error?.message ?? error
313     }`
314     );
315 rakinar2 11 exit(1);
316     }
317    
318     print(`wrote generated changelog to ${options.output}`);
319 rakinar2 15 } else {
320 rakinar2 11 console.log(changelog);
321     }
322     }
323    
324     main();

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26