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;
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 // Also retrieve new (but not reopened) bugs, as bugs are only auto-closed when
65 // merged into master, but the changelog gets generated on stable.
66 auto templateRequest =
67     `https://issues.dlang.org/buglist.cgi?bug_id={buglist}&bug_status=NEW&bug_status=RESOLVED&`~
68         `ctype=csv&columnlist=component,bug_severity,short_desc`;
69 
70 auto generateRequest(Range)(string templ, Range issues)
71 {
72     auto buglist = format("%(%d,%)", issues);
73     return templateRequest.replace("{buglist}", buglist);
74 }
75 
76 auto dateFromStr(string sdate)
77 {
78     int year, month, day;
79     formattedRead(sdate, "%s-%s-%s", &year, &month, &day);
80     return Date(year, month, day);
81 }
82 
83 string[dchar] parenToMacro;
84 shared static this()
85 {
86     parenToMacro = ['(' : "$(LPAREN)", ')' : "$(RPAREN)"];
87 }
88 
89 /** Replace '(' and ')' with macros to avoid closing down macros by accident. */
90 string escapeParens(string input)
91 {
92     return input.translate(parenToMacro);
93 }
94 
95 /** Get a list of all bugzilla issues mentioned in revRange */
96 auto getIssues(string revRange)
97 {
98     import std.process : execute, pipeProcess, Redirect, wait;
99     import std.regex : ctRegex, match, splitter;
100 
101     // see https://github.com/github/github-services/blob/2e886f407696261bd5adfc99b16d36d5e7b50241/lib/services/bugzilla.rb#L155
102     enum closedRE = ctRegex!(`((close|fix|address)e?(s|d)? )?(ticket|bug|tracker item|issue)s?:? *([\d ,\+&#and]+)`, "i");
103 
104     auto issues = appender!(int[]);
105     foreach (repo; ["dmd", "druntime", "phobos", "dlang.org", "tools", "installer"]
106              .map!(r => buildPath("..", r)))
107     {
108         auto cmd = ["git", "-C", repo, "fetch", "--tags", "https://github.com/dlang/" ~ repo.baseName,
109                            "+refs/heads/*:refs/remotes/upstream/*"];
110         auto p = pipeProcess(cmd, Redirect.stdout);
111         enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
112 
113         cmd = ["git", "-C", repo, "log", revRange];
114         p = pipeProcess(cmd, Redirect.stdout);
115         scope(exit) enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
116 
117         foreach (line; p.stdout.byLine())
118         {
119             if (auto m = match(line, closedRE))
120             {
121                 if (!m.captures[1].length) continue;
122                 m.captures[5]
123                     .splitter(ctRegex!`[^\d]+`)
124                     .filter!(b => b.length)
125                     .map!(to!int)
126                     .copy(issues);
127             }
128         }
129     }
130     return issues.data.sort().release.uniq;
131 }
132 
133 /** Generate and return the change log as a string. */
134 auto getBugzillaChanges(string revRange)
135 {
136     // component (e.g. DMD) -> bug type (e.g. regression) -> list of bug entries
137     BugzillaEntry[][string][string] entries;
138 
139     auto issues = getIssues(revRange);
140     // abort prematurely if no issues are found in all git logs
141     if (issues.empty)
142         return entries;
143 
144     auto req = generateRequest(templateRequest, issues);
145     debug stderr.writeln(req);  // write text
146     auto data = req.get;
147 
148     foreach (fields; csvReader!(Tuple!(int, string, string, string))(data, null))
149     {
150         string comp = fields[1].toLower;
151         switch (comp)
152         {
153             case "dlang.org": comp = "dlang.org"; break;
154             case "dmd": comp = "DMD Compiler"; break;
155             case "druntime": comp = "Druntime"; break;
156             case "installer": comp = "Installer"; break;
157             case "phobos": comp = "Phobos"; break;
158             case "tools": comp = "Tools"; break;
159             case "dub": comp = "Dub"; break;
160             case "visuald": comp = "VisualD"; break;
161             default: assert(0, comp);
162         }
163 
164         string type = fields[2].toLower;
165         switch (type)
166         {
167             case "regression":
168                 type = "regressions";
169                 break;
170 
171             case "blocker", "critical", "major", "normal", "minor", "trivial":
172                 type = "bugs";
173                 break;
174 
175             case "enhancement":
176                 type = "enhancements";
177                 break;
178 
179             default: assert(0, type);
180         }
181 
182         entries[comp][type] ~= BugzillaEntry(fields[0], fields[3].idup);
183     }
184     return entries;
185 }
186 
187 /**
188 Reads a single changelog file.
189 
190 An entry consists of a title line, a blank separator line and
191 the description
192 
193 Params:
194     filename = changelog file to be parsed
195     repoName = origin repository that contains the changelog entry
196 
197 Returns: The parsed `ChangelogEntry`
198 */
199 ChangelogEntry readChangelog(string filename, string repoName)
200 {
201     import std.algorithm.searching : countUntil;
202     import std.file : read;
203     import std.path : baseName, stripExtension;
204     import std..string : strip;
205 
206     auto lines = filename.readText().splitLines();
207 
208     // filter empty files
209     if (lines.empty)
210         return ChangelogEntry.init;
211 
212     // filter ddoc files
213     if (lines[0].startsWith("Ddoc"))
214         return ChangelogEntry.init;
215 
216     enforce(lines.length >= 3 &&
217         !lines[0].empty &&
218          lines[1].empty &&
219         !lines[2].empty,
220         "Changelog entries should consist of one title line, a blank separator line, and a description.");
221 
222     ChangelogEntry entry = {
223         title: lines[0].strip,
224         description: lines[2..$].join("\n").strip,
225         basename: filename.baseName.stripExtension,
226         repo: repoName,
227         filePath: filename.findSplitAfter(repoName)[1].findSplitAfter("/")[1],
228     };
229     return entry;
230 }
231 
232 /**
233 Looks for changelog files (ending with `.dd`) in a directory and parses them.
234 
235 Params:
236     changelogDir = directory to search for changelog files
237     repoName = origin repository that contains the changelog entry
238 
239 Returns: An InputRange of `ChangelogEntry`s
240 */
241 auto readTextChanges(string changelogDir, string repoName)
242 {
243     import std.algorithm.iteration : filter, map;
244     import std.file : dirEntries, SpanMode;
245     import std..string : endsWith;
246 
247     return dirEntries(changelogDir, SpanMode.shallow)
248             .filter!(a => a.name().endsWith(".dd"))
249             .array.sort()
250             .map!(a => readChangelog(a, repoName))
251             .filter!(a => a.title.length > 0);
252 }
253 
254 /**
255 Writes the overview headline of the manually listed changes in the ddoc format as list.
256 
257 Params:
258     changes = parsed InputRange of changelog information
259     w = Output range to use
260 */
261 void writeTextChangesHeader(Entries, Writer)(Entries changes, Writer w, string headline)
262     if (isInputRange!Entries && isOutputRange!(Writer, string))
263 {
264     // write the overview titles
265     w.formattedWrite("$(BUGSTITLE_TEXT_HEADER %s,\n\n", headline);
266     scope(exit) w.put("\n)\n\n");
267     foreach(change; changes)
268     {
269         w.formattedWrite("$(LI $(RELATIVE_LINK2 %s,%s))\n", change.basename, change.title);
270     }
271 }
272 /**
273 Writes the long description of the manually listed changes in the ddoc format as list.
274 
275 Params:
276     changes = parsed InputRange of changelog information
277     w = Output range to use
278 */
279 void writeTextChangesBody(Entries, Writer)(Entries changes, Writer w, string headline)
280     if (isInputRange!Entries && isOutputRange!(Writer, string))
281 {
282     w.formattedWrite("$(BUGSTITLE_TEXT_BODY %s,\n\n", headline);
283     scope(exit) w.put("\n)\n\n");
284     foreach(change; changes)
285     {
286         w.formattedWrite("$(LI $(LNAME2 %s,%s)\n", change.basename, change.title);
287         w.formattedWrite("$(CHANGELOG_SOURCE_FILE %s, %s)\n", change.repo, change.filePath);
288         scope(exit) w.put(")\n\n");
289 
290         bool inPara, inCode;
291         foreach (line; change.description.splitLines)
292         {
293             if (line.stripLeft.startsWith("---"))
294             {
295                 if (inPara)
296                 {
297                     w.put(")\n");
298                     inPara = false;
299                 }
300                 inCode = !inCode;
301             }
302             else if (!inCode && !inPara && !line.empty)
303             {
304                 w.put("$(P\n");
305                 inPara = true;
306             }
307             else if (inPara && line.empty)
308             {
309                 w.put(")\n");
310                 inPara = false;
311             }
312             w.put(line);
313             w.put("\n");
314         }
315         if (inPara)
316             w.put(")\n");
317     }
318 }
319 
320 /**
321 Writes the fixed issued from Bugzilla in the ddoc format as a single list.
322 
323 Params:
324     changes = parsed InputRange of changelog information
325     w = Output range to use
326 */
327 void writeBugzillaChanges(Entries, Writer)(Entries entries, Writer w)
328     if (isOutputRange!(Writer, string))
329 {
330     immutable components = ["DMD Compiler", "Phobos", "Druntime", "dlang.org", "Optlink", "Tools", "Installer"];
331     immutable bugtypes = ["regressions", "bugs", "enhancements"];
332 
333     foreach (component; components)
334     {
335         if (auto comp = component in entries)
336         {
337             foreach (bugtype; bugtypes)
338             if (auto bugs = bugtype in *comp)
339             {
340                 w.formattedWrite("$(BUGSTITLE_BUGZILLA %s %s,\n\n", component, bugtype);
341                 foreach (bug; sort!"a.id < b.id"(*bugs))
342                 {
343                     w.formattedWrite("$(LI $(BUGZILLA %s): %s)\n",
344                                         bug.id, bug.summary.escapeParens());
345                 }
346                 w.put(")\n");
347             }
348         }
349     }
350 }
351 
352 int main(string[] args)
353 {
354     auto outputFile = "./changelog.dd";
355     auto nextVersionString = "LATEST";
356 
357     auto currDate = Clock.currTime();
358     auto nextVersionDate = "%s %02d, %04d"
359         .format(currDate.month.to!string.capitalize, currDate.day, currDate.year);
360 
361     string previousVersion = "Previous version";
362     bool hideTextChanges = false;
363     string revRange;
364 
365     auto helpInformation = getopt(
366         args,
367         std.getopt.config.passThrough,
368         "output|o", &outputFile,
369         "date", &nextVersionDate,
370         "version", &nextVersionString,
371         "prev-version", &previousVersion, // this can automatically be detected
372         "no-text", &hideTextChanges);
373 
374     if (helpInformation.helpWanted)
375     {
376 `Changelog generator
377 Please supply a bugzilla version
378 ./changed.d "v2.071.2..upstream/stable"`.defaultGetoptPrinter(helpInformation.options);
379     }
380 
381     if (args.length >= 2)
382     {
383         revRange = args[1];
384 
385         // extract the previous version
386         auto parts = revRange.split("..");
387         if (parts.length > 1)
388             previousVersion = parts[0].replace("v", "");
389     }
390     else
391     {
392         writeln("Skipped querying Bugzilla for changes. Please define a revision range e.g ./changed v2.072.2..upstream/stable");
393     }
394 
395     // location of the changelog files
396     alias Repo = Tuple!(string, "name", string, "headline", string, "path");
397     auto repos = [Repo("dmd", "Compiler changes", null),
398                   Repo("druntime", "Runtime changes", null),
399                   Repo("phobos", "Library changes", null),
400                   Repo("dlang.org", "Language changes", null),
401                   Repo("installer", "Installer changes", null),
402                   Repo("tools", "Tools changes", null),
403                   Repo("dub", "Dub changes", null)];
404 
405     auto changedRepos = repos
406          .map!(repo => Repo(repo.name, repo.headline, buildPath(__FILE_FULL_PATH__.dirName, "..", repo.name, repo.name == "dlang.org" ? "language-changelog" : "changelog")))
407          .filter!(r => r.path.exists);
408 
409     // ensure that all files either end on .dd or .md
410     bool errors;
411     foreach (repo; changedRepos)
412     {
413         auto invalidFiles = repo.path
414             .dirEntries(SpanMode.shallow)
415             .filter!(a => !a.name.endsWith(".dd", ".md"));
416         if (!invalidFiles.empty)
417         {
418             invalidFiles.each!(f => stderr.writefln("ERROR: %s needs to have .dd or .md as extension", f.buildNormalizedPath));
419             errors = 1;
420         }
421     }
422     import core.stdc.stdlib : exit;
423     if (errors)
424         1.exit;
425 
426     auto f = File(outputFile, "w");
427     auto w = f.lockingTextWriter();
428     w.put("Ddoc\n\n");
429     w.put("$(CHANGELOG_NAV_INJECT)\n\n");
430 
431     {
432         w.formattedWrite("$(VERSION %s, =================================================,\n\n", nextVersionDate);
433 
434         scope(exit) w.put(")\n");
435 
436         if (!hideTextChanges)
437         {
438             // search for raw change files
439             auto changelogDirs = changedRepos
440                  .map!(r => tuple!("headline", "changes")(r.headline, r.path.readTextChanges(r.name).array))
441                  .filter!(r => !r.changes.empty);
442 
443             // print the overview headers
444             changelogDirs.each!(r => r.changes.writeTextChangesHeader(w, r.headline));
445 
446             if (!revRange.empty)
447                 w.put("$(CHANGELOG_SEP_HEADER_TEXT_NONEMPTY)\n\n");
448 
449             w.put("$(CHANGELOG_SEP_HEADER_TEXT)\n\n");
450 
451             // print the detailed descriptions
452             changelogDirs.each!(x => x.changes.writeTextChangesBody(w, x.headline));
453 
454             if (revRange.length)
455                 w.put("$(CHANGELOG_SEP_TEXT_BUGZILLA)\n\n");
456         }
457         else
458         {
459                 w.put("$(CHANGELOG_SEP_NO_TEXT_BUGZILLA)\n\n");
460         }
461 
462         // print the entire changelog history
463         if (revRange.length)
464             revRange.getBugzillaChanges.writeBugzillaChanges(w);
465     }
466 
467     version(Contributors_Lib)
468     if (revRange)
469     {
470         import contributors : FindConfig, findAuthors, reduceAuthors;
471         FindConfig config = {
472             cwd: __FILE_FULL_PATH__.dirName.asNormalizedPath.to!string,
473         };
474         config.mailmapFile = config.cwd.buildPath(".mailmap");
475         auto authors = revRange.findAuthors(config).reduceAuthors;
476         w.formattedWrite("$(D_CONTRIBUTORS_HEADER %d)\n", authors.save.walkLength);
477         w.put("$(D_CONTRIBUTORS\n");
478         authors.each!(a => w.formattedWrite("    $(D_CONTRIBUTOR %s)\n", a.name));
479         w.put(")\n");
480         w.put("$(D_CONTRIBUTORS_FOOTER)\n");
481     }
482 
483     w.put("$(CHANGELOG_NAV_INJECT)\n\n");
484 
485     // write own macros
486     w.formattedWrite(`Macros:
487     VER=%s
488     TITLE=Change Log: $(VER)
489 `, nextVersionString);
490 
491     writefln("Change log generated to: '%s'", outputFile);
492     return 0;
493 }