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 }