Skip to content

Commit cfeb112

Browse files
committed
Add changelog script
1 parent 24ebf70 commit cfeb112

File tree

1 file changed

+215
-0
lines changed

1 file changed

+215
-0
lines changed

scripts/new-change

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python
2+
"""Generate a new changelog entry.
3+
4+
Usage
5+
=====
6+
7+
To generate a new changelog entry::
8+
9+
scripts/new-change
10+
11+
This will open up a file in your editor (via the ``EDITOR`` env var).
12+
You'll see this template::
13+
14+
# Type should be one of: feature, bugfix
15+
type:
16+
17+
# Category is the high level feature area.
18+
# This can be a service identifier (e.g ``s3``),
19+
# or something like: Paginator.
20+
category:
21+
22+
# A brief description of the change. You can
23+
# use github style references to issues such as
24+
# "fixes #489", "boto/boto3#100", etc. These
25+
# will get automatically replaced with the correct
26+
# link.
27+
description:
28+
29+
Fill in the appropriate values, save and exit the editor.
30+
Make sure to commit these changes as part of your pull request.
31+
32+
If, when your editor is open, you decide don't don't want to add a changelog
33+
entry, save an empty file and no entry will be generated.
34+
35+
You can then use the ``scripts/render-change`` to generate the
36+
CHANGELOG.rst file.
37+
38+
"""
39+
import os
40+
import re
41+
import sys
42+
import json
43+
import string
44+
import random
45+
import tempfile
46+
import subprocess
47+
import argparse
48+
49+
50+
VALID_CHARS = set(string.ascii_letters + string.digits)
51+
CHANGES_DIR = os.path.join(
52+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
53+
'.changes'
54+
)
55+
TEMPLATE = """\
56+
# Type should be one of: feature, bugfix, enhancement, api-change
57+
# feature: A larger feature or change in behavior, usually resulting in a
58+
# minor version bump.
59+
# bugfix: Fixing a bug in an existing code path.
60+
# enhancment: Small change to an underlying implementation detail.
61+
# api-change: Changes to a modeled API.
62+
type: {change_type}
63+
64+
# Category is the high level feature area.
65+
# This can be a service identifier (e.g ``s3``),
66+
# or something like: Paginator.
67+
category: {category}
68+
69+
# A brief description of the change. You can
70+
# use github style references to issues such as
71+
# "fixes #489", "boto/boto3#100", etc. These
72+
# will get automatically replaced with the correct
73+
# link.
74+
description: {description}
75+
"""
76+
77+
78+
def new_changelog_entry(args):
79+
# Changelog values come from one of two places.
80+
# Either all values are provided on the command line,
81+
# or we open a text editor and let the user provide
82+
# enter their values.
83+
if all_values_provided(args):
84+
parsed_values = {
85+
'type': args.change_type,
86+
'category': args.category,
87+
'description': args.description,
88+
}
89+
else:
90+
parsed_values = get_values_from_editor(args)
91+
if has_empty_values(parsed_values):
92+
sys.stderr.write(
93+
"Empty changelog values received, skipping entry creation.\n")
94+
return 1
95+
replace_issue_references(parsed_values, args.repo)
96+
write_new_change(parsed_values)
97+
return 0
98+
99+
100+
def has_empty_values(parsed_values):
101+
return not (parsed_values.get('type') and
102+
parsed_values.get('category') and
103+
parsed_values.get('description'))
104+
105+
106+
def all_values_provided(args):
107+
return args.change_type and args.category and args.description
108+
109+
110+
def get_values_from_editor(args):
111+
with tempfile.NamedTemporaryFile('w') as f:
112+
contents = TEMPLATE.format(
113+
change_type=args.change_type,
114+
category=args.category,
115+
description=args.description,
116+
)
117+
f.write(contents)
118+
f.flush()
119+
env = os.environ
120+
editor = env.get('VISUAL', env.get('EDITOR', 'vim'))
121+
p = subprocess.Popen('%s %s' % (editor, f.name), shell=True)
122+
p.communicate()
123+
with open(f.name) as f:
124+
filled_in_contents = f.read()
125+
parsed_values = parse_filled_in_contents(filled_in_contents)
126+
return parsed_values
127+
128+
129+
def replace_issue_references(parsed, repo_name):
130+
description = parsed['description']
131+
132+
def linkify(match):
133+
number = match.group()[1:]
134+
return (
135+
'`%s <https://github.com/%s/issues/%s>`__' % (
136+
match.group(), repo_name, number))
137+
138+
new_description = re.sub('#\d+', linkify, description)
139+
parsed['description'] = new_description
140+
141+
142+
def write_new_change(parsed_values):
143+
if not os.path.isdir(CHANGES_DIR):
144+
os.makedirs(CHANGES_DIR)
145+
# Assume that new changes go into the next release.
146+
dirname = os.path.join(CHANGES_DIR, 'next-release')
147+
if not os.path.isdir(dirname):
148+
os.makedirs(dirname)
149+
# Need to generate a unique filename for this change.
150+
# We'll try a couple things until we get a unique match.
151+
category = parsed_values['category']
152+
short_summary = ''.join(filter(lambda x: x in VALID_CHARS, category))
153+
filename = '{type_name}-{summary}'.format(
154+
type_name=parsed_values['type'],
155+
summary=short_summary)
156+
possible_filename = os.path.join(
157+
dirname, '%s-%s.json' % (filename, str(random.randint(1, 100000))))
158+
while os.path.isfile(possible_filename):
159+
possible_filename = os.path.join(
160+
dirname, '%s-%s.json' % (filename, str(random.randint(1, 100000))))
161+
with open(possible_filename, 'w') as f:
162+
f.write(json.dumps(parsed_values, indent=2) + "\n")
163+
164+
165+
def parse_filled_in_contents(contents):
166+
"""Parse filled in file contents and returns parsed dict.
167+
168+
Return value will be::
169+
{
170+
"type": "bugfix",
171+
"category": "category",
172+
"description": "This is a description"
173+
}
174+
175+
"""
176+
if not contents.strip():
177+
return {}
178+
parsed = {}
179+
lines = iter(contents.splitlines())
180+
for line in lines:
181+
line = line.strip()
182+
if line.startswith('#'):
183+
continue
184+
if 'type' not in parsed and line.startswith('type:'):
185+
parsed['type'] = line.split(':')[1].strip()
186+
elif 'category' not in parsed and line.startswith('category:'):
187+
parsed['category'] = line.split(':')[1].strip()
188+
elif 'description' not in parsed and line.startswith('description:'):
189+
# Assume that everything until the end of the file is part
190+
# of the description, so we can break once we pull in the
191+
# remaining lines.
192+
first_line = line.split(':')[1].strip()
193+
full_description = '\n'.join([first_line] + list(lines))
194+
parsed['description'] = full_description.strip()
195+
break
196+
return parsed
197+
198+
199+
def main():
200+
parser = argparse.ArgumentParser()
201+
parser.add_argument('-t', '--type', dest='change_type',
202+
default='', choices=('bugfix', 'feature',
203+
'enhancement', 'api-change'))
204+
parser.add_argument('-c', '--category', dest='category',
205+
default='')
206+
parser.add_argument('-d', '--description', dest='description',
207+
default='')
208+
parser.add_argument('-r', '--repo', default='awslabs/aws-shell',
209+
help='Optional repo name, e.g: awslabs/aws-shell')
210+
args = parser.parse_args()
211+
sys.exit(new_changelog_entry(args))
212+
213+
214+
if __name__ == '__main__':
215+
main()

0 commit comments

Comments
 (0)