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