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

Annotation of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


Revision 61 - (hide annotations)
Tue Sep 10 14:47:37 2024 UTC (6 months, 3 weeks ago) by rakinar2
File size: 14314 byte(s)
style: update license comments

1 rakinar2 11 #!/usr/bin/env node
2    
3     /**
4 rakinar2 61 * Copyright (C) 2024 OSN Developers.
5 rakinar2 11 *
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 rakinar2 56 const { execSync, exec, spawn } = require("child_process");
26 rakinar2 11 const { writeFile } = require("fs/promises");
27     const { parseArgs } = require("util");
28 rakinar2 55 const crypto = require("crypto");
29 rakinar2 11
30     const ME = path.basename(process.argv[1]);
31 rakinar2 55 const GIT_COMMIT_BOUNDARY = crypto.randomBytes(64).toString("hex");
32     const GIT_SPACE_BOUNDARY = crypto.randomBytes(64).toString("hex");
33 rakinar2 11
34     function print(...args) {
35     console.log(`${ME}: ${args.join(" ")}`);
36     }
37    
38     function perror(...args) {
39     console.error(`\x1b[0m${ME}: error: ${args.join(" ")}`);
40     }
41    
42     function findInPath(executable) {
43 rakinar2 15 for (const segment of process.env.PATH?.split(
44 rakinar2 55 process.platform === "win32" ? ";" : ":",
45 rakinar2 15 ) ?? []) {
46     const executablePath = path.join(
47     segment,
48 rakinar2 55 executable + (process.platform === "win32" ? ".exe" : ""),
49 rakinar2 15 );
50    
51 rakinar2 11 if (existsSync(executablePath)) {
52     return executablePath;
53     }
54     }
55    
56     return null;
57     }
58    
59     function checkForGit() {
60     const gitPath = findInPath("git");
61    
62     if (!gitPath) {
63     perror("could not find git in $PATH");
64     perror("please make sure git is installed and available in $PATH");
65     exit(1);
66     }
67    
68     return gitPath;
69     }
70    
71 rakinar2 56 async function getGitLog(gitPath) {
72 rakinar2 11 try {
73 rakinar2 56 let output = "";
74    
75     const child = spawn(
76     gitPath,
77     [
78     "--no-pager",
79     "log",
80     `--pretty=format:%h %H %an${GIT_SPACE_BOUNDARY} %ae %ad${GIT_SPACE_BOUNDARY} %B${GIT_COMMIT_BOUNDARY}`,
81     ],
82     { encoding: "utf8", stdio: "pipe" },
83 rakinar2 55 );
84 rakinar2 56
85     child.stdout.on("data", (data) => {
86     output += data;
87     });
88    
89     child.stderr.on("data", (data) => {
90     console.error(data);
91     });
92    
93     await new Promise((resolve) => {
94     child.on("close", (code) => {
95     if (code !== 0) {
96     perror("command `git log' failed with exit code " + code);
97     exit(1);
98     } else {
99     resolve();
100     }
101     });
102     });
103    
104     return output;
105     } catch (error) {
106     console.error(error);
107 rakinar2 55 perror("command `git log' failed");
108 rakinar2 11 exit(1);
109     }
110     }
111    
112 rakinar2 55 function strUntil(str, boundary) {
113     let output = "";
114     let i = 0;
115    
116     for (i = 0; i < str.length; i++) {
117     if (str[i] === boundary[0]) {
118     let boundaryIndex = 0;
119     let previousI = i;
120    
121     while (
122     boundaryIndex < boundary.length &&
123     i < str.length &&
124     str[i] === boundary[boundaryIndex]
125     ) {
126     i++;
127     boundaryIndex++;
128     }
129    
130     if (boundaryIndex === boundary.length) {
131     return { output, size: i };
132     }
133    
134     i = previousI;
135     }
136    
137     output += str[i];
138     }
139    
140     return { output, size: i };
141     }
142    
143 rakinar2 11 function parseGitLog(gitLog) {
144     const commits = [];
145    
146 rakinar2 55 let i = 0;
147 rakinar2 11
148 rakinar2 55 while (i < gitLog.length) {
149     const { output, size } = strUntil(gitLog.slice(i), GIT_COMMIT_BOUNDARY);
150     i += size;
151 rakinar2 11
152 rakinar2 55 let outputIndex = 0;
153 rakinar2 11
154 rakinar2 55 const shortIdSpaceIndex = output.indexOf(" ");
155     const idSpaceIndex = output.indexOf(" ", shortIdSpaceIndex + 1);
156     const shortId = output.slice(outputIndex, shortIdSpaceIndex);
157     const id = output.slice(shortIdSpaceIndex + 1, idSpaceIndex);
158 rakinar2 11
159 rakinar2 55 outputIndex += shortId.length + id.length + 2;
160 rakinar2 11
161 rakinar2 55 const { output: authorName, size: authorNameSize } = strUntil(
162     output.slice(outputIndex),
163     GIT_SPACE_BOUNDARY,
164     );
165 rakinar2 15
166 rakinar2 55 outputIndex += authorNameSize + 1;
167 rakinar2 11
168 rakinar2 55 const authorEmailSpaceIndex = output.indexOf(" ", outputIndex + 1);
169     const authorEmail = output.slice(outputIndex, authorEmailSpaceIndex);
170    
171     outputIndex += authorEmail.length + 1;
172    
173     const { output: date, size: dateSize } = strUntil(
174     output.slice(outputIndex),
175     GIT_SPACE_BOUNDARY,
176     );
177    
178     outputIndex += dateSize + 1;
179    
180     const message = output.slice(outputIndex);
181     const newlineIndex = message.indexOf("\n");
182     const subject = message.slice(
183     0,
184     newlineIndex === -1 ? undefined : newlineIndex,
185     );
186     const body = newlineIndex === -1 ? "" : message.slice(newlineIndex + 1);
187    
188 rakinar2 15 const validFooterProps = [
189     "signed-off-by",
190     "co-authored-by",
191     "on-behalf-of",
192     ];
193 rakinar2 55 const footerProps = {};
194     const coAuthors = [];
195 rakinar2 15
196 rakinar2 55 for (const line of body.split("\n")) {
197     const colonIndex = line.indexOf(":");
198     const key = line.slice(0, colonIndex).trim().toLowerCase();
199     const value = line.slice(colonIndex + 1).trim();
200 rakinar2 11
201 rakinar2 55 if (validFooterProps.includes(key)) {
202     footerProps[key] = value;
203 rakinar2 11 }
204 rakinar2 15
205 rakinar2 55 if (key === "co-authored-by") {
206     const name = value.slice(0, value.lastIndexOf(" ")).trim();
207 rakinar2 11
208 rakinar2 55 coAuthors.push({
209     name,
210     email: value
211     .slice(name.length)
212     .trim()
213     .replace(/^<|>$/g, ""),
214     });
215 rakinar2 11 }
216     }
217    
218     commits.push({
219 rakinar2 55 shortId,
220     id,
221     author: {
222     name: authorName,
223     email: authorEmail,
224     },
225     date: new Date(date),
226     subject,
227     body,
228     footerProps,
229 rakinar2 11 message,
230 rakinar2 55 coAuthors,
231     authors: [
232     {
233     name: authorName,
234     email: authorEmail,
235     },
236     ...coAuthors,
237     ],
238 rakinar2 11 });
239     }
240    
241     return commits;
242     }
243    
244 rakinar2 55 function escapeMarkdown(str) {
245     return str.replace(/([_*~`])/g, "\\$1");
246     }
247    
248 rakinar2 54 function generateMarkdownChangelog(commits) {
249     let output = "# Changelog\n\n";
250    
251     const grouppedCommitsByDate = {};
252    
253     for (const commit of commits) {
254 rakinar2 55 const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
255     commit.date.getUTCMonth() + 1
256     )
257 rakinar2 54 .toString()
258 rakinar2 55 .padStart(2, "0")}-${commit.date.getUTCFullYear()}::${commit.authors
259     .map((a) => `[${escapeMarkdown(a.name)}](mailto:${a.email})`)
260     .join(":")}`;
261 rakinar2 54 grouppedCommitsByDate[key] ??= [];
262     grouppedCommitsByDate[key].push(commit);
263     }
264    
265     for (const key in grouppedCommitsByDate) {
266     const [date, author] = key.split("::");
267 rakinar2 55 output += `### ${date} - [${author}]\n\n`;
268 rakinar2 54
269     for (const commit of grouppedCommitsByDate[key]) {
270 rakinar2 55 const conventionalCommitType = commit.subject.match(
271     /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|release|deps)(\(.+\))?:/,
272     );
273     let needsBreakingChangeWarning = conventionalCommitType?.[0]
274     ?.trim()
275     ?.endsWith("!:");
276    
277     if (conventionalCommitType) {
278     output += ` * **${conventionalCommitType[0]}**${commit.subject.slice(conventionalCommitType[0].length)}`;
279     } else {
280     output += ` * ${commit.subject}`;
281     }
282    
283     if (commit.body) {
284     output += " \n";
285    
286     for (const line of commit.body.split("\n")) {
287     const colonIndex = line.indexOf(":");
288    
289     if (colonIndex !== -1) {
290     const name = line.slice(0, colonIndex);
291     const value = line.slice(colonIndex + 1);
292     output += ` **${name}:** ${value} \n`;
293    
294     if (name === "BREAKING CHANGE") {
295     needsBreakingChangeWarning = false;
296     }
297     } else {
298     output += ` ${line} \n`;
299     }
300     }
301     }
302    
303     if (needsBreakingChangeWarning) {
304     output += " **This is a breaking change.** \n";
305     output += " \n";
306     }
307    
308     if (!commit.body && !needsBreakingChangeWarning) {
309     output += "\n";
310     }
311 rakinar2 54 }
312    
313     output += "\n";
314     }
315    
316 rakinar2 55 return output.trimEnd();
317 rakinar2 54 }
318    
319 rakinar2 11 function generateChangelog(commits) {
320     let output = "";
321 rakinar2 15
322 rakinar2 11 const grouppedCommitsByDate = {};
323 rakinar2 15
324 rakinar2 11 for (const commit of commits) {
325 rakinar2 55 const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
326     commit.date.getUTCMonth() + 1
327     )
328 rakinar2 15 .toString()
329 rakinar2 55 .padStart(
330     2,
331     "0",
332     )}-${commit.date.getUTCFullYear()}::${commit.authors.map((a) => `${a.name} <${a.email}>`).join(":")}`;
333 rakinar2 11 grouppedCommitsByDate[key] ??= [];
334     grouppedCommitsByDate[key].push(commit);
335     }
336    
337     for (const key in grouppedCommitsByDate) {
338     const separatorPosition = key.indexOf("::");
339     const date = key.slice(0, separatorPosition);
340     const commits = grouppedCommitsByDate[key];
341    
342 rakinar2 55 output += `${date} ${commits[0].authors
343     .map((a) => `${a.name} <${a.email}>`)
344     .join(", ")}\n\n`;
345 rakinar2 11
346     for (const commit of commits) {
347 rakinar2 55 output += ` [*] ${commit.subject}\n${commit.body
348     .split("\n")
349     .map((part) => ` ${part}`)
350     .join("\n")}\n\n`;
351 rakinar2 11 }
352     }
353 rakinar2 15
354 rakinar2 11 return output.trim();
355     }
356    
357     function printHelp() {
358     console.log("Usage:");
359     console.log(` ${ME} [OPTION]...`);
360     console.log("Generate a formatted ChangeLog from Git commit logs.");
361     console.log();
362     console.log("Options:");
363     console.log(" -h, --help Show this help and exit.");
364     console.log(" -v, --version Show this script's version.");
365 rakinar2 55 console.log(" -f, --format Set the changelog format.");
366 rakinar2 54 console.log(" Supported formats are: plain,");
367     console.log(" markdown.");
368     console.log(" -o, --output=<FILE> Write the generated changelog to");
369 rakinar2 11 console.log(" a file instead of standard output.");
370     console.log(" --no-overwrite Disallow overwriting of the output");
371     console.log(" file if it exists already.");
372     console.log();
373     console.log("Send general inquiries, questions and bug reports");
374 rakinar2 20 console.log("to <[email protected]>.");
375 rakinar2 11 }
376    
377     function printVersion() {
378     console.log("Copyright (C) 2024 OSN, Inc.");
379 rakinar2 15 console.log(
380 rakinar2 55 "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.",
381 rakinar2 15 );
382     console.log(
383 rakinar2 55 "This is free software: you are free to change and redistribute it.",
384 rakinar2 15 );
385 rakinar2 11 console.log("There is NO WARRANTY, to the extent permitted by law.");
386     console.log();
387     console.log("Written by Ar Rakin.");
388     }
389    
390     async function main() {
391     let options;
392    
393     try {
394     options = parseArgs({
395     argv: process.argv.slice(1),
396     strict: true,
397     allowPositionals: false,
398     options: {
399     help: {
400     type: "boolean",
401 rakinar2 15 alias: "h",
402 rakinar2 11 },
403     version: {
404     type: "boolean",
405 rakinar2 15 alias: "v",
406 rakinar2 11 },
407     output: {
408     type: "string",
409 rakinar2 15 short: "o",
410 rakinar2 11 },
411     "no-overwrite": {
412 rakinar2 15 type: "boolean",
413     },
414 rakinar2 54 format: {
415     type: "string",
416 rakinar2 55 short: "f",
417     },
418 rakinar2 15 },
419 rakinar2 11 }).values;
420 rakinar2 15 } catch (error) {
421 rakinar2 11 perror(`${error?.message ?? error}`);
422     exit(1);
423     }
424    
425     if (options.help) {
426     printHelp();
427     exit(0);
428     }
429    
430     if (options.version) {
431     printVersion();
432     exit(0);
433     }
434    
435 rakinar2 54 if (options.format && !["markdown", "plain"].includes(options.format)) {
436 rakinar2 55 perror(
437     "option `--format` or `-f` only accepts one of the following: markdown, plain",
438     );
439 rakinar2 54 exit(1);
440     }
441    
442 rakinar2 11 if (!options.output && options["no-overwrite"]) {
443 rakinar2 15 perror(
444 rakinar2 55 "option `--no-overwrite' without `--output` does not make sense",
445 rakinar2 15 );
446 rakinar2 11 exit(1);
447     }
448 rakinar2 15
449     if (
450     options.output &&
451     options["no-overwrite"] &&
452     existsSync(options.output)
453     ) {
454 rakinar2 11 perror(`${options.output}: cannot write changelog: File exists`);
455     exit(1);
456     }
457 rakinar2 15
458 rakinar2 11 const gitPath = checkForGit();
459 rakinar2 56 const gitLog = await getGitLog(gitPath);
460 rakinar2 11 const commits = parseGitLog(gitLog);
461 rakinar2 15 const filteredCommits = commits.filter(
462     (commit) =>
463     !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
464 rakinar2 55 commit.message,
465     ),
466 rakinar2 15 );
467 rakinar2 55 const changelog =
468     options.format === "markdown"
469     ? generateMarkdownChangelog(filteredCommits)
470     : generateChangelog(filteredCommits);
471 rakinar2 11
472 rakinar2 15 if (options.output) {
473 rakinar2 11 try {
474     await writeFile(options.output, changelog);
475 rakinar2 15 } catch (error) {
476     perror(
477     `${options.output}: failed to write changelog: ${
478     error?.message ?? error
479 rakinar2 55 }`,
480 rakinar2 15 );
481 rakinar2 11 exit(1);
482     }
483    
484     print(`wrote generated changelog to ${options.output}`);
485 rakinar2 15 } else {
486 rakinar2 11 console.log(changelog);
487     }
488     }
489    
490     main();

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26