Skip to content

Commit 334387b

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, category, description, and contributor from the git branch.
1 parent 07f3ec4 commit 334387b

3 files changed

Lines changed: 296 additions & 0 deletions

File tree

.changelog/feature-changelog.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "feature",
3+
"category": "tools",
4+
"contributor": "kai lin",
5+
"description": "Add changelog fragment script and update CONTRIBUTING.md"
6+
}

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ 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+
67+
Valid fragment types:
68+
- `feature` - new functionality
69+
- `bugfix` - a bug fix
70+
- `dependency` - dependency update (e.g. CRT bump)
71+
- `breaking-change` - ABI/API breaking change
72+
6373
## Your First Code Change
6474
Before submitting your pull request, refer to the pull request readiness
6575
checklist below:
@@ -68,6 +78,7 @@ checklist below:
6878
* [ ] Code is documented, especially public and user-facing constructs
6979
* [ ] Git commit message is detailed and includes context behind the change
7080
* [ ] If the change is related to an existing Bug Report or Feature Request, the issue number is referenced
81+
* [ ] Includes a changelog fragment (run `tools/scripts/new-change`)
7182

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

tools/scripts/new-change

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

0 commit comments

Comments
 (0)