1 #!/usr/bin/env rdmd
2 
3 // Written in the D programming language
4 
5 /**
6 Change log generator which fetches the list of bugfixes
7 from the D Bugzilla between the given dates.
8 Moreover manual changes are accumulated from raw text files in the
9 Dlang repositories.
10 It stores its result in DDoc form to a text file.
11 
12 Copyright: D Language Foundation 2016.
13 
14 License:   $(WEB boost.org/LICENSE_1_0.txt, Boost License 1.0).
15 
16 Authors:   Dmitry Olshansky,
17            Andrej Mitrovic,
18            Sebastian Wilzbach
19 
20 Example usage:
21 
22 ---
23 rdmd changed.d "v2.071.2..upstream/stable"
24 ---
25 
26 It is also possible to directly preview the generated changelog file:
27 
28 ---
29 rdmd changed.d "v2.071.2..upstream/stable" && dmd ../dlang.org/macros.ddoc ../dlang.org/html.ddoc ../dlang.org/dlang.org.ddoc ../dlang.org/doc.ddoc ../dlang.org/changelog/changelog.ddoc changelog.dd -Df../dlang.org/web/changelog/pending.html
30 ---
31 
32 If no arguments are passed, only the manual changes will be accumulated and Bugzilla
33 won't be queried (faster).
34 
35 A manual changelog entry consists of a title line, a blank separator line and
36 the description.
37 */
38 
39 // NOTE: this script requires libcurl to be linked in (usually done by default).
40 
41 module changed;
42 
43 import std.net.curl, std.conv, std.exception, std.algorithm, std.csv, std.typecons,
44     std.stdio, std.datetime, std.array, std.string, std.file, std.format, std.getopt,
45     std.path, std.functional;
46 
47 import std.range.primitives, std.traits;
48 
49 struct BugzillaEntry
50 {
51     int id;
52     string summary;
53 }
54 
55 struct ChangelogEntry
56 {
57     string title; // the first line (can't contain links)
58     string description; // a detailed description (separated by a new line)
59     string basename; // basename without extension (used for the anchor link to the description)
60     string repo; // origin repository that contains the changelog entry
61     string filePath; // path to the changelog entry (relative from the repository root)
62 }
63 
64 struct ChangelogStats
65 {
66     size_t bugzillaIssues; // number of referenced bugzilla issues of this release
67     size_t changelogEntries; // number of changelog entries of this release
68     size_t contributors; // number of distinct contributors that have contributed to this release
69 
70     /**
71     Adds a changelog entry to the summary statistics.
72 
73     Params:
74         entry = changelog entry
75     */
76     void addChangelogEntry(const ref ChangelogEntry entry)
77     {
78         changelogEntries++;
79     }
80 
81     /**
82     Adds a Bugzilla issue to the summary statistics.
83 
84     Params:
85         entry = bugzilla entry
86         component = component of the bugzilla issue (e.g. "dmd" or "phobos")
87         type = type of the bugzilla issue (e.g. "regression" or "blocker")
88     */
89     void addBugzillaIssue(const ref BugzillaEntry, string component, string type)
90     {
91         bugzillaIssues++;
92     }
93 }
94 ChangelogStats changelogStats;
95 
96 
97 // Also retrieve new (but not reopened) bugs, as bugs are only auto-closed when
98 // merged into master, but the changelog gets generated on stable.
99 auto templateRequest =
100     `https://issues.dlang.org/buglist.cgi?bug_id={buglist}&bug_status=NEW&bug_status=RESOLVED&`~
101         `ctype=csv&columnlist=component,bug_severity,short_desc`;
102 
103 auto generateRequest(Range)(string templ, Range issues)
104 {
105     auto buglist = format("%(%d,%)", issues);
106     return templateRequest.replace("{buglist}", buglist);
107 }
108 
109 auto dateFromStr(string sdate)
110 {
111     int year, month, day;
112     formattedRead(sdate, "%s-%s-%s", &year, &month, &day);
113     return Date(year, month, day);
114 }
115 
116 string[dchar] parenToMacro;
117 shared static this()
118 {
119     parenToMacro = ['(' : "$(LPAREN)", ')' : "$(RPAREN)"];
120 }
121 
122 /** Replace '(' and ')' with macros to avoid closing down macros by accident. */
123 string escapeParens(string input)
124 {
125     return input.translate(parenToMacro);
126 }
127 
128 /** Get a list of all bugzilla issues mentioned in revRange */
129 auto getIssues(string revRange)
130 {
131     import std.process : execute, pipeProcess, Redirect, wait;
132     import std.regex : ctRegex, match, splitter;
133 
134     // Keep in sync with the regex in dlang-bot:
135     // https://github.com/dlang/dlang-bot/blob/master/source/dlangbot/bugzilla.d#L24
136     // This regex was introduced in https://github.com/dlang/dlang-bot/pull/240
137     // and only the first part of the regex is needed (the second part matches
138     // issues reference that won't close the issue).
139     // Note: "Bugzilla" is required since https://github.com/dlang/dlang-bot/pull/302;
140     // temporarily both are accepted during a transition period.
141     enum closedRE = ctRegex!(`(?:^fix(?:es)?(?:\s+bugzilla)?(?:\s+(?:issues?|bugs?))?\s+(#?\d+(?:[\s,\+&and]+#?\d+)*))`, "i");
142 
143     auto issues = appender!(int[]);
144     foreach (repo; ["dmd", "phobos", "dlang.org", "tools", "installer"]
145              .map!(r => buildPath("..", r)))
146     {
147         auto cmd = ["git", "-C", repo, "fetch", "--tags", "https://github.com/dlang/" ~ repo.baseName,
148                            "+refs/heads/*:refs/remotes/upstream/*"];
149         auto p = pipeProcess(cmd, Redirect.stdout);
150         enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
151 
152         cmd = ["git", "-C", repo, "log", revRange];
153         p = pipeProcess(cmd, Redirect.stdout);
154         scope(exit) enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
155 
156         foreach (line; p.stdout.byLine())
157         {
158             if (auto m = match(line.stripLeft, closedRE))
159             {
160                 m.captures[1]
161                     .splitter(ctRegex!`[^\d]+`)
162                     .filter!(b => b.length)
163                     .map!(to!int)
164                     .copy(issues);
165             }
166         }
167     }
168     return issues.data.sort().release.uniq;
169 }
170 
171 /** Generate and return the change log as a string. */
172 auto getBugzillaChanges(string revRange)
173 {
174     // component (e.g. DMD) -> bug type (e.g. regression) -> list of bug entries
175     BugzillaEntry[][string][string] entries;
176 
177     auto issues = getIssues(revRange);
178     // abort prematurely if no issues are found in all git logs
179     if (issues.empty)
180         return entries;
181 
182     auto req = generateRequest(templateRequest, issues);
183     debug stderr.writeln(req);  // write text
184     auto data = req.get;
185 
186     foreach (fields; csvReader!(Tuple!(int, string, string, string))(data, null))
187     {
188         string comp = fields[1].toLower;
189         switch (comp)
190         {
191             case "dlang.org": comp = "dlang.org"; break;
192             case "dmd": comp = "DMD Compiler"; break;
193             case "druntime": comp = "Druntime"; break;
194             case "installer": comp = "Installer"; break;
195             case "phobos": comp = "Phobos"; break;
196             case "tools": comp = "Tools"; break;
197             case "dub": comp = "Dub"; break;
198             case "visuald": comp = "VisualD"; break;
199             default: assert(0, comp);
200         }
201 
202         string type = fields[2].toLower;
203         switch (type)
204         {
205             case "regression":
206                 type = "regression fixes";
207                 break;
208 
209             case "blocker", "critical", "major", "normal", "minor", "trivial":
210                 type = "bug fixes";
211                 break;
212 
213             case "enhancement":
214                 type = "enhancements";
215                 break;
216 
217             default: assert(0, type);
218         }
219 
220         auto entry = BugzillaEntry(fields[0], fields[3].idup);
221         entries[comp][type] ~= entry;
222         changelogStats.addBugzillaIssue(entry, comp, type);
223     }
224     return entries;
225 }
226 
227 /**
228 Reads a single changelog file.
229 
230 An entry consists of a title line, a blank separator line and
231 the description
232 
233 Params:
234     filename = changelog file to be parsed
235     repoName = origin repository that contains the changelog entry
236 
237 Returns: The parsed `ChangelogEntry`
238 */
239 ChangelogEntry readChangelog(string filename, string repoName)
240 {
241     import std.algorithm.searching : countUntil;
242     import std.file : read;
243     import std.path : baseName, stripExtension;
244     import std.string : strip;
245 
246     auto lines = filename.readText().splitLines();
247 
248     // filter empty files
249     if (lines.empty)
250         return ChangelogEntry.init;
251 
252     // filter ddoc files
253     if (lines[0].startsWith("Ddoc"))
254         return ChangelogEntry.init;
255 
256     enforce(lines.length >= 3 &&
257         !lines[0].empty &&
258          lines[1].empty &&
259         !lines[2].empty,
260         "Changelog entries should consist of one title line, a blank separator line, and a description.");
261 
262     ChangelogEntry entry = {
263         title: lines[0].strip,
264         description: lines[2..$].join("\n").strip,
265         basename: filename.baseName.stripExtension,
266         repo: repoName,
267         filePath: filename.findSplitAfter(repoName)[1].findSplitAfter("/")[1],
268     };
269     return entry;
270 }
271 
272 /**
273 Looks for changelog files (ending with `.dd`) in a directory and parses them.
274 
275 Params:
276     changelogDir = directory to search for changelog files
277     repoName = origin repository that contains the changelog entry
278 
279 Returns: An InputRange of `ChangelogEntry`s
280 */
281 auto readTextChanges(string changelogDir, string repoName, string prefix)
282 {
283     import std.algorithm.iteration : filter, map;
284     import std.file : dirEntries, SpanMode;
285     import std.path : baseName;
286     import std.string : endsWith;
287 
288     return dirEntries(changelogDir, SpanMode.shallow)
289             .filter!(a => a.name().endsWith(".dd"))
290             .filter!(a => prefix is null || a.name().baseName.startsWith(prefix))
291             .array.sort()
292             .map!(a => readChangelog(a, repoName))
293             .filter!(a => a.title.length > 0);
294 }
295 
296 /**
297 Writes the overview headline of the manually listed changes in the ddoc format as list.
298 
299 Params:
300     changes = parsed InputRange of changelog information
301     w = Output range to use
302 */
303 void writeTextChangesHeader(Entries, Writer)(Entries changes, Writer w, string headline)
304     if (isInputRange!Entries && isOutputRange!(Writer, string))
305 {
306     // write the overview titles
307     w.formattedWrite("$(BUGSTITLE_TEXT_HEADER %s,\n\n", headline);
308     scope(exit) w.put("\n)\n\n");
309     foreach(change; changes)
310     {
311         w.formattedWrite("$(LI $(RELATIVE_LINK2 %s,%s))\n", change.basename, change.title);
312     }
313 }
314 /**
315 Writes the long description of the manually listed changes in the ddoc format as list.
316 
317 Params:
318     changes = parsed InputRange of changelog information
319     w = Output range to use
320 */
321 void writeTextChangesBody(Entries, Writer)(Entries changes, Writer w, string headline)
322     if (isInputRange!Entries && isOutputRange!(Writer, string))
323 {
324     w.formattedWrite("$(BUGSTITLE_TEXT_BODY %s,\n\n", headline);
325     scope(exit) w.put("\n)\n\n");
326     foreach(change; changes)
327     {
328         w.formattedWrite("$(LI $(LNAME2 %s,%s)\n", change.basename, change.title);
329         w.formattedWrite("$(CHANGELOG_SOURCE_FILE %s, %s)\n", change.repo, change.filePath);
330         scope(exit) w.put(")\n\n");
331 
332         bool inPara, inCode;
333         foreach (line; change.description.splitLines)
334         {
335             if (line.stripLeft.startsWith("---", "```"))
336             {
337                 if (inPara)
338                 {
339                     w.put(")\n");
340                     inPara = false;
341                 }
342                 inCode = !inCode;
343             }
344             else if (!inCode && !inPara && !line.empty)
345             {
346                 w.put("$(P\n");
347                 inPara = true;
348             }
349             else if (inPara && line.empty)
350             {
351                 w.put(")\n");
352                 inPara = false;
353             }
354             w.put(line);
355             w.put("\n");
356         }
357         if (inPara)
358             w.put(")\n");
359     }
360 }
361 
362 /**
363 Writes the fixed issued from Bugzilla in the ddoc format as a single list.
364 
365 Params:
366     changes = parsed InputRange of changelog information
367     w = Output range to use
368 */
369 void writeBugzillaChanges(Entries, Writer)(Entries entries, Writer w)
370     if (isOutputRange!(Writer, string))
371 {
372     immutable components = ["DMD Compiler", "Phobos", "Druntime", "dlang.org", "Optlink", "Tools", "Installer"];
373     immutable bugtypes = ["regression fixes", "bug fixes", "enhancements"];
374 
375     foreach (component; components)
376     {
377         if (auto comp = component in entries)
378         {
379             foreach (bugtype; bugtypes)
380             if (auto bugs = bugtype in *comp)
381             {
382                 w.formattedWrite("$(BUGSTITLE_BUGZILLA %s %s,\n\n", component, bugtype);
383                 foreach (bug; sort!"a.id < b.id"(*bugs))
384                 {
385                     w.formattedWrite("$(LI $(BUGZILLA %s): %s)\n",
386                                         bug.id, bug.summary.escapeParens());
387                 }
388                 w.put(")\n");
389             }
390         }
391     }
392 }
393 
394 int main(string[] args)
395 {
396     auto outputFile = "./changelog.dd";
397     auto nextVersionString = "LATEST";
398 
399     auto currDate = Clock.currTime();
400     auto nextVersionDate = "%s %02d, %04d"
401         .format(currDate.month.to!string.capitalize, currDate.day, currDate.year);
402 
403     string previousVersion = "Previous version";
404     bool hideTextChanges = false;
405     string revRange;
406 
407     auto helpInformation = getopt(
408         args,
409         std.getopt.config.passThrough,
410         "output|o", &outputFile,
411         "date", &nextVersionDate,
412         "version", &nextVersionString,
413         "prev-version", &previousVersion, // this can automatically be detected
414         "no-text", &hideTextChanges);
415 
416     if (helpInformation.helpWanted)
417     {
418 `Changelog generator
419 Please supply a bugzilla version
420 ./changed.d "v2.071.2..upstream/stable"`.defaultGetoptPrinter(helpInformation.options);
421     }
422 
423     if (args.length >= 2)
424     {
425         revRange = args[1];
426 
427         // extract the previous version
428         auto parts = revRange.split("..");
429         if (parts.length > 1)
430             previousVersion = parts[0].replace("v", "");
431     }
432     else
433     {
434         writeln("Skipped querying Bugzilla for changes. Please define a revision range e.g ./changed v2.072.2..upstream/stable");
435     }
436 
437     // location of the changelog files
438     alias Repo = Tuple!(string, "name", string, "headline", string, "path", string, "prefix");
439     auto repos = [Repo("dmd", "Compiler changes", "changelog", "dmd."),
440                   Repo("dmd", "Runtime changes", "changelog", "druntime."),
441                   Repo("phobos", "Library changes", "changelog", null),
442                   Repo("dlang.org", "Language changes", "language-changelog", null),
443                   Repo("installer", "Installer changes", "changelog", null),
444                   Repo("tools", "Tools changes", "changelog", null),
445                   Repo("dub", "Dub changes", "changelog", null)];
446 
447     auto changedRepos = repos
448          .map!(repo => Repo(repo.name, repo.headline, buildPath(__FILE_FULL_PATH__.dirName, "..", repo.name, repo.path), repo.prefix))
449          .filter!(r => r.path.exists);
450 
451     // ensure that all files either end on .dd or .md
452     bool errors;
453     foreach (repo; changedRepos)
454     {
455         auto invalidFiles = repo.path
456             .dirEntries(SpanMode.shallow)
457             .filter!(a => !a.name.endsWith(".dd", ".md"));
458         if (!invalidFiles.empty)
459         {
460             invalidFiles.each!(f => stderr.writefln("ERROR: %s needs to have .dd or .md as extension", f.buildNormalizedPath));
461             errors = 1;
462         }
463     }
464     import core.stdc.stdlib : exit;
465     if (errors)
466         1.exit;
467 
468     auto f = File(outputFile, "w");
469     auto w = f.lockingTextWriter();
470     w.put("Ddoc\n\n");
471     w.put("$(CHANGELOG_NAV_INJECT)\n\n");
472 
473     // Accumulate Bugzilla issues
474     typeof(revRange.getBugzillaChanges) bugzillaChanges;
475     if (revRange.length)
476         bugzillaChanges = revRange.getBugzillaChanges;
477 
478     // Accumulate contributors from the git log
479     version(Contributors_Lib)
480     {
481         import contributors : FindConfig, findAuthors, reduceAuthors;
482         typeof(revRange.findAuthors(FindConfig.init).reduceAuthors.array) authors;
483         if (revRange)
484         {
485             FindConfig config = {
486                 cwd: __FILE_FULL_PATH__.dirName.asNormalizedPath.to!string,
487             };
488             config.mailmapFile = config.cwd.buildPath(".mailmap");
489             authors = revRange.findAuthors(config).reduceAuthors.array;
490             changelogStats.contributors = authors.save.walkLength;
491         }
492     }
493 
494     {
495         w.formattedWrite("$(VERSION %s, =================================================,\n\n", nextVersionDate);
496 
497         scope(exit) w.put(")\n");
498 
499 
500         if (!hideTextChanges)
501         {
502             // search for raw change files
503             auto changelogDirs = changedRepos
504                  .map!(r => tuple!("headline", "changes")(r.headline, r.path.readTextChanges(r.name, r.prefix).array))
505                  .filter!(r => !r.changes.empty);
506 
507             // accumulate stats
508             {
509                 changelogDirs.each!(c => c.changes.each!(c => changelogStats.addChangelogEntry(c)));
510                 w.put("$(CHANGELOG_HEADER_STATISTICS\n");
511                 scope(exit) w.put(")\n\n");
512 
513                 with(changelogStats)
514                 {
515                     auto changelog = changelogEntries > 0 ? "%d major change%s and".format(changelogEntries, changelogEntries > 1 ? "s" : "") : "";
516                     w.put("$(VER) comes with {changelogEntries} {bugzillaIssues} fixed Bugzilla issue{bugzillaIssuesPlural}.
517         A huge thanks goes to the
518         $(LINK2 #contributors, {nrContributors} contributor{nrContributorsPlural})
519         who made $(VER) possible."
520                         .replace("{bugzillaIssues}", bugzillaIssues.text)
521                         .replace("{bugzillaIssuesPlural}", bugzillaIssues != 1 ? "s" : "")
522                         .replace("{changelogEntries}", changelog)
523                         .replace("{nrContributors}", contributors.text)
524                         .replace("{nrContributorsPlural}", contributors != 1 ? "s" : "")
525                     );
526                 }
527             }
528 
529             // print the overview headers
530             changelogDirs.each!(c => c.changes.writeTextChangesHeader(w, c.headline));
531 
532             if (!revRange.empty)
533                 w.put("$(CHANGELOG_SEP_HEADER_TEXT_NONEMPTY)\n\n");
534 
535             w.put("$(CHANGELOG_SEP_HEADER_TEXT)\n\n");
536 
537             // print the detailed descriptions
538             changelogDirs.each!(x => x.changes.writeTextChangesBody(w, x.headline));
539 
540             if (revRange.length)
541                 w.put("$(CHANGELOG_SEP_TEXT_BUGZILLA)\n\n");
542         }
543         else
544         {
545                 w.put("$(CHANGELOG_SEP_NO_TEXT_BUGZILLA)\n\n");
546         }
547 
548         // print the entire changelog history
549         if (revRange.length)
550             bugzillaChanges.writeBugzillaChanges(w);
551     }
552 
553     version(Contributors_Lib)
554     if (revRange)
555     {
556         w.formattedWrite("$(D_CONTRIBUTORS_HEADER %d)\n", changelogStats.contributors);
557         w.put("$(D_CONTRIBUTORS\n");
558         authors.each!(a => w.formattedWrite("    $(D_CONTRIBUTOR %s)\n", a.name));
559         w.put(")\n");
560         w.put("$(D_CONTRIBUTORS_FOOTER)\n");
561     }
562 
563     w.put("$(CHANGELOG_NAV_INJECT)\n\n");
564 
565     // write own macros
566     w.formattedWrite(`Macros:
567     VER=%s
568     TITLE=Change Log: $(VER)
569 `, nextVersionString);
570 
571     writefln("Change log generated to: '%s'", outputFile);
572     return 0;
573 }