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
|
#!/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)? *(.*)")
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)
## 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)
FIRST_VERSION = "initial"
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]
name = name.strip()
body = body.strip()
if name not in authors:
authors[name] = ""
authors[name] += body
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).
last_version = FIRST_VERSION
for i in range(len(items), 1, -3):
date, version, body = items[i - 3:i]
# If no version is present in the header, assume it's the same as the
# previous block's version.
if version:
version = normalize_version(version)
last_version = version
else:
version = last_version
yield parse_date(date), version, parse_authors(body)
## 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():
_authors = list(set(authors))
_authors.sort()
_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 = notes.strip())
## 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 date, version, authors in parse_blocks(changelog):
# Apply version and date filters.
if version_list and (version not in version_list):
continue
if date < args.from_date or date > args.to_date:
continue
if version not in versions:
versions[version] = [], []
_authors, _changes = versions[version]
_authors.extend(authors.keys())
_changes.extend(authors.values())
notes = generate_notes(versions)
with args.output as _file:
_file.write(notes)
if __name__ == "__main__":
main()
|