@@ -146,6 +146,34 @@ string escapeParens(string input)
146146 return input.translate(parenToMacro);
147147}
148148
149+ /**
150+ Get the date of the previous release, which is the start of the revision range
151+
152+ This is more reliable than the date of the oldest commit in the range, because
153+ merging in an unrelated history (like the spec sources) drags in commits that
154+ are years older than the previous release.
155+ */
156+ Nullable! DateTime getPreviousReleaseDateTime (string revRange)
157+ {
158+ auto parts = revRange.split(" .." );
159+ if (parts.length < 2 || parts[0 ].empty)
160+ return Nullable! (DateTime ).init;
161+
162+ foreach (repo; [" dmd" , " phobos" , " dlang.org" , " tools" , " installer" ]
163+ .map! (r => buildPath(" .." , r)))
164+ {
165+ auto cmd = [" git" , " -C" , repo, " log" , " -1" , " --no-patch" , " --no-notes"
166+ , " --date=format-local:%Y-%m-%dT%H:%M:%S" , " --pretty=%cd"
167+ , parts[0 ]];
168+ auto p = pipeProcess(cmd, Redirect.stdout);
169+ auto lines = p.stdout.byLineCopy.map! strip.filter! (l => ! l.empty).array;
170+ if (wait(p.pid) != 0 || lines.empty)
171+ continue ;
172+ return DateTime .fromISOExtString(lines.front).nullable;
173+ }
174+ return Nullable! (DateTime ).init;
175+ }
176+
149177Nullable! DateTime getFirstDateTime (string revRange)
150178{
151179 DateTime [] all;
@@ -309,8 +337,6 @@ Nullable!int getBugzillaId(string body_)
309337GithubIssue[][string /* type*/ ][string /* comp*/ ] getGithubIssuesRest(string revRange,
310338 const DateTime startDate, const DateTime endDate, const string bearer)
311339{
312- import std.algorithm.searching : canFind;
313-
314340 GithubIssue[][string ][string ] ret;
315341 // Keep this list of comps in sync with the switch statement in writeBugzillaChanges
316342 string [2 ][] comps =
@@ -332,14 +358,10 @@ GithubIssue[][string /*type*/ ][string /*comp*/] getGithubIssuesRest(string revR
332358 continue ;
333359
334360 GithubIssue[][string /* type */ ] tmp;
335- GithubIssue[] ghi = getGithubIssuesRest(" dlang" , project, startDate,
336- endDate, bearer);
361+ GithubIssue[] ghi = getGithubIssuesRest(" dlang" , project,
362+ issues.githubIssueIds[project], startDate, endDate, bearer);
337363 foreach (jt; ghi)
338364 {
339- // ignore if closed issue does not have a git log reference
340- if (! issues.githubIssueIds[project].canFind(jt.id))
341- continue ;
342-
343365 GithubIssue[]* p = jt.type in tmp;
344366 if (p ! is null )
345367 {
@@ -356,66 +378,59 @@ GithubIssue[][string /*type*/ ][string /*comp*/] getGithubIssuesRest(string revR
356378}
357379
358380/**
359- Get closed issues of a github project
381+ Get the closed issues of a github project that are referenced in the git log
382+
383+ Only issues closed in the `[startDate, endDate]` window are returned, so that
384+ stale references to unrelated issues with the same number don't end up in the
385+ changelog.
360386
361387Params:
362388 project = almost always the dlang github project
363389 repo = the name of the repo to get the closed issues for
390+ numbers = the issue numbers referenced by the commits of the release
391+ startDate = issues closed before this date are not part of this release
364392 endDate = the cutoff date for closed issues
365393 bearer = the classic github bearer token
366394*/
367395GithubIssue[] getGithubIssuesRest (const string project, const string repo
368- , const DateTime startDate, const DateTime endDate, const string bearer)
396+ , const int [] numbers, const DateTime startDate, const DateTime endDate
397+ , const string bearer)
369398{
370- import std.regex : ctRegex, matchFirst;
371-
372399 GithubIssue[] ret;
373- // Initial URL for first request
374- string nextUrl = (" https://api.github.com/repos/%s/%s/issues?per_page=100"
375- ~ " &state=closed&since=%s" )
376- .format(project, repo, startDate.toISOExtString() ~ " Z" );
377400
378- foreach (_; 0 .. 100 )
379- { // 1000 issues per release should be enough
380- if (nextUrl.empty)
381- break ;
401+ foreach (number; numbers )
402+ {
403+ string url = " https://api.github.com/repos/%s/%s/issues/%d "
404+ .format(project, repo, number) ;
382405
383- HTTP http = HTTP (nextUrl );
406+ HTTP http = HTTP (url );
384407 http.addRequestHeader(" Accept" , " application/vnd.github+json" );
385408 http.addRequestHeader(" X-GitHub-Api-Version" , " 2022-11-28" );
386409 http.addRequestHeader(" Authorization" , bearer);
387410
388411 char [] response;
389- string linkHeader ;
390- try
412+ int statusCode ;
413+ http.onReceive = ( ubyte [] d)
391414 {
392- http.onReceive = (ubyte [] d)
393- {
394- response ~= cast (char [])d;
395- return d.length;
396- };
397- http.onReceiveHeader = (const (char )[] key, const (char )[] value)
398- {
399- if (key == " link" )
400- linkHeader = value.idup;
401- };
402- http.perform();
403- }
404- catch (Exception e)
415+ response ~= cast (char [])d;
416+ return d.length;
417+ };
418+ http.onReceiveStatusLine = (HTTP .StatusLine line)
405419 {
406- throw e;
407- }
420+ statusCode = line.code;
421+ };
422+ http.perform();
423+
424+ // referenced issues may live in another repository
425+ if (statusCode == 404 || statusCode == 410 )
426+ continue ;
427+ enforce(statusCode == 200 , " %s returned status %d:\n %s"
428+ .format(url, statusCode, cast (string )response));
429+
430+ JSONValue it = parseJSON(cast (string )response);
431+ enforce(it.type == JSONType.object, it.toPrettyString()
432+ ~ " \n Must be an object" );
408433
409- string s = cast (string )response;
410- JSONValue j = parseJSON(s);
411- enforce(j.type == JSONType.array, j.toPrettyString()
412- ~ " \n Must be an array" );
413- JSONValue[] arr = j.arrayNoRef();
414- if (arr.empty)
415- {
416- break ;
417- }
418- foreach (it; arr)
419434 {
420435 GithubIssue tmp;
421436 // Issues and pull request are both returned by the github api
@@ -455,13 +470,16 @@ GithubIssue[] getGithubIssuesRest(const string project, const string repo
455470 const (JSONValue)* mem = " closed_at" in it;
456471 enforce(mem ! is null , it.toPrettyString()
457472 ~ " \n must contain 'closed_at'" );
458- enforce((* mem).type == JSONType.string , (* mem).toPrettyString()
459- ~ " \n 'closed_at' must be an string" );
473+ // still open, so not part of this release
474+ if ((* mem).type != JSONType.string )
475+ continue ;
460476 string d = (* mem).get ! string ();
461477 d = d.endsWith(" Z" )
462478 ? d[0 .. $ - 1 ]
463479 : d;
464480 tmp.closedAt = DateTime .fromISOExtString(d);
481+ if (tmp.closedAt < startDate || tmp.closedAt > endDate)
482+ continue ;
465483 }
466484 {
467485 const (JSONValue)* mem = " labels" in it;
@@ -502,19 +520,6 @@ GithubIssue[] getGithubIssuesRest(const string project, const string repo
502520 }
503521 ret ~= tmp;
504522 }
505-
506- // Parse Link header for cursor-based pagination
507- // Format: <url>; rel="next", <url>; rel="last"
508- nextUrl = null ;
509- if (! linkHeader.empty)
510- {
511- enum linkRe = ctRegex! ` <([^>]+)>;\s*rel="next"` ;
512- auto m = matchFirst(linkHeader, linkRe);
513- if (! m.empty)
514- {
515- nextUrl = m[1 ];
516- }
517- }
518523 }
519524 return ret;
520525}
@@ -813,7 +818,9 @@ Please supply a bugzilla version
813818 , githubClassicTokenFileName));
814819 const string githubToken = readText(githubClassicTokenFileName).strip();
815820
816- Nullable! (DateTime ) firstDate = getFirstDateTime(revRange);
821+ Nullable! (DateTime ) firstDate = getPreviousReleaseDateTime(revRange);
822+ if (firstDate.isNull())
823+ firstDate = getFirstDateTime(revRange);
817824 enforce(! firstDate.isNull(), " Couldn't find a date from the revRange" );
818825 githubChanges = getGithubIssuesRest(revRange, firstDate.get (), cast (DateTime )currDate
819826 , githubToken);
0 commit comments