aboutsummaryrefslogtreecommitdiff
path: root/.github/scripts/generate_release_notes.py
blob: c70619121f6a99e6b0f2fa456a3dd91e05b3f2f3 (plain) (blame)
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
#!/usr/bin/env python3
# PSn00bSDK release notes generator
# (C) 2021 spicyjpeg - MPL licensed

import sys, re
from time     import gmtime, strptime, struct_time
from argparse import ArgumentParser, FileType

## Helpers

VERSION_REGEX   = re.compile(r"^(?:refs\/tags\/)?(?:v|ver|version|release)? *(.*)")
TEXT_WRAP_REGEX = re.compile(r"(?<!\n)[ \t]*?\n[ \t]*(?!\n)", re.MULTILINE)

def parse_date(date):
	if isinstance(date, struct_time):
		return date

	return strptime(date.strip(), "%Y-%m-%d")

def normalize_version(version):
	return VERSION_REGEX.match(version.lower()).group(1)

def unwrap_text(text):
	return TEXT_WRAP_REGEX.sub(" ", text.strip())

def deduplicate_authors(authors):
	_authors = []
	folded   = []

	for name in authors:
		if (fold := name.lower()) in folded:
			continue

		_authors.append(name)
		folded.append(fold)

	_authors.sort()
	return _authors

## Changelog parser

BLOCK_REGEX  = re.compile(r"^#{2,}[ \t]*([0-9]{4}-[0-9]{2}-[0-9]{2})(?:[:\- \t]+(.*?))?$", re.MULTILINE)
AUTHOR_REGEX = re.compile(r"^([A-Za-z0-9_].*?)[ \t]*:.*?$", re.MULTILINE)

def parse_authors(block):
	# [ _crap, author, body, author, body, ... ]
	items = AUTHOR_REGEX.split(block.strip())

	if items[0].strip():
		raise RuntimeError("a block has changes listed with no author specified")

	authors = {}
	for i in range(1, len(items), 2):
		name, body = items[i:i + 2]
		if (name := name.strip()) not in authors:
			authors[name] = ""

		authors[name] += body.strip()

	return authors

def parse_blocks(changelog):
	# [ _crap, date, version, body, date, version, body, ... ]
	items = BLOCK_REGEX.split(changelog.strip())

	# Iterate over all blocks from bottom to top (i.e. oldest first) and group
	# them by the version number of the block they precede.
	blocks = []

	for i in range(len(items), 1, -3):
		date, version, body = items[i - 3:i]

		blocks.append(( parse_date(date), parse_authors(body) ))

		if version:
			yield normalize_version(version), tuple(blocks)
			blocks = []

## Release notes generation

VERSION_TEMPLATE = """New in version **{version}** (contributed by {authors}):

{changes}

"""
NOTES_TEMPLATE = """{notes}

------------------------------------------------------------------------------------------------------
_These notes have been generated automatically. See the changelog or commit history for more details._
"""

NO_VERSIONS_TEMPLATE = "No information available about this release."
AUTHOR_LINK_TEMPLATE = "**{0}**"
#AUTHOR_LINK_TEMPLATE = "[{0}](https://github.com/{0})"

def generate_notes(versions):
	notes = ""

	for version, ( authors, changes ) in versions.items():
		if not changes:
			continue

		_authors = deduplicate_authors(authors)
		_authors = map(AUTHOR_LINK_TEMPLATE.format, _authors)

		notes += VERSION_TEMPLATE.format(
			version = version,
			authors = ", ".join(_authors),
			changes = "\n\n".join(changes)
		)

	if not notes:
		notes = NO_VERSIONS_TEMPLATE

	return NOTES_TEMPLATE.format(notes = unwrap_text(notes))

## Main

def get_args():
	parser = ArgumentParser(
		description = "Generates and outputs release notes from a Markdown changelog file."
	)
	parser.add_argument(
		"changelog",
		type    = FileType("rt"),
		help    = "Markdown changelog file to parse"
	)
	parser.add_argument(
		"-o", "--output",
		type    = FileType("wt"),
		default = sys.stdout,
		help    = "where to output release notes (stdout by default)",
		metavar = "file"
	)
	parser.add_argument(
		"-v", "--version",
		action  = "append",
		type    = str,
		help    = "ignore all changes not belonging to a version (can be specified multiple times)",
		metavar = "name"
	)
	parser.add_argument(
		"-f", "--from-date",
		type    = parse_date,
		default = parse_date("2000-01-01"),
		help    = "ignore all changes before date",
		metavar = "yyyy-mm-dd"
	)
	parser.add_argument(
		"-t", "--to-date",
		type    = parse_date,
		default = gmtime(),
		help    = "ignore all changes after date",
		metavar = "yyyy-mm-dd"
	)

	return parser.parse_args()

def main():
	args         = get_args()
	version_list = list(map(normalize_version, args.version or []))

	with args.changelog as _file:
		changelog = _file.read()

	# Iterate over all blocks in the changelog and sort them by version,
	# merging all changes and authors for each version.
	versions = {}

	for version, blocks in parse_blocks(changelog):
		if version_list and (version not in version_list):
			continue

		if version not in versions:
			versions[version] = [], []

		_authors, _changes = versions[version]

		for date, authors in blocks:
			if date < args.from_date or date > args.to_date:
				continue

			_authors.extend(authors.keys())
			_changes.extend(authors.values())

	notes = generate_notes(versions)

	with args.output as _file:
		_file.write(notes)

if __name__ == "__main__":
	main()