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