Skip to content

Commit 8694c53

Browse files
committed
Add changelog fragment script and update CONTRIBUTING.md
Add tools/scripts/new-change for generating .changelog/ fragment files. Supports interactive mode (opens editor), CLI flags, and --auto mode that infers type, scope, and description from the git branch.
1 parent 0c4942f commit 8694c53

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ Code contributions to the SDK are done through [Pull Requests][pull-requests]. P
6060
If you are thinking about adding entirely new functionality, open a [Feature Request](#feature-requests) first before beginning work; again this is to make sure that no one else is already working on it, and also that it makes sense to be included in the SDK.
6161
* All code contributions must be accompanied with new or modified tests that verify that the code works as expected; i.e. that the issue has been fixed or that the functionality works as intended.
6262

63+
## Changelog
64+
65+
Every PR that changes SDK behavior should include a changelog fragment. Run `tools/scripts/new-change` to generate one interactively, or use `tools/scripts/new-change --auto` to auto-detect from your branch. Commit the generated file in `.changelog/` with your changes.
66+
6367
## Your First Code Change
6468
Before submitting your pull request, refer to the pull request readiness
6569
checklist below:
@@ -68,6 +72,7 @@ checklist below:
6872
* [ ] Code is documented, especially public and user-facing constructs
6973
* [ ] Git commit message is detailed and includes context behind the change
7074
* [ ] If the change is related to an existing Bug Report or Feature Request, the issue number is referenced
75+
* [ ] Includes a changelog fragment (run `tools/scripts/new-change`)
7176

7277
__Note__: Some changes have additional requirements. Refer to the section below
7378
to see if your change will require additional work to be accepted.

‎tools/scripts/new-change‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#!/usr/bin/env python3
2+
# Adapted from aws/aws-sdk-java-v2 scripts/new-change
3+
"""Generate a new changelog fragment.
4+
5+
Usage
6+
=====
7+
8+
Interactively (opens editor)::
9+
10+
tools/scripts/new-change
11+
12+
Non-interactively (for CI)::
13+
14+
tools/scripts/new-change --type bugfix --scope aws-cpp-sdk-core --description "Fix timeout"
15+
16+
Auto-detect from git (infers type, scope, description from branch changes)::
17+
18+
tools/scripts/new-change --auto
19+
20+
"""
21+
import argparse
22+
import hashlib
23+
import json
24+
import os
25+
import re
26+
import subprocess
27+
import sys
28+
import tempfile
29+
30+
VALID_TYPES = ['feature', 'bugfix', 'dependency', 'breaking-change']
31+
CHANGELOG_DIR = os.path.join(
32+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
33+
'.changelog'
34+
)
35+
TEMPLATE = """\
36+
# Type should be one of: feature, bugfix, dependency, breaking-change
37+
type: {change_type}
38+
39+
# The scope of the change (e.g. aws-cpp-sdk-core, aws-cpp-sdk-s3)
40+
scope: {scope}
41+
42+
# The description of the change.
43+
description: {description}
44+
"""
45+
46+
47+
48+
def new_changelog_entry(args):
49+
if args.auto:
50+
parsed_values = auto_detect()
51+
if not parsed_values:
52+
sys.stderr.write("Could not auto-detect changelog entry. Use interactive mode or pass flags.\n")
53+
return 1
54+
elif all_values_provided(args):
55+
parsed_values = {
56+
'type': args.change_type,
57+
'scope': args.scope,
58+
'description': args.description,
59+
}
60+
else:
61+
parsed_values = get_values_from_editor(args)
62+
if not parsed_values:
63+
sys.stderr.write("Empty file, skipping entry creation.\n")
64+
return 1
65+
missing = [p for p in ['type', 'scope', 'description'] if not parsed_values.get(p)]
66+
if missing:
67+
sys.stderr.write(
68+
"No values provided for: %s. Skipping entry creation.\n" % ', '.join(missing))
69+
return 1
70+
71+
if parsed_values['type'] not in VALID_TYPES:
72+
sys.stderr.write(
73+
"Invalid type '%s'. Must be one of: %s\n" % (parsed_values['type'], ', '.join(VALID_TYPES)))
74+
return 1
75+
76+
replace_issue_references(parsed_values)
77+
filename = write_new_change(parsed_values)
78+
print("Created changelog fragment: %s" % filename)
79+
return 0
80+
81+
82+
def auto_detect():
83+
"""Infer type, scope, and description from git branch state."""
84+
# Get description from the first commit message on this branch
85+
description = git('log', 'main..HEAD', '--format=%s', '--reverse').strip().split('\n')[0]
86+
if not description:
87+
# Fallback to last commit
88+
description = git('log', '-1', '--format=%s').strip()
89+
if not description:
90+
return None
91+
92+
# Get all changed files vs main, excluding codegen dirs
93+
changed_files = git('diff', '--name-only', 'main', '--', '.').strip().split('\n')
94+
changed_files = [f for f in changed_files if f]
95+
96+
# Infer scope from common package
97+
scope = infer_scope(changed_files)
98+
99+
# Infer type from description
100+
change_type = infer_type(description)
101+
102+
print("Auto-detected:")
103+
print(" type: %s" % change_type)
104+
print(" scope: %s" % scope)
105+
print(" description: %s" % description)
106+
107+
return {
108+
'type': change_type,
109+
'scope': scope,
110+
'description': description,
111+
}
112+
113+
114+
def infer_scope(changed_files):
115+
"""Infer the scope from changed file paths."""
116+
packages = set()
117+
has_generated = False
118+
for f in changed_files:
119+
if f.startswith('generated/'):
120+
has_generated = True
121+
continue
122+
# Only list non-generated packages individually
123+
parts = f.split('/')
124+
for part in parts:
125+
if part.startswith('aws-cpp-sdk-'):
126+
packages.add(part)
127+
break
128+
129+
scopes = sorted(packages)
130+
if has_generated:
131+
scopes.append('generated/src')
132+
133+
if len(scopes) == 1:
134+
return scopes[0]
135+
elif len(scopes) > 1:
136+
return ', '.join(scopes)
137+
return ''
138+
139+
140+
def infer_type(description):
141+
"""Infer the change type from the commit message."""
142+
lower = description.lower()
143+
if any(word in lower for word in ['fix', 'bug', 'crash', 'segfault', 'leak']):
144+
return 'bugfix'
145+
elif any(word in lower for word in ['break', 'abi', 'remove', 'deprecat']):
146+
return 'breaking-change'
147+
elif any(word in lower for word in ['bump', 'crt', 'dependency', 'update.*version']):
148+
return 'dependency'
149+
return 'feature'
150+
151+
152+
def git(*args):
153+
"""Run a git command and return stdout."""
154+
try:
155+
result = subprocess.run(
156+
['git'] + list(args),
157+
capture_output=True, text=True, timeout=30)
158+
return result.stdout
159+
except (subprocess.TimeoutExpired, FileNotFoundError):
160+
return ''
161+
162+
163+
def all_values_provided(args):
164+
return args.change_type and args.scope and args.description
165+
166+
167+
def get_values_from_editor(args):
168+
with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
169+
contents = TEMPLATE.format(
170+
change_type=args.change_type or '',
171+
scope=args.scope or '',
172+
description=args.description or '',
173+
)
174+
f.write(contents)
175+
f.flush()
176+
tmpname = f.name
177+
178+
try:
179+
env = os.environ
180+
editor = env.get('VISUAL', env.get('EDITOR', 'vim'))
181+
p = subprocess.Popen('%s %s' % (editor, tmpname), shell=True)
182+
p.communicate()
183+
with open(tmpname) as f:
184+
filled_in = f.read()
185+
return parse_filled_in_contents(filled_in)
186+
finally:
187+
os.unlink(tmpname)
188+
189+
190+
def replace_issue_references(parsed):
191+
description = parsed['description']
192+
193+
def linkify(match):
194+
number = match.group()[1:]
195+
return '[%s](https://github.com/aws/aws-sdk-cpp/issues/%s)' % (match.group(), number)
196+
197+
parsed['description'] = re.sub(r'(?<!\[)#\d+', linkify, description)
198+
199+
200+
def write_new_change(parsed_values):
201+
if not os.path.isdir(CHANGELOG_DIR):
202+
os.makedirs(CHANGELOG_DIR)
203+
204+
contents = json.dumps(parsed_values, indent=2) + "\n"
205+
contents_digest = hashlib.sha1(contents.encode('utf-8')).hexdigest()
206+
207+
# Use branch name as filename if available, otherwise fall back to scope slug
208+
branch = git('rev-parse', '--abbrev-ref', 'HEAD').strip()
209+
if branch and branch != 'main' and branch != 'HEAD':
210+
name_slug = ''.join(c for c in branch if c.isalnum() or c == '-')
211+
else:
212+
name_slug = ''.join(c for c in parsed_values['scope'] if c.isalnum() or c == '-')
213+
if len(name_slug) > 60:
214+
name_slug = name_slug[:60]
215+
filename = '%s-%s.json' % (parsed_values['type'], name_slug)
216+
filepath = os.path.join(CHANGELOG_DIR, filename)
217+
218+
with open(filepath, 'w') as f:
219+
f.write(contents)
220+
return filepath
221+
222+
223+
def parse_filled_in_contents(contents):
224+
if not contents.strip():
225+
return {}
226+
parsed = {}
227+
lines = iter(contents.splitlines())
228+
for line in lines:
229+
line = line.strip()
230+
if line.startswith('#'):
231+
continue
232+
if 'type' not in parsed and line.startswith('type:'):
233+
parsed['type'] = line[len('type:'):].strip()
234+
elif 'scope' not in parsed and line.startswith('scope:'):
235+
parsed['scope'] = line[len('scope:'):].strip()
236+
elif 'description' not in parsed and line.startswith('description:'):
237+
first_line = line[len('description:'):].strip()
238+
full_description = '\n'.join([first_line] + list(lines))
239+
parsed['description'] = full_description.strip()
240+
break
241+
return parsed
242+
243+
244+
def main():
245+
parser = argparse.ArgumentParser(description='Generate a new changelog fragment')
246+
parser.add_argument('-t', '--type', dest='change_type', default='',
247+
choices=VALID_TYPES)
248+
parser.add_argument('-s', '--scope', dest='scope', default='')
249+
parser.add_argument('-d', '--description', dest='description', default='')
250+
parser.add_argument('--auto', action='store_true',
251+
help='Auto-detect type, scope, and description from git')
252+
args = parser.parse_args()
253+
sys.exit(new_changelog_entry(args))
254+
255+
256+
if __name__ == '__main__':
257+
main()

0 commit comments

Comments
 (0)