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

Diff of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log | View Patch Patch

revision 20 by rakinar2, Sun Aug 4 13:06:22 2024 UTC revision 55 by rakinar2, Fri Aug 30 11:42:33 2024 UTC
# Line 25  const { exit } = require("process"); Line 25  const { exit } = require("process");
25  const { execSync } = require("child_process");  const { execSync } = require("child_process");
26  const { writeFile } = require("fs/promises");  const { writeFile } = require("fs/promises");
27  const { parseArgs } = require("util");  const { parseArgs } = require("util");
28    const crypto = require("crypto");
29    
30  const ME = path.basename(process.argv[1]);  const ME = path.basename(process.argv[1]);
31    const GIT_COMMIT_BOUNDARY = crypto.randomBytes(64).toString("hex");
32    const GIT_SPACE_BOUNDARY = crypto.randomBytes(64).toString("hex");
33    
34  function print(...args) {  function print(...args) {
35      console.log(`${ME}: ${args.join(" ")}`);      console.log(`${ME}: ${args.join(" ")}`);
# Line 38  function perror(...args) { Line 41  function perror(...args) {
41    
42  function findInPath(executable) {  function findInPath(executable) {
43      for (const segment of process.env.PATH?.split(      for (const segment of process.env.PATH?.split(
44          process.platform === "win32" ? ";" : ":"          process.platform === "win32" ? ";" : ":",
45      ) ?? []) {      ) ?? []) {
46          const executablePath = path.join(          const executablePath = path.join(
47              segment,              segment,
48              executable + (process.platform === "win32" ? ".exe" : "")              executable + (process.platform === "win32" ? ".exe" : ""),
49          );          );
50    
51          if (existsSync(executablePath)) {          if (existsSync(executablePath)) {
# Line 67  function checkForGit() { Line 70  function checkForGit() {
70    
71  function getGitLog(gitPath) {  function getGitLog(gitPath) {
72      try {      try {
73          return execSync(gitPath + " --no-pager log", { encoding: "utf8" });          return execSync(
74                gitPath +
75                    ` --no-pager log --pretty=format:"%h %H %an${GIT_SPACE_BOUNDARY} %ae %ad${GIT_SPACE_BOUNDARY} %B${GIT_COMMIT_BOUNDARY}"`,
76                { encoding: "utf8" },
77            );
78      } catch {      } catch {
79          perror("command `git --no-pager log' failed");          perror("command `git log' failed");
80          exit(1);          exit(1);
81      }      }
82  }  }
83    
84    function strUntil(str, boundary) {
85        let output = "";
86        let i = 0;
87    
88        for (i = 0; i < str.length; i++) {
89            if (str[i] === boundary[0]) {
90                let boundaryIndex = 0;
91                let previousI = i;
92    
93                while (
94                    boundaryIndex < boundary.length &&
95                    i < str.length &&
96                    str[i] === boundary[boundaryIndex]
97                ) {
98                    i++;
99                    boundaryIndex++;
100                }
101    
102                if (boundaryIndex === boundary.length) {
103                    return { output, size: i };
104                }
105    
106                i = previousI;
107            }
108    
109            output += str[i];
110        }
111    
112        return { output, size: i };
113    }
114    
115  function parseGitLog(gitLog) {  function parseGitLog(gitLog) {
     const lines = gitLog.split("\n");  
116      const commits = [];      const commits = [];
117    
118      for (let i = 0; i < lines.length; ) {      let i = 0;
         if (!lines[i].startsWith("commit")) {  
             i++;  
             continue;  
         }  
119    
120          const [, hash] = lines[i++].split(" ");      while (i < gitLog.length) {
121          const headerProps = {};          const { output, size } = strUntil(gitLog.slice(i), GIT_COMMIT_BOUNDARY);
122            i += size;
123    
124          while (          let outputIndex = 0;
             i < lines.length &&  
             lines[i].trim() !== "" &&  
             !/^\s/.test(lines[i])  
         ) {  
             const colonIndex = lines[i].indexOf(":");  
             const name = lines[i].slice(0, colonIndex).toLowerCase();  
             const value = lines[i].slice(colonIndex + 1).trim();  
             headerProps[name] = value;  
             i++;  
         }  
125    
126          const messageLines = [];          const shortIdSpaceIndex = output.indexOf(" ");
127            const idSpaceIndex = output.indexOf(" ", shortIdSpaceIndex + 1);
128            const shortId = output.slice(outputIndex, shortIdSpaceIndex);
129            const id = output.slice(shortIdSpaceIndex + 1, idSpaceIndex);
130    
131          while (i < lines.length && !lines[i].startsWith("commit")) {          outputIndex += shortId.length + id.length + 2;
             const lineToPush = lines[i++].replace(/^    /, "");  
132    
133              if (!lineToPush) {          const { output: authorName, size: authorNameSize } = strUntil(
134                  continue;              output.slice(outputIndex),
135              }              GIT_SPACE_BOUNDARY,
136            );
137    
138              messageLines.push(lineToPush);          outputIndex += authorNameSize + 1;
139          }  
140            const authorEmailSpaceIndex = output.indexOf(" ", outputIndex + 1);
141            const authorEmail = output.slice(outputIndex, authorEmailSpaceIndex);
142    
143            outputIndex += authorEmail.length + 1;
144    
145            const { output: date, size: dateSize } = strUntil(
146                output.slice(outputIndex),
147                GIT_SPACE_BOUNDARY,
148            );
149    
150            outputIndex += dateSize + 1;
151    
152            const message = output.slice(outputIndex);
153            const newlineIndex = message.indexOf("\n");
154            const subject = message.slice(
155                0,
156                newlineIndex === -1 ? undefined : newlineIndex,
157            );
158            const body = newlineIndex === -1 ? "" : message.slice(newlineIndex + 1);
159    
         let mindex = messageLines.length - 1;  
         const footerProps = {};  
160          const validFooterProps = [          const validFooterProps = [
161              "signed-off-by",              "signed-off-by",
162              "co-authored-by",              "co-authored-by",
163              "on-behalf-of",              "on-behalf-of",
164          ];          ];
165            const footerProps = {};
166            const coAuthors = [];
167    
168          while (          for (const line of body.split("\n")) {
169              mindex >= 1 &&              const colonIndex = line.indexOf(":");
170              /^[A-Za-z0-9-]+: /.test(messageLines.at(mindex))              const key = line.slice(0, colonIndex).trim().toLowerCase();
171          ) {              const value = line.slice(colonIndex + 1).trim();
             const messageLine = messageLines[mindex--];  
             const colonIndex = messageLine.indexOf(":");  
             const name = messageLine.slice(0, colonIndex).toLowerCase();  
172    
173              if (!validFooterProps.includes(name)) {              if (validFooterProps.includes(key)) {
174                  continue;                  footerProps[key] = value;
175              }              }
176    
177              const value = messageLine.slice(colonIndex + 1).trim();              if (key === "co-authored-by") {
178                    const name = value.slice(0, value.lastIndexOf(" ")).trim();
179    
180              if (name in footerProps && !Array.isArray(footerProps[name])) {                  coAuthors.push({
181                  footerProps[name] = [footerProps[name]];                      name,
182                        email: value
183                            .slice(name.length)
184                            .trim()
185                            .replace(/^<|>$/g, ""),
186                    });
187              }              }
   
             if (Array.isArray(footerProps[name])) {  
                 footerProps[name].push(value);  
             } else {  
                 footerProps[name] = value;  
             }  
   
             messageLines.splice(mindex - 1, 1);  
188          }          }
189    
         const message = messageLines.join("\n");  
   
190          commits.push({          commits.push({
191              hash,              shortId,
192              message,              id,
193              headerProps,              author: {
194                    name: authorName,
195                    email: authorEmail,
196                },
197                date: new Date(date),
198                subject,
199                body,
200              footerProps,              footerProps,
201              signedOffBy: footerProps["signed-off-by"],              message,
202              onBehalfOf: footerProps["on-behalf-of"],              coAuthors,
203              author: headerProps["author"],              authors: [
204              createdAt: new Date(headerProps["date"]),                  {
205                        name: authorName,
206                        email: authorEmail,
207                    },
208                    ...coAuthors,
209                ],
210          });          });
211      }      }
212    
213      return commits;      return commits;
214  }  }
215    
216    function escapeMarkdown(str) {
217        return str.replace(/([_*~`])/g, "\\$1");
218    }
219    
220    function generateMarkdownChangelog(commits) {
221        let output = "# Changelog\n\n";
222    
223        const grouppedCommitsByDate = {};
224    
225        for (const commit of commits) {
226            const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
227                commit.date.getUTCMonth() + 1
228            )
229                .toString()
230                .padStart(2, "0")}-${commit.date.getUTCFullYear()}::${commit.authors
231                .map((a) => `[${escapeMarkdown(a.name)}](mailto:${a.email})`)
232                .join(":")}`;
233            grouppedCommitsByDate[key] ??= [];
234            grouppedCommitsByDate[key].push(commit);
235        }
236    
237        for (const key in grouppedCommitsByDate) {
238            const [date, author] = key.split("::");
239            output += `### ${date} - [${author}]\n\n`;
240    
241            for (const commit of grouppedCommitsByDate[key]) {
242                const conventionalCommitType = commit.subject.match(
243                    /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|release|deps)(\(.+\))?:/,
244                );
245                let needsBreakingChangeWarning = conventionalCommitType?.[0]
246                    ?.trim()
247                    ?.endsWith("!:");
248    
249                if (conventionalCommitType) {
250                    output += `  * **${conventionalCommitType[0]}**${commit.subject.slice(conventionalCommitType[0].length)}`;
251                } else {
252                    output += `  * ${commit.subject}`;
253                }
254    
255                if (commit.body) {
256                    output += "    \n";
257    
258                    for (const line of commit.body.split("\n")) {
259                        const colonIndex = line.indexOf(":");
260    
261                        if (colonIndex !== -1) {
262                            const name = line.slice(0, colonIndex);
263                            const value = line.slice(colonIndex + 1);
264                            output += `    **${name}:** ${value}  \n`;
265    
266                            if (name === "BREAKING CHANGE") {
267                                needsBreakingChangeWarning = false;
268                            }
269                        } else {
270                            output += `    ${line}  \n`;
271                        }
272                    }
273                }
274    
275                if (needsBreakingChangeWarning) {
276                    output += "    **This is a breaking change.**  \n";
277                    output += "    \n";
278                }
279    
280                if (!commit.body && !needsBreakingChangeWarning) {
281                    output += "\n";
282                }
283            }
284    
285            output += "\n";
286        }
287    
288        return output.trimEnd();
289    }
290    
291  function generateChangelog(commits) {  function generateChangelog(commits) {
292      let output = "";      let output = "";
293    
294      const grouppedCommitsByDate = {};      const grouppedCommitsByDate = {};
295    
296      for (const commit of commits) {      for (const commit of commits) {
297          const key = `${commit.createdAt          const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
298              .getUTCDate()              commit.date.getUTCMonth() + 1
299              .toString()          )
             .padStart(2, 0)}-${commit.createdAt  
             .getUTCMonth()  
300              .toString()              .toString()
301              .padStart(2, "0")}-${commit.createdAt.getUTCFullYear()}::${              .padStart(
302              Array.isArray(commit.author)                  2,
303                  ? commit.author.join(":")                  "0",
304                  : commit.author              )}-${commit.date.getUTCFullYear()}::${commit.authors.map((a) => `${a.name} <${a.email}>`).join(":")}`;
         }`;  
305          grouppedCommitsByDate[key] ??= [];          grouppedCommitsByDate[key] ??= [];
306          grouppedCommitsByDate[key].push(commit);          grouppedCommitsByDate[key].push(commit);
307      }      }
# Line 189  function generateChangelog(commits) { Line 311  function generateChangelog(commits) {
311          const date = key.slice(0, separatorPosition);          const date = key.slice(0, separatorPosition);
312          const commits = grouppedCommitsByDate[key];          const commits = grouppedCommitsByDate[key];
313    
314          output += `${date}  ${          output += `${date}  ${commits[0].authors
315              Array.isArray(commits[0].author)              .map((a) => `${a.name} <${a.email}>`)
316                  ? commits[0].author.join(", ")              .join(", ")}\n\n`;
                 : commits[0].author  
         }\n\n`;  
317    
318          for (const commit of commits) {          for (const commit of commits) {
319              output += `        ${commit.message.replaceAll(              output += `     [*] ${commit.subject}\n${commit.body
320                  "\n",                  .split("\n")
321                  "\n        "                  .map((part) => `         ${part}`)
322              )}\n\n`;                  .join("\n")}\n\n`;
323          }          }
324      }      }
325    
# Line 214  function printHelp() { Line 334  function printHelp() {
334      console.log("Options:");      console.log("Options:");
335      console.log("  -h, --help           Show this help and exit.");      console.log("  -h, --help           Show this help and exit.");
336      console.log("  -v, --version        Show this script's version.");      console.log("  -v, --version        Show this script's version.");
337      console.log("  -o, --output=[FILE]  Write the generated changelog to");      console.log("  -f, --format         Set the changelog format.");
338        console.log("                       Supported formats are: plain,");
339        console.log("                       markdown.");
340        console.log("  -o, --output=<FILE>  Write the generated changelog to");
341      console.log("                       a file instead of standard output.");      console.log("                       a file instead of standard output.");
342      console.log("      --no-overwrite   Disallow overwriting of the output");      console.log("      --no-overwrite   Disallow overwriting of the output");
343      console.log("                       file if it exists already.");      console.log("                       file if it exists already.");
# Line 226  function printHelp() { Line 349  function printHelp() {
349  function printVersion() {  function printVersion() {
350      console.log("Copyright (C) 2024 OSN, Inc.");      console.log("Copyright (C) 2024 OSN, Inc.");
351      console.log(      console.log(
352          "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>."          "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.",
353      );      );
354      console.log(      console.log(
355          "This is free software: you are free to change and redistribute it."          "This is free software: you are free to change and redistribute it.",
356      );      );
357      console.log("There is NO WARRANTY, to the extent permitted by law.");      console.log("There is NO WARRANTY, to the extent permitted by law.");
358      console.log();      console.log();
# Line 260  async function main() { Line 383  async function main() {
383                  "no-overwrite": {                  "no-overwrite": {
384                      type: "boolean",                      type: "boolean",
385                  },                  },
386                    format: {
387                        type: "string",
388                        short: "f",
389                    },
390              },              },
391          }).values;          }).values;
392      } catch (error) {      } catch (error) {
# Line 277  async function main() { Line 404  async function main() {
404          exit(0);          exit(0);
405      }      }
406    
407        if (options.format && !["markdown", "plain"].includes(options.format)) {
408            perror(
409                "option `--format` or `-f` only accepts one of the following: markdown, plain",
410            );
411            exit(1);
412        }
413    
414      if (!options.output && options["no-overwrite"]) {      if (!options.output && options["no-overwrite"]) {
415          perror(          perror(
416              "option `--no-overwrite' without `--output` does not make sense"              "option `--no-overwrite' without `--output` does not make sense",
417          );          );
418          exit(1);          exit(1);
419      }      }
# Line 299  async function main() { Line 433  async function main() {
433      const filteredCommits = commits.filter(      const filteredCommits = commits.filter(
434          (commit) =>          (commit) =>
435              !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(              !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
436                  commit.message                  commit.message,
437              )              ),
438      );      );
439      const changelog = generateChangelog(filteredCommits);      const changelog =
440            options.format === "markdown"
441                ? generateMarkdownChangelog(filteredCommits)
442                : generateChangelog(filteredCommits);
443    
444      if (options.output) {      if (options.output) {
445          try {          try {
# Line 311  async function main() { Line 448  async function main() {
448              perror(              perror(
449                  `${options.output}: failed to write changelog: ${                  `${options.output}: failed to write changelog: ${
450                      error?.message ?? error                      error?.message ?? error
451                  }`                  }`,
452              );              );
453              exit(1);              exit(1);
454          }          }

Legend:
Removed from v.20  
changed lines
  Added in v.55

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26