mirror of
https://github.com/dlang/tools.git
synced 2025-04-25 12:40:21 +03:00
573 lines
19 KiB
D
Executable file
573 lines
19 KiB
D
Executable file
#!/usr/bin/env rdmd
|
|
|
|
// Written in the D programming language
|
|
|
|
/**
|
|
Change log generator which fetches the list of bugfixes
|
|
from the D Bugzilla between the given dates.
|
|
Moreover manual changes are accumulated from raw text files in the
|
|
Dlang repositories.
|
|
It stores its result in DDoc form to a text file.
|
|
|
|
Copyright: D Language Foundation 2016.
|
|
|
|
License: $(WEB boost.org/LICENSE_1_0.txt, Boost License 1.0).
|
|
|
|
Authors: Dmitry Olshansky,
|
|
Andrej Mitrovic,
|
|
Sebastian Wilzbach
|
|
|
|
Example usage:
|
|
|
|
---
|
|
rdmd changed.d "v2.071.2..upstream/stable"
|
|
---
|
|
|
|
It is also possible to directly preview the generated changelog file:
|
|
|
|
---
|
|
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
|
|
---
|
|
|
|
If no arguments are passed, only the manual changes will be accumulated and Bugzilla
|
|
won't be queried (faster).
|
|
|
|
A manual changelog entry consists of a title line, a blank separator line and
|
|
the description.
|
|
*/
|
|
|
|
// NOTE: this script requires libcurl to be linked in (usually done by default).
|
|
|
|
module changed;
|
|
|
|
import std.net.curl, std.conv, std.exception, std.algorithm, std.csv, std.typecons,
|
|
std.stdio, std.datetime, std.array, std.string, std.file, std.format, std.getopt,
|
|
std.path, std.functional;
|
|
|
|
import std.range.primitives, std.traits;
|
|
|
|
struct BugzillaEntry
|
|
{
|
|
int id;
|
|
string summary;
|
|
}
|
|
|
|
struct ChangelogEntry
|
|
{
|
|
string title; // the first line (can't contain links)
|
|
string description; // a detailed description (separated by a new line)
|
|
string basename; // basename without extension (used for the anchor link to the description)
|
|
string repo; // origin repository that contains the changelog entry
|
|
string filePath; // path to the changelog entry (relative from the repository root)
|
|
}
|
|
|
|
struct ChangelogStats
|
|
{
|
|
size_t bugzillaIssues; // number of referenced bugzilla issues of this release
|
|
size_t changelogEntries; // number of changelog entries of this release
|
|
size_t contributors; // number of distinct contributors that have contributed to this release
|
|
|
|
/**
|
|
Adds a changelog entry to the summary statistics.
|
|
|
|
Params:
|
|
entry = changelog entry
|
|
*/
|
|
void addChangelogEntry(const ref ChangelogEntry entry)
|
|
{
|
|
changelogEntries++;
|
|
}
|
|
|
|
/**
|
|
Adds a Bugzilla issue to the summary statistics.
|
|
|
|
Params:
|
|
entry = bugzilla entry
|
|
component = component of the bugzilla issue (e.g. "dmd" or "phobos")
|
|
type = type of the bugzilla issue (e.g. "regression" or "blocker")
|
|
*/
|
|
void addBugzillaIssue(const ref BugzillaEntry, string component, string type)
|
|
{
|
|
bugzillaIssues++;
|
|
}
|
|
}
|
|
ChangelogStats changelogStats;
|
|
|
|
|
|
// Also retrieve new (but not reopened) bugs, as bugs are only auto-closed when
|
|
// merged into master, but the changelog gets generated on stable.
|
|
auto templateRequest =
|
|
`https://issues.dlang.org/buglist.cgi?bug_id={buglist}&bug_status=NEW&bug_status=RESOLVED&`~
|
|
`ctype=csv&columnlist=component,bug_severity,short_desc`;
|
|
|
|
auto generateRequest(Range)(string templ, Range issues)
|
|
{
|
|
auto buglist = format("%(%d,%)", issues);
|
|
return templateRequest.replace("{buglist}", buglist);
|
|
}
|
|
|
|
auto dateFromStr(string sdate)
|
|
{
|
|
int year, month, day;
|
|
formattedRead(sdate, "%s-%s-%s", &year, &month, &day);
|
|
return Date(year, month, day);
|
|
}
|
|
|
|
string[dchar] parenToMacro;
|
|
shared static this()
|
|
{
|
|
parenToMacro = ['(' : "$(LPAREN)", ')' : "$(RPAREN)"];
|
|
}
|
|
|
|
/** Replace '(' and ')' with macros to avoid closing down macros by accident. */
|
|
string escapeParens(string input)
|
|
{
|
|
return input.translate(parenToMacro);
|
|
}
|
|
|
|
/** Get a list of all bugzilla issues mentioned in revRange */
|
|
auto getIssues(string revRange)
|
|
{
|
|
import std.process : execute, pipeProcess, Redirect, wait;
|
|
import std.regex : ctRegex, match, splitter;
|
|
|
|
// Keep in sync with the regex in dlang-bot:
|
|
// https://github.com/dlang/dlang-bot/blob/master/source/dlangbot/bugzilla.d#L24
|
|
// This regex was introduced in https://github.com/dlang/dlang-bot/pull/240
|
|
// and only the first part of the regex is needed (the second part matches
|
|
// issues reference that won't close the issue).
|
|
// Note: "Bugzilla" is required since https://github.com/dlang/dlang-bot/pull/302;
|
|
// temporarily both are accepted during a transition period.
|
|
enum closedRE = ctRegex!(`(?:^fix(?:es)?(?:\s+bugzilla)?(?:\s+(?:issues?|bugs?))?\s+(#?\d+(?:[\s,\+&and]+#?\d+)*))`, "i");
|
|
|
|
auto issues = appender!(int[]);
|
|
foreach (repo; ["dmd", "phobos", "dlang.org", "tools", "installer"]
|
|
.map!(r => buildPath("..", r)))
|
|
{
|
|
auto cmd = ["git", "-C", repo, "fetch", "--tags", "https://github.com/dlang/" ~ repo.baseName,
|
|
"+refs/heads/*:refs/remotes/upstream/*"];
|
|
auto p = pipeProcess(cmd, Redirect.stdout);
|
|
enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
|
|
|
|
cmd = ["git", "-C", repo, "log", revRange];
|
|
p = pipeProcess(cmd, Redirect.stdout);
|
|
scope(exit) enforce(wait(p.pid) == 0, "Failed to execute '%(%s %)'.".format(cmd));
|
|
|
|
foreach (line; p.stdout.byLine())
|
|
{
|
|
if (auto m = match(line.stripLeft, closedRE))
|
|
{
|
|
m.captures[1]
|
|
.splitter(ctRegex!`[^\d]+`)
|
|
.filter!(b => b.length)
|
|
.map!(to!int)
|
|
.copy(issues);
|
|
}
|
|
}
|
|
}
|
|
return issues.data.sort().release.uniq;
|
|
}
|
|
|
|
/** Generate and return the change log as a string. */
|
|
auto getBugzillaChanges(string revRange)
|
|
{
|
|
// component (e.g. DMD) -> bug type (e.g. regression) -> list of bug entries
|
|
BugzillaEntry[][string][string] entries;
|
|
|
|
auto issues = getIssues(revRange);
|
|
// abort prematurely if no issues are found in all git logs
|
|
if (issues.empty)
|
|
return entries;
|
|
|
|
auto req = generateRequest(templateRequest, issues);
|
|
debug stderr.writeln(req); // write text
|
|
auto data = req.get;
|
|
|
|
foreach (fields; csvReader!(Tuple!(int, string, string, string))(data, null))
|
|
{
|
|
string comp = fields[1].toLower;
|
|
switch (comp)
|
|
{
|
|
case "dlang.org": comp = "dlang.org"; break;
|
|
case "dmd": comp = "DMD Compiler"; break;
|
|
case "druntime": comp = "Druntime"; break;
|
|
case "installer": comp = "Installer"; break;
|
|
case "phobos": comp = "Phobos"; break;
|
|
case "tools": comp = "Tools"; break;
|
|
case "dub": comp = "Dub"; break;
|
|
case "visuald": comp = "VisualD"; break;
|
|
default: assert(0, comp);
|
|
}
|
|
|
|
string type = fields[2].toLower;
|
|
switch (type)
|
|
{
|
|
case "regression":
|
|
type = "regression fixes";
|
|
break;
|
|
|
|
case "blocker", "critical", "major", "normal", "minor", "trivial":
|
|
type = "bug fixes";
|
|
break;
|
|
|
|
case "enhancement":
|
|
type = "enhancements";
|
|
break;
|
|
|
|
default: assert(0, type);
|
|
}
|
|
|
|
auto entry = BugzillaEntry(fields[0], fields[3].idup);
|
|
entries[comp][type] ~= entry;
|
|
changelogStats.addBugzillaIssue(entry, comp, type);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
Reads a single changelog file.
|
|
|
|
An entry consists of a title line, a blank separator line and
|
|
the description
|
|
|
|
Params:
|
|
filename = changelog file to be parsed
|
|
repoName = origin repository that contains the changelog entry
|
|
|
|
Returns: The parsed `ChangelogEntry`
|
|
*/
|
|
ChangelogEntry readChangelog(string filename, string repoName)
|
|
{
|
|
import std.algorithm.searching : countUntil;
|
|
import std.file : read;
|
|
import std.path : baseName, stripExtension;
|
|
import std.string : strip;
|
|
|
|
auto lines = filename.readText().splitLines();
|
|
|
|
// filter empty files
|
|
if (lines.empty)
|
|
return ChangelogEntry.init;
|
|
|
|
// filter ddoc files
|
|
if (lines[0].startsWith("Ddoc"))
|
|
return ChangelogEntry.init;
|
|
|
|
enforce(lines.length >= 3 &&
|
|
!lines[0].empty &&
|
|
lines[1].empty &&
|
|
!lines[2].empty,
|
|
"Changelog entries should consist of one title line, a blank separator line, and a description.");
|
|
|
|
ChangelogEntry entry = {
|
|
title: lines[0].strip,
|
|
description: lines[2..$].join("\n").strip,
|
|
basename: filename.baseName.stripExtension,
|
|
repo: repoName,
|
|
filePath: filename.findSplitAfter(repoName)[1].findSplitAfter("/")[1],
|
|
};
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
Looks for changelog files (ending with `.dd`) in a directory and parses them.
|
|
|
|
Params:
|
|
changelogDir = directory to search for changelog files
|
|
repoName = origin repository that contains the changelog entry
|
|
|
|
Returns: An InputRange of `ChangelogEntry`s
|
|
*/
|
|
auto readTextChanges(string changelogDir, string repoName, string prefix)
|
|
{
|
|
import std.algorithm.iteration : filter, map;
|
|
import std.file : dirEntries, SpanMode;
|
|
import std.path : baseName;
|
|
import std.string : endsWith;
|
|
|
|
return dirEntries(changelogDir, SpanMode.shallow)
|
|
.filter!(a => a.name().endsWith(".dd"))
|
|
.filter!(a => prefix is null || a.name().baseName.startsWith(prefix))
|
|
.array.sort()
|
|
.map!(a => readChangelog(a, repoName))
|
|
.filter!(a => a.title.length > 0);
|
|
}
|
|
|
|
/**
|
|
Writes the overview headline of the manually listed changes in the ddoc format as list.
|
|
|
|
Params:
|
|
changes = parsed InputRange of changelog information
|
|
w = Output range to use
|
|
*/
|
|
void writeTextChangesHeader(Entries, Writer)(Entries changes, Writer w, string headline)
|
|
if (isInputRange!Entries && isOutputRange!(Writer, string))
|
|
{
|
|
// write the overview titles
|
|
w.formattedWrite("$(BUGSTITLE_TEXT_HEADER %s,\n\n", headline);
|
|
scope(exit) w.put("\n)\n\n");
|
|
foreach(change; changes)
|
|
{
|
|
w.formattedWrite("$(LI $(RELATIVE_LINK2 %s,%s))\n", change.basename, change.title);
|
|
}
|
|
}
|
|
/**
|
|
Writes the long description of the manually listed changes in the ddoc format as list.
|
|
|
|
Params:
|
|
changes = parsed InputRange of changelog information
|
|
w = Output range to use
|
|
*/
|
|
void writeTextChangesBody(Entries, Writer)(Entries changes, Writer w, string headline)
|
|
if (isInputRange!Entries && isOutputRange!(Writer, string))
|
|
{
|
|
w.formattedWrite("$(BUGSTITLE_TEXT_BODY %s,\n\n", headline);
|
|
scope(exit) w.put("\n)\n\n");
|
|
foreach(change; changes)
|
|
{
|
|
w.formattedWrite("$(LI $(LNAME2 %s,%s)\n", change.basename, change.title);
|
|
w.formattedWrite("$(CHANGELOG_SOURCE_FILE %s, %s)\n", change.repo, change.filePath);
|
|
scope(exit) w.put(")\n\n");
|
|
|
|
bool inPara, inCode;
|
|
foreach (line; change.description.splitLines)
|
|
{
|
|
if (line.stripLeft.startsWith("---", "```"))
|
|
{
|
|
if (inPara)
|
|
{
|
|
w.put(")\n");
|
|
inPara = false;
|
|
}
|
|
inCode = !inCode;
|
|
}
|
|
else if (!inCode && !inPara && !line.empty)
|
|
{
|
|
w.put("$(P\n");
|
|
inPara = true;
|
|
}
|
|
else if (inPara && line.empty)
|
|
{
|
|
w.put(")\n");
|
|
inPara = false;
|
|
}
|
|
w.put(line);
|
|
w.put("\n");
|
|
}
|
|
if (inPara)
|
|
w.put(")\n");
|
|
}
|
|
}
|
|
|
|
/**
|
|
Writes the fixed issued from Bugzilla in the ddoc format as a single list.
|
|
|
|
Params:
|
|
changes = parsed InputRange of changelog information
|
|
w = Output range to use
|
|
*/
|
|
void writeBugzillaChanges(Entries, Writer)(Entries entries, Writer w)
|
|
if (isOutputRange!(Writer, string))
|
|
{
|
|
immutable components = ["DMD Compiler", "Phobos", "Druntime", "dlang.org", "Optlink", "Tools", "Installer"];
|
|
immutable bugtypes = ["regression fixes", "bug fixes", "enhancements"];
|
|
|
|
foreach (component; components)
|
|
{
|
|
if (auto comp = component in entries)
|
|
{
|
|
foreach (bugtype; bugtypes)
|
|
if (auto bugs = bugtype in *comp)
|
|
{
|
|
w.formattedWrite("$(BUGSTITLE_BUGZILLA %s %s,\n\n", component, bugtype);
|
|
foreach (bug; sort!"a.id < b.id"(*bugs))
|
|
{
|
|
w.formattedWrite("$(LI $(BUGZILLA %s): %s)\n",
|
|
bug.id, bug.summary.escapeParens());
|
|
}
|
|
w.put(")\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
int main(string[] args)
|
|
{
|
|
auto outputFile = "./changelog.dd";
|
|
auto nextVersionString = "LATEST";
|
|
|
|
auto currDate = Clock.currTime();
|
|
auto nextVersionDate = "%s %02d, %04d"
|
|
.format(currDate.month.to!string.capitalize, currDate.day, currDate.year);
|
|
|
|
string previousVersion = "Previous version";
|
|
bool hideTextChanges = false;
|
|
string revRange;
|
|
|
|
auto helpInformation = getopt(
|
|
args,
|
|
std.getopt.config.passThrough,
|
|
"output|o", &outputFile,
|
|
"date", &nextVersionDate,
|
|
"version", &nextVersionString,
|
|
"prev-version", &previousVersion, // this can automatically be detected
|
|
"no-text", &hideTextChanges);
|
|
|
|
if (helpInformation.helpWanted)
|
|
{
|
|
`Changelog generator
|
|
Please supply a bugzilla version
|
|
./changed.d "v2.071.2..upstream/stable"`.defaultGetoptPrinter(helpInformation.options);
|
|
}
|
|
|
|
if (args.length >= 2)
|
|
{
|
|
revRange = args[1];
|
|
|
|
// extract the previous version
|
|
auto parts = revRange.split("..");
|
|
if (parts.length > 1)
|
|
previousVersion = parts[0].replace("v", "");
|
|
}
|
|
else
|
|
{
|
|
writeln("Skipped querying Bugzilla for changes. Please define a revision range e.g ./changed v2.072.2..upstream/stable");
|
|
}
|
|
|
|
// location of the changelog files
|
|
alias Repo = Tuple!(string, "name", string, "headline", string, "path", string, "prefix");
|
|
auto repos = [Repo("dmd", "Compiler changes", "changelog", "dmd."),
|
|
Repo("dmd", "Runtime changes", "changelog", "druntime."),
|
|
Repo("phobos", "Library changes", "changelog", null),
|
|
Repo("dlang.org", "Language changes", "language-changelog", null),
|
|
Repo("installer", "Installer changes", "changelog", null),
|
|
Repo("tools", "Tools changes", "changelog", null),
|
|
Repo("dub", "Dub changes", "changelog", null)];
|
|
|
|
auto changedRepos = repos
|
|
.map!(repo => Repo(repo.name, repo.headline, buildPath(__FILE_FULL_PATH__.dirName, "..", repo.name, repo.path), repo.prefix))
|
|
.filter!(r => r.path.exists);
|
|
|
|
// ensure that all files either end on .dd or .md
|
|
bool errors;
|
|
foreach (repo; changedRepos)
|
|
{
|
|
auto invalidFiles = repo.path
|
|
.dirEntries(SpanMode.shallow)
|
|
.filter!(a => !a.name.endsWith(".dd", ".md"));
|
|
if (!invalidFiles.empty)
|
|
{
|
|
invalidFiles.each!(f => stderr.writefln("ERROR: %s needs to have .dd or .md as extension", f.buildNormalizedPath));
|
|
errors = 1;
|
|
}
|
|
}
|
|
import core.stdc.stdlib : exit;
|
|
if (errors)
|
|
1.exit;
|
|
|
|
auto f = File(outputFile, "w");
|
|
auto w = f.lockingTextWriter();
|
|
w.put("Ddoc\n\n");
|
|
w.put("$(CHANGELOG_NAV_INJECT)\n\n");
|
|
|
|
// Accumulate Bugzilla issues
|
|
typeof(revRange.getBugzillaChanges) bugzillaChanges;
|
|
if (revRange.length)
|
|
bugzillaChanges = revRange.getBugzillaChanges;
|
|
|
|
// Accumulate contributors from the git log
|
|
version(Contributors_Lib)
|
|
{
|
|
import contributors : FindConfig, findAuthors, reduceAuthors;
|
|
typeof(revRange.findAuthors(FindConfig.init).reduceAuthors.array) authors;
|
|
if (revRange)
|
|
{
|
|
FindConfig config = {
|
|
cwd: __FILE_FULL_PATH__.dirName.asNormalizedPath.to!string,
|
|
};
|
|
config.mailmapFile = config.cwd.buildPath(".mailmap");
|
|
authors = revRange.findAuthors(config).reduceAuthors.array;
|
|
changelogStats.contributors = authors.save.walkLength;
|
|
}
|
|
}
|
|
|
|
{
|
|
w.formattedWrite("$(VERSION %s, =================================================,\n\n", nextVersionDate);
|
|
|
|
scope(exit) w.put(")\n");
|
|
|
|
|
|
if (!hideTextChanges)
|
|
{
|
|
// search for raw change files
|
|
auto changelogDirs = changedRepos
|
|
.map!(r => tuple!("headline", "changes")(r.headline, r.path.readTextChanges(r.name, r.prefix).array))
|
|
.filter!(r => !r.changes.empty);
|
|
|
|
// accumulate stats
|
|
{
|
|
changelogDirs.each!(c => c.changes.each!(c => changelogStats.addChangelogEntry(c)));
|
|
w.put("$(CHANGELOG_HEADER_STATISTICS\n");
|
|
scope(exit) w.put(")\n\n");
|
|
|
|
with(changelogStats)
|
|
{
|
|
auto changelog = changelogEntries > 0 ? "%d major change%s and".format(changelogEntries, changelogEntries > 1 ? "s" : "") : "";
|
|
w.put("$(VER) comes with {changelogEntries} {bugzillaIssues} fixed Bugzilla issue{bugzillaIssuesPlural}.
|
|
A huge thanks goes to the
|
|
$(LINK2 #contributors, {nrContributors} contributor{nrContributorsPlural})
|
|
who made $(VER) possible."
|
|
.replace("{bugzillaIssues}", bugzillaIssues.text)
|
|
.replace("{bugzillaIssuesPlural}", bugzillaIssues != 1 ? "s" : "")
|
|
.replace("{changelogEntries}", changelog)
|
|
.replace("{nrContributors}", contributors.text)
|
|
.replace("{nrContributorsPlural}", contributors != 1 ? "s" : "")
|
|
);
|
|
}
|
|
}
|
|
|
|
// print the overview headers
|
|
changelogDirs.each!(c => c.changes.writeTextChangesHeader(w, c.headline));
|
|
|
|
if (!revRange.empty)
|
|
w.put("$(CHANGELOG_SEP_HEADER_TEXT_NONEMPTY)\n\n");
|
|
|
|
w.put("$(CHANGELOG_SEP_HEADER_TEXT)\n\n");
|
|
|
|
// print the detailed descriptions
|
|
changelogDirs.each!(x => x.changes.writeTextChangesBody(w, x.headline));
|
|
|
|
if (revRange.length)
|
|
w.put("$(CHANGELOG_SEP_TEXT_BUGZILLA)\n\n");
|
|
}
|
|
else
|
|
{
|
|
w.put("$(CHANGELOG_SEP_NO_TEXT_BUGZILLA)\n\n");
|
|
}
|
|
|
|
// print the entire changelog history
|
|
if (revRange.length)
|
|
bugzillaChanges.writeBugzillaChanges(w);
|
|
}
|
|
|
|
version(Contributors_Lib)
|
|
if (revRange)
|
|
{
|
|
w.formattedWrite("$(D_CONTRIBUTORS_HEADER %d)\n", changelogStats.contributors);
|
|
w.put("$(D_CONTRIBUTORS\n");
|
|
authors.each!(a => w.formattedWrite(" $(D_CONTRIBUTOR %s)\n", a.name));
|
|
w.put(")\n");
|
|
w.put("$(D_CONTRIBUTORS_FOOTER)\n");
|
|
}
|
|
|
|
w.put("$(CHANGELOG_NAV_INJECT)\n\n");
|
|
|
|
// write own macros
|
|
w.formattedWrite(`Macros:
|
|
VER=%s
|
|
TITLE=Change Log: $(VER)
|
|
`, nextVersionString);
|
|
|
|
writefln("Change log generated to: '%s'", outputFile);
|
|
return 0;
|
|
}
|