Permalink
Newer
Older
100755 798 lines (702 sloc) 26.8 KB
1
#! /usr/bin/env python3
2
#
3
# Show a compact release note summary of a range of Git commits.
4
#
5
# Example use: release-notes.py --help
6
#
7
# Note: the first commit in the range is excluded!
8
#
9
# Requires:
10
# - GitPython https://pypi.python.org/pypi/GitPython/
11
# - You need to configure your local repo to pull the PR refs from
12
# GitHub. To do this, add a line like:
13
# fetch = +refs/pull/*/head:refs/pull/origin/*
14
# to the GitHub remote section of .git/config.
15
#
16
# Disclaimer: this program is provided without warranties of any kind,
17
# including suitability for any purpose. The author(s) will not be
18
# responsible if this script eats your left sock.
19
#
20
# Known limitations:
21
#
22
# - if different people with the same name contribute, this script
23
# will be confused. (it will merge their work under one entry).
24
# - the list of aliases below must be manually modified when
25
# contributors change their git name and/or email address.
26
#
27
# Note: there are unit tests in the release-notes subdirectory!
28
29
import sys
30
import itertools
31
import re
32
import os
34
import subprocess
35
from git import Repo
36
from optparse import OptionParser
37
from git.repo.fun import name_to_object
38
from git.util import Stats
39
40
### Global behavior constants ###
41
42
# minimum sha length to disambiguate
43
shamin = 9
44
45
# FIXME(knz): This probably needs to use the .mailmap.
46
author_aliases = {
47
'dianasaur323': "Diana Hsieh",
48
'kena': "Raphael 'kena' Poss",
49
'vivekmenezes': "Vivek Menezes",
50
'RaduBerinde': "Radu Berinde",
51
'Andy Kimball': "Andrew Kimball",
52
'marc': "Marc Berhault",
53
'Lauren': "Lauren Hirata",
54
'lhirata' : "Lauren Hirata",
55
'Emmanuel': "Emmanuel Sales",
56
'MBerhault': "Marc Berhault",
57
'Nate': "Nathaniel Stewart",
58
'a6802739': "Song Hao",
59
'Abhemailk abhi.madan01@gmail.com': "Abhishek Madan",
60
'rytaft': "Rebecca Taft",
61
'songhao': "Song Hao",
62
'solongordon': "Solon Gordon",
63
'tim-o': "Tim O'Brien",
64
'Amruta': "Amruta Ranade",
65
'yuzefovich': "Yahor Yuzefovich",
66
'madhavsuresh': "Madhav Suresh",
67
}
68
69
# FIXME(knz): This too.
70
crdb_folk = set([
71
"Abhishek Madan",
72
"Alex Robinson",
73
"Alfonso Subiotto Marqués",
74
"Amruta Ranade",
75
"Andrei Matei",
76
"Andrew Couch",
77
"Andrew Kimball",
78
"Andy Woods",
79
"Arjun Narayan",
80
"Ben Darnell",
82
"Bram Gruneir",
83
"Daniel Harrison",
84
"David Taylor",
85
"Diana Hsieh",
86
"Emmanuel Sales",
87
"Jesse Seldess",
88
"Jessica Edwards",
89
"Joseph Lowinske",
90
"Joey Pereira",
91
"Jordan Lewis",
92
"Justin Jaffray",
93
"Kuan Luo",
95
"Madhav Suresh",
96
"Marc Berhault",
97
"Masha Schneider",
98
"Matt Jibson",
99
"Matt Tracy",
100
"Nathan VanBenschoten",
101
"Nathaniel Stewart",
102
"Nikhil Benesch",
103
"Paul Bardea",
104
"Pete Vilter",
105
"Peter Mattis",
106
"Radu Berinde",
107
"Raphael 'kena' Poss",
108
"Rebecca Taft",
109
"Rich Loveland",
110
"Richard Wu",
111
"Ridwan Sharif",
112
"Sean Loiselle",
113
"Solon Gordon",
114
"Spencer Kimball",
115
"Tamir Duberstein",
116
"Tim O'Brien",
117
"Tobias Schottdorf",
119
"Vivek Menezes",
120
"Yahor Yuzefovich",
121
])
122
123
124
# Section titles for release notes.
125
relnotetitles = {
126
'cli change': "Command-Line Changes",
127
'sql change': "SQL Language Changes",
128
'admin ui change': "Admin UI Changes",
129
'general change': "General Changes",
130
'build change': "Build Changes",
131
'enterprise change': "Enterprise Edition Changes",
132
'backward-incompatible change': "Backward-Incompatible Changes",
133
'performance improvement': "Performance Improvements",
134
'bug fix': "Bug Fixes",
135
}
136
137
# Order in which to show the sections.
138
relnote_sec_order = [
139
'backward-incompatible change',
140
'general change',
141
'enterprise change',
142
'sql change',
143
'cli change',
144
'admin ui change',
145
'bug fix',
146
'performance improvement',
147
'build change',
148
]
149
150
# Release note category common misspellings.
151
cat_misspells = {
152
'sql' : 'sql change',
153
'general': 'general change',
154
'core change': 'general change',
155
'bugfix': 'bug fix',
156
'performance change' : 'performance improvement',
157
'performance' : 'performance improvement',
158
'ui' : 'admin ui change',
159
'backwards-incompatible change': 'backward-incompatible change',
160
'enterprise': 'enterprise change'
161
}
162
163
## Release note format ##
164
165
# The following release note formats have been seen in the wild:
166
#
167
# Release note (xxx): yyy <- canonical
168
# Release Notes: None
169
# Release note (xxx): yyy
170
# Release note (xxx) : yyy
171
# Release note: (xxx): yyy
172
# Release note: xxx: yyy
173
# Release note: (xxx) yyy
174
# Release note: yyy (no category)
175
# Release note (xxx, zzz): yyy
176
norelnote = re.compile(r'^[rR]elease [nN]otes?: *[Nn]one', flags=re.M)
177
# Captures :? (xxx) ?: yyy
178
form1 = r':? *\((?P<cat1>[^)]*)\) *:?'
179
# Captures : xxx: yyy - this must be careful not to capture too much, we just accept one or two words
180
form2 = r': *(?P<cat2>[^ ]+(?: +[^ ]+)?) *:'
181
# Captures : yyy - no category
182
form3 = r':(?P<cat3>)'
183
relnote = re.compile(r'(?:^|[\n\r])[rR]elease [nN]otes? *(?:' + form1 + '|' + form2 + '|' + form3 + r') *(?P<note>.*)$', flags=re.S)
184
185
coauthor = re.compile(r'^Co-authored-by: (?P<name>[^<]*) <(?P<email>.*)>', flags=re.M)
186
fixannot = re.compile(r'^([fF]ix(es|ed)?|[cC]lose(d|s)?) #', flags=re.M)
187
188
## Merge commit format ##
189
190
# The following merge commits have been seen in the wild:
191
#
192
# Merge pull request #XXXXX from ... <- GitHub merges
193
# Merge #XXXXX #XXXXX #XXXXX <- Bors merges
194
merge_numbers = re.compile(r'^Merge( pull request)?(?P<numbers>( #[0-9]+)+)')
195
196
### Initialization / option parsing ###
197
198
parser = OptionParser()
199
parser.add_option("-k", "--sort-key", dest="sort_key", default="title",
200
help="sort by KEY (pr, title, insertions, deletions, files, sha, date; default: title)", metavar="KEY")
201
parser.add_option("-r", "--reverse", action="store_true", dest="reverse_sort", default=False,
202
help="reverse sort")
203
parser.add_option("-f", "--from", dest="from_commit",
204
help="list history from COMMIT. Note: the first commit is excluded.", metavar="COMMIT")
205
parser.add_option("-t", "--until", dest="until_commit", default="HEAD",
206
help="list history up and until COMMIT (default: HEAD)", metavar="COMMIT")
207
parser.add_option("-p", "--pull-ref", dest="pull_ref_prefix", default="refs/pull/origin",
208
help="prefix for pull request refs (default: refs/pull/origin)", metavar="PREFIX")
209
parser.add_option("--hide-unambiguous-shas", action="store_true", dest="hide_shas", default=False,
210
help="omit commit SHAs from the release notes and per-contributor sections")
211
parser.add_option("--hide-per-contributor-section", action="store_true", dest="hide_per_contributor", default=False,
212
help="omit the per-contributor section")
213
parser.add_option("--hide-downloads-section", action="store_true", dest="hide_downloads", default=False,
214
help="omit the email sign-up and downloads section")
215
parser.add_option("--hide-header", action="store_true", dest="hide_header", default=False,
216
help="omit the title and date header")
217
218
(options, args) = parser.parse_args()
219
220
sortkey = options.sort_key
221
revsort = options.reverse_sort
222
pull_ref_prefix = options.pull_ref_prefix
223
hideshas = options.hide_shas
224
hidepercontributor = options.hide_per_contributor
225
hidedownloads = options.hide_downloads
226
hideheader = options.hide_header
227
228
repo = Repo('.')
229
heads = repo.heads
230
231
try:
232
firstCommit = repo.commit(options.from_commit)
233
except:
234
print("Unable to find the first commit of the range.", file=sys.stderr)
235
print("No ref named %s." % options.from_commit, file=sys.stderr)
236
exit(0)
237
238
try:
239
commit = repo.commit(options.until_commit)
240
except:
241
print("Unable to find the last commit of the range.", file=sys.stderr)
242
print("No ref named %s." % options.until_commit, file=sys.stderr)
243
exit(0)
244
245
if commit == firstCommit:
246
print("Commit range is empty!", file=sys.stderr)
247
print(parser.get_usage(), file=sys.stderr)
248
print("Example use:", file=sys.stderr)
249
print(" %s --help" % sys.argv[0], file=sys.stderr)
250
print(" %s --from xxx >output.md" % sys.argv[0], file=sys.stderr)
251
print(" %s --from xxx --until yyy >output.md" % sys.argv[0], file=sys.stderr)
252
print("Note: the first commit is excluded. Use e.g.: --from <prev-release-tag> --until <new-release-candidate-sha>", file=sys.stderr)
253
exit(0)
254
255
# Check that pull_ref_prefix is valid
256
testrefname = "%s/1" % pull_ref_prefix
257
try:
258
repo.commit(testrefname)
259
except:
260
print("Unable to find pull request refs at %s." % pull_ref_prefix, file=sys.stderr)
261
print("Is your repo set up to fetch them? Try adding", file=sys.stderr)
262
print(" fetch = +refs/pull/*/head:%s/*" % pull_ref_prefix, file=sys.stderr)
263
print("to the GitHub remote section of .git/config.", file=sys.stderr)
264
exit(0)
266
### Reading data from repository ###
267
268
def identify_commit(commit):
269
return '%s ("%s", %s)' % (
270
commit.hexsha, commit.message.split('\n',1)[0],
271
datetime.datetime.fromtimestamp(commit.committed_date).ctime())
272
273
# Is the first commit reachable from the current one?
274
base = repo.merge_base(firstCommit, commit)
275
if len(base) == 0:
276
print("error: %s:%s\nand %s:%s\nhave no common ancestor" % (
277
options.from_commit, identify_commit(firstCommit),
278
options.until_commit, identify_commit(commit)), file=sys.stderr)
279
exit(1)
280
commonParent = base[0]
281
if firstCommit != commonParent:
282
print("warning: %s:%s\nis not an ancestor of %s:%s!" % (
283
options.from_commit, identify_commit(firstCommit),
284
options.until_commit, identify_commit(commit)), file=sys.stderr)
285
print(file=sys.stderr)
286
ageindays = int((firstCommit.committed_date - commonParent.committed_date)/86400)
287
prevlen = sum((1 for x in repo.iter_commits(commonParent.hexsha + '...' + firstCommit.hexsha)))
288
print("The first common ancestor is %s" % identify_commit(commonParent), file=sys.stderr)
289
print("which is %d commits older than %s:%s\nand %d days older. Using that as origin." %\
290
(prevlen, options.from_commit, identify_commit(firstCommit), ageindays), file=sys.stderr)
291
print(file=sys.stderr)
292
firstCommit = commonParent
293
options.from_commit = commonParent.hexsha
295
print("Changes from\n%s\nuntil\n%s" % (identify_commit(firstCommit), identify_commit(commit)), file=sys.stderr)
296
297
release_notes = {}
298
missing_release_notes = []
299
300
def collect_authors(commit):
301
authors = set()
302
author = author_aliases.get(commit.author.name, commit.author.name)
303
if author != 'GitHub':
304
authors.add(author)
305
author = author_aliases.get(commit.committer.name, commit.committer.name)
306
if author != 'GitHub':
307
authors.add(author)
308
for m in coauthor.finditer(commit.message):
309
aname = m.group('name').strip()
310
author = author_aliases.get(aname, aname)
311
authors.add(author)
312
return authors
313
314
315
def extract_release_notes(pr, title, commit):
316
authors = collect_authors(commit)
317
318
msglines = commit.message.split('\n')
319
curnote = []
320
innote = False
321
foundnote = False
322
cat = None
323
notes = []
324
for line in msglines:
325
m = coauthor.search(line)
326
if m is not None:
327
# A Co-authored-line finishes the parsing of the commit message,
328
# because it's included at the end only.
329
break
330
331
m = fixannot.search(line)
332
if m is not None:
333
# Fix/Close etc. Ignore.
334
continue
335
336
m = norelnote.search(line)
337
if m is not None:
338
# Release note: None
339
#
340
# Remember we found a note (so the commit is not marked as "missing
341
# a release note"), but we won't collect it.
342
foundnote = True
343
continue
344
345
m = relnote.search(line)
346
if m is None:
347
# Current line does not contain a release note separator.
348
# If we were already collecting a note, continue collecting it.
349
if innote:
350
curnote.append(line)
351
continue
352
353
# We have a release note boundary. If we were collecting a
354
# note already, complete it.
355
if innote:
356
notes.append((cat, curnote))
357
curnote = []
358
innote = False
359
360
# Start a new release note.
361
362
firstline = m.group('note').strip()
363
if firstline.lower() == 'none':
364
# Release note: none - there's no note yet.
365
continue
366
foundnote = True
367
innote = True
368
369
# Capitalize the first line.
370
if firstline != "":
371
firstline = firstline[0].upper() + firstline[1:]
372
373
curnote = [firstline]
374
cat = m.group('cat1')
375
if cat is None:
376
cat = m.group('cat2')
377
if cat is None:
378
cat = 'missing category'
379
# Normalize to tolerate various capitalizations.
380
cat = cat.lower()
381
# If there are multiple categories separated by commas or slashes, use the first as grouping key.
382
cat = cat.split(',', 1)[0]
383
cat = cat.split('/', 1)[0]
384
# If there is any misspell, correct it.
385
if cat in cat_misspells:
386
cat = cat_misspells[cat]
387
388
if innote:
389
notes.append((cat, curnote))
390
391
# At the end the notes will be presented in reverse order, because
392
# we explore the commits in reverse order. However within 1 commit
393
# the notes are in the correct order. So reverse them upfront here,
394
# so that the 2nd reverse gets them in the right order again.
395
for cat, note in reversed(notes):
396
completenote(commit, cat, note, authors, pr, title)
397
398
missing_item = None
399
if not foundnote:
400
# Missing release note. Keep track for later.
401
missing_item = makeitem(pr, title, commit.hexsha[:shamin], authors)
402
return missing_item, authors
404
def makeitem(pr, prtitle, sha, authors):
405
return {'authors': ', '.join(sorted(authors)),
406
'sha': sha,
409
'note': None}
410
411
def completenote(commit, cat, curnote, authors, pr, title):
412
notemsg = '\n'.join(curnote).strip()
413
item = makeitem(pr, title, commit.hexsha[:shamin], authors)
414
item['note'] = notemsg
415
416
# Now collect per category.
417
catnotes = release_notes.get(cat, [])
418
catnotes.append(item)
419
release_notes[cat] = catnotes
420
421
per_group_history = {}
422
individual_authors = set()
423
allprs = set()
424
425
spinner = itertools.cycle(['/', '-', '\\', '|'])
426
counter = 0
427
428
def spin():
429
global counter
430
# Display a progress bar
431
counter += 1
432
if counter % 10 == 0:
433
if counter % 100 == 0:
434
print("\b..", end='', file=sys.stderr)
435
print("\b", end='', file=sys.stderr)
436
print(next(spinner), end='', file=sys.stderr)
437
sys.stderr.flush()
438
439
# This function groups and counts all the commits that belong to a particular PR.
440
# Some description is in order regarding the logic here: it should visit all
441
# commits that are on the PR and only on the PR. If there's some secondary
442
# branch merge included on the PR, as long as those commits don't otherwise end
443
# up reachable from the target branch, they'll be included. If there's a back-
444
# merge from the target branch, that should be excluded.
445
#
446
# Examples:
447
#
448
# ### secondary branch merged into PR
449
#
450
# Dev branched off of K, made a commit J, made a commit G while someone else
451
# committed H, merged H from the secondary branch to the topic branch in E,
452
# made a final commit in C, then merged to master in A.
453
#
454
# A <-- master
455
# |\
456
# | \
457
# B C <-- PR tip
458
# | |
459
# | |
460
# D E <-- secondary merge
461
# | |\
462
# | | \
463
# F G H <-- secondary branch
464
# | | /
465
# | |/
466
# I J
467
# | /
468
# |/
469
# K <-- merge base
470
#
471
# C, E, G, H, and J will each be checked. None of them are ancestors of B,
472
# so they will all be visited. E will be not be counted because the message
473
# starts with "Merge", so in the end C, G, H, and J will be included.
474
#
475
# ### back-merge from target branch
476
#
477
# Dev branched off H, made one commit G, merged the latest F from master in E,
478
# made one final commit in C, then merged the PR.
479
#
480
# A <-- master
481
# |\
482
# | \
483
# B C <-- PR tip
484
# | |
485
# | |
486
# D E <-- back-merge
487
# | /|
488
# |/ |
489
# F G
490
# | /
491
# |/
492
# H <-- merge base
493
#
494
# C, E, F, and G will each be checked. F is an ancestor of B, so it will be
495
# excluded. E starts with "Merge", so it will not be counted. Only C and G will
496
# have statistics included.
497
def analyze_pr(merge, pr):
498
allprs.add(pr)
499
500
refname = pull_ref_prefix + "/" + pr[1:]
501
tip = name_to_object(repo, refname)
502
503
noteexpr = re.compile("^%s: (?P<message>.*) r=.* a=.*" % pr[1:], flags=re.M)
504
m = noteexpr.search(merge.message)
505
note = ''
506
if m is None:
507
# GitHub merge
508
note = merge.message.split('\n',3)[2]
509
else:
510
# Bors merge
511
note = m.group('message')
512
note = note.strip()
513
514
merge_base_result = repo.merge_base(merge.parents[0], tip)
515
if len(merge_base_result) == 0:
516
print("uh-oh! can't find merge base! pr", pr, file=sys.stderr)
517
exit(-1)
518
519
merge_base = merge_base_result[0]
520
521
commits_to_analyze = [tip]
522
seen_commits = set()
524
missing_items = []
525
authors = set()
526
ncommits = 0
527
while len(commits_to_analyze) > 0:
528
spin()
529
530
commit = commits_to_analyze.pop(0)
531
if commit in seen_commits:
532
# We may be seeing the same commit twice if a feature branch has
533
# been forked in sub-branches. Just skip over what we've seen
534
# already.
535
continue
536
seen_commits.add(commit)
537
538
if not commit.message.startswith("Merge"):
539
missing_item, prauthors = extract_release_notes(pr, note, commit)
540
authors.update(prauthors)
542
if missing_item is not None:
543
missing_items.append(missing_item)
544
545
for parent in commit.parents:
546
if not repo.is_ancestor(parent, merge.parents[0]):
547
# We're not yet back on the main branch. Just continue digging.
548
commits_to_analyze.append(parent)
549
else:
550
# The parent is on the main branch. We're done digging.
551
# print("found merge parent, stopping. final authors", authors)
552
pass
553
554
if ncommits == len(missing_items):
555
# None of the commits found had a release note. List them.
556
for item in missing_items:
557
missing_release_notes.append(item)
558
559
text = repo.git.diff(merge_base.hexsha, tip.hexsha, '--', numstat=True)
560
stats = Stats._list_from_string(repo, text)
561
562
collect_item(pr, note, merge.hexsha[:shamin], ncommits, authors, stats.total, merge.committed_date)
564
def collect_item(pr, prtitle, sha, ncommits, authors, stats, prts):
565
individual_authors.update(authors)
566
if len(authors) == 0:
567
authors.add("Unknown Author")
568
item = makeitem(pr, prtitle, sha, authors)
569
item.update({'ncommits': ncommits,
570
'insertions': stats['insertions'],
571
'deletions': stats['deletions'],
572
'files': stats['files'],
573
'lines': stats['lines'],
574
'date': datetime.date.fromtimestamp(prts).isoformat(),
575
})
576
577
history = per_group_history.get(item['authors'], [])
578
history.append(item)
579
per_group_history[item['authors']] = history
580
581
def analyze_standalone_commit(commit):
582
# Some random out-of-branch commit. Let's not forget them.
583
authors = collect_authors(commit)
584
title = commit.message.split('\n',1)[0].strip()
585
item = makeitem('#unknown', title, commit.hexsha[:shamin], authors)
586
missing_release_notes.append(item)
587
collect_item('#unknown', title, commit.hexsha[:shamin], 1, authors, commit.stats.total, commit.committed_date)
588
589
while commit != firstCommit:
590
spin()
591
592
ctime = datetime.datetime.fromtimestamp(commit.committed_date).ctime()
593
numbermatch = merge_numbers.search(commit.message)
594
# Analyze the commit
595
if numbermatch is not None:
596
prs = numbermatch.group("numbers").strip().split(" ")
597
for pr in prs:
598
print(" \r%s (%s) " % (pr, ctime), end='', file=sys.stderr)
599
analyze_pr(commit, pr)
601
print(" \r%s (%s) " % (commit.hexsha[:shamin], ctime), end='', file=sys.stderr)
602
analyze_standalone_commit(commit)
603
604
if len(commit.parents) == 0:
605
break
606
commit = commit.parents[0]
607
608
allgroups = list(per_group_history.keys())
609
allgroups.sort(key=lambda x:x.lower())
611
print("\b\nComputing first-time contributors...", end='', file=sys.stderr)
612
613
ext_contributors = individual_authors - crdb_folk
614
firsttime_contributors = []
615
for a in individual_authors:
616
# Find all aliases known for this person
617
aliases = [a]
618
for alias, name in author_aliases.items():
619
if name == a:
620
aliases.append(alias)
621
# Collect the history for every alias
622
hist = b''
623
for al in aliases:
624
spin()
625
c = subprocess.run(["git", "log", "--author=%s" % al, options.from_commit, '-n', '1'], stdout=subprocess.PIPE, check=True)
626
hist += c.stdout
627
if len(hist) == 0:
628
# No commit from that author older than the first commit
629
# selected, so that's a first-time author.
630
firsttime_contributors.append(a)
631
632
print("\b\n", file=sys.stderr)
633
sys.stderr.flush()
634
635
### Presentation of results ###
636
637
## Print the release notes.
638
639
# Start with known sections.
640
641
current_version = subprocess.check_output(["git", "describe", "--tags", options.until_commit], universal_newlines=True).strip()
642
previous_version = subprocess.check_output(["git", "describe", "--tags", options.from_commit], universal_newlines=True).strip()
643
644
if not hideheader:
645
print("---")
646
print("title: What&#39;s New in", current_version)
647
print("toc: false")
648
print("summary: Additions and changes in CockroachDB version", current_version, "since version", previous_version)
649
print("---")
650
print()
651
print("## " + time.strftime("%B %d, %Y"))
652
print()
654
## Print the release notes sign-up and Downloads section.
655
if not hidedownloads:
656
print("""Get future release notes emailed to you:
657
658
<div class="hubspot-install-form install-form-1 clearfix">
659
<script>
660
hbspt.forms.create({
661
css: '',
662
cssClass: 'install-form',
663
portalId: '1753393',
664
formId: '39686297-81d2-45e7-a73f-55a596a8d5ff',
665
formInstanceId: 1,
666
target: '.install-form-1'
667
});
668
</script>
669
</div>""")
670
print()
671
672
print("""### Downloads
673
674
<div id="os-tabs" class="clearfix">
675
<a href="https://binaries.cockroachdb.com/cockroach-""" + current_version + """.darwin-10.9-amd64.tgz"><button id="mac" data-eventcategory="mac-binary-release-notes">Mac</button></a>
676
<a href="https://binaries.cockroachdb.com/cockroach-""" + current_version + """.linux-amd64.tgz"><button id="linux" data-eventcategory="linux-binary-release-notes">Linux</button></a>
677
<a href="https://binaries.cockroachdb.com/cockroach-""" + current_version + """.windows-6.2-amd64.zip"><button id="windows" data-eventcategory="windows-binary-release-notes">Windows</button></a>
678
<a href="https://binaries.cockroachdb.com/cockroach-""" + current_version + """.src.tgz"><button id="source" data-eventcategory="source-release-notes">Source</button></a>
679
</div>""")
681
682
seenshas = set()
683
seenprs = set()
684
def renderlinks(item):
685
ret = '[%(pr)s][%(pr)s]' % item
686
seenprs.add(item['pr'])
687
if not hideshas:
688
ret += ' [%(sha)s][%(sha)s]' % item
689
seenshas.add(item['sha'])
690
return ret
691
692
for sec in relnote_sec_order:
693
r = release_notes.get(sec, None)
694
if r is None:
695
# No change in this section, nothing to print.
696
continue
697
sectitle = relnotetitles[sec]
698
print("###", sectitle)
699
print()
700
701
for item in reversed(r):
702
print("-", item['note'].replace('\n', '\n '), renderlinks(item))
703
704
print()
705
706
extrasec = set()
707
for sec in release_notes:
708
if sec in relnote_sec_order:
709
# already handled above, don't do anything.
710
continue
711
extrasec.add(sec)
712
if len(extrasec) > 0 or len(missing_release_notes) > 0:
713
print("### Miscellaneous")
714
print()
715
if len(extrasec) > 0:
716
extrasec_sorted = sorted(list(extrasec))
717
for extrasec in extrasec_sorted:
718
print("#### %s" % extrasec.title())
719
print()
720
for item in release_notes[extrasec]:
721
print("-", item['note'].replace('\n', '\n '), renderlinks(item))
722
print()
723
724
if len(missing_release_notes) > 0:
725
print("#### Changes without release note annotation")
726
print()
727
for item in missing_release_notes:
728
authors = item['authors']
729
print("- [%(pr)s][%(pr)s] [%(sha)s][%(sha)s] %(title)s" % item, "(%s)" % authors)
730
seenshas.add(item['sha'])
731
seenprs.add(item['pr'])
732
print()
733
734
## Print the Doc Updates section.
735
print("### Doc Updates")
737
print("Docs team: Please add these manually.")
740
## Print the Contributors section.
741
print("### Contributors")
742
print()
743
print("This release includes %d merged PR%s by %s author%s." %
744
(len(allprs), len(allprs) != 1 and "s" or "",
745
len(individual_authors), (len(individual_authors) != 1 and "s" or ""),
747
748
ext_contributors = individual_authors - crdb_folk
749
750
notified_authors = sorted(set(ext_contributors) | set(firsttime_contributors))
751
if len(notified_authors) > 0:
752
print("We would like to thank the following contributors from the CockroachDB community:")
754
for person in notified_authors:
755
print("-", person, end='')
756
if person in firsttime_contributors:
758
if person in crdb_folk:
759
annot = ", CockroachDB team member"
760
print(" (first-time contributor%s)" % annot, end='')
761
print()
763
764
## Print the per-author contribution list.
765
if not hidepercontributor:
766
print("### PRs merged by contributors")
767
print()
768
if not hideshas:
769
fmt = " - %(date)s [%(pr)-6s][%(pr)-6s] [%(sha)s][%(sha)s] (+%(insertions)4d -%(deletions)4d ~%(lines)4d/%(files)2d) %(title)s"
771
fmt = " - %(date)s [%(pr)-6s][%(pr)-6s] (+%(insertions)4d -%(deletions)4d ~%(lines)4d/%(files)2d) %(title)s"
773
for group in allgroups:
774
items = per_group_history[group]
775
print("- %s:" % group)
776
items.sort(key=lambda x:x[sortkey],reverse=not revsort)
777
for item in items:
778
print(fmt % item, end='')
779
if not hideshas:
780
seenshas.add(item['sha'])
781
seenprs.add(item['pr'])
782
783
ncommits = item['ncommits']
784
if ncommits > 1:
786
print("%d commits" % ncommits, end='')
787
print(")", end='')
788
print()
789
print()
790
print()
791
792
# Link the PRs and SHAs
793
for pr in sorted(seenprs):
794
print("[%s]: https://github.com/cockroachdb/cockroach/pull/%s" % (pr, pr[1:]))
795
for sha in sorted(seenshas):
796
print("[%s]: https://github.com/cockroachdb/cockroach/commit/%s" % (sha, sha))
797
print()