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

Annotation of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


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

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26