1#!/usr/bin/env python 2# This file is borrowed from the aws/aws-cli project with the following modifications: 3# - Add a 'deprecation' category, and validation for the category value 4# - Modify the 'linkify' method to use Markdown syntax instead of reStructuredText (rst) 5# - Better error reporting when one or more of the fields are empty 6# - Change filename format to use a the SHA1 digest of the content instead of a random number 7# - Change references to aws/cli to aws/aws-sdk-java 8"""Generate a new changelog entry. 9 10Usage 11===== 12 13To generate a new changelog entry:: 14 15 bin/new-change 16 17This will open up a file in your editor (via the ``EDITOR`` env var). 18You'll see this template:: 19 # Type should be one of: feature, bugfix, deprecation, removal, documentation 20 type: {change_type} 21 22 # The marketing name of the service this change applies to 23 # e.g: AWS CodeCommit, Amazon DynamoDB 24 # or "AWS SDK for Java v2" if it's an SDK change to the core, runtime etc 25 category: {category} 26 27 28 The description of the change. Feel free to use Markdown here. 29 description: {description} 30 31Fill in the appropriate values, save and exit the editor. 32 33If, when your editor is open, you decide don't don't want to add a changelog 34entry, save an empty file and no entry will be generated. 35 36""" 37import argparse 38import hashlib 39import json 40import os 41import re 42import string 43import subprocess 44import sys 45import tempfile 46 47from changelog.git import stage_file 48 49VALID_CHARS = set(string.ascii_letters + string.digits) 50CHANGES_DIR = os.path.join( 51 os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 52 '.changes' 53) 54CHANGE_PARTS = ['type', 'category', 'description'] 55TEMPLATE = """\ 56# Type should be one of: feature, bugfix, deprecation, removal, documentation 57type: {change_type} 58 59# The marketing name of the service this change applies to 60# e.g: AWS CodeCommit, Amazon DynamoDB 61# or "AWS SDK for Java v2" if it's an SDK change to the core, runtime etc 62category: {category} 63 64# Your GitHub username (without '@') to be included in the CHANGELOG. 65# Every contribution counts and we would like to recognize 66# your contribution! 67# Leave it empty if you would prefer not to be mentioned. 68contributor: {contributor} 69 70The description of the change. Feel free to use Markdown here. 71description: {description} 72""" 73 74 75def new_changelog_entry(args): 76 # Changelog values come from one of two places. 77 # Either all values are provided on the command line, 78 # or we open a text editor and let the user provide 79 # enter their values. 80 if all_values_provided(args): 81 parsed_values = { 82 'type': args.change_type, 83 'category': args.category, 84 'description': args.description, 85 'contributor': args.contributor 86 } 87 else: 88 parsed_values = get_values_from_editor(args) 89 missing_parts = get_missing_parts(parsed_values) 90 if len(missing_parts) > 0: 91 sys.stderr.write( 92 "No values provided for: %s. Skipping entry creation.\n" % missing_parts) 93 return 1 94 95 replace_issue_references(parsed_values, args.repo) 96 filename = write_new_change(parsed_values) 97 return stage_file(filename) 98 99def get_missing_parts(parsed_values): 100 return [p for p in CHANGE_PARTS if not parsed_values.get(p)] 101 102 103def all_values_provided(args): 104 return args.change_type and args.category and args.description and args.contributor 105 106 107def get_values_from_editor(args): 108 with tempfile.NamedTemporaryFile('w') as f: 109 contents = TEMPLATE.format( 110 change_type=args.change_type, 111 category=args.category, 112 description=args.description, 113 contributor=args.contributor 114 ) 115 f.write(contents) 116 f.flush() 117 env = os.environ 118 editor = env.get('VISUAL', env.get('EDITOR', 'vim')) 119 p = subprocess.Popen('%s %s' % (editor, f.name), shell=True) 120 p.communicate() 121 with open(f.name) as f: 122 filled_in_contents = f.read() 123 parsed_values = parse_filled_in_contents(filled_in_contents) 124 return parsed_values 125 126 127def replace_issue_references(parsed, repo_name): 128 description = parsed['description'] 129 130 def linkify(match): 131 number = match.group()[1:] 132 return ( 133 '[%s](https://github.com/%s/issues/%s)' % ( 134 match.group(), repo_name, number)) 135 136 new_description = re.sub('(?<!\[)#\d+', linkify, description) 137 parsed['description'] = new_description 138 139def write_new_change(parsed_values): 140 if not os.path.isdir(CHANGES_DIR): 141 os.makedirs(CHANGES_DIR) 142 # Assume that new changes go into the next release. 143 dirname = os.path.join(CHANGES_DIR, 'next-release') 144 if not os.path.isdir(dirname): 145 os.makedirs(dirname) 146 # Need to generate a unique filename for this change. 147 category = parsed_values['category'] 148 149 contributor = parsed_values['contributor'] 150 if contributor and contributor.strip: 151 contributor = remove_prefix(contributor, '@') 152 parsed_values['contributor'] = contributor 153 154 short_summary = ''.join(filter(lambda x: x in VALID_CHARS, category)) 155 contents = json.dumps(parsed_values, indent=4) + "\n" 156 contents_digest = hashlib.sha1(contents.encode('utf-8')).hexdigest() 157 filename = '{type_name}-{summary}-{digest}.json'.format( 158 type_name=parsed_values['type'], 159 summary=short_summary, 160 digest=contents_digest[0:7]) 161 filename = os.path.join(dirname, filename) 162 with open(filename, 'w') as f: 163 f.write(contents) 164 return filename 165 166def remove_prefix(text, prefix): 167 if text.startswith(prefix): 168 return text[len(prefix):] 169 return text 170 171def parse_filled_in_contents(contents): 172 """Parse filled in file contents and returns parsed dict. 173 174 Return value will be:: 175 { 176 "type": "bugfix", 177 "category": "category", 178 "description": "This is a description" 179 } 180 181 """ 182 if not contents.strip(): 183 return {} 184 parsed = {} 185 lines = iter(contents.splitlines()) 186 for line in lines: 187 line = line.strip() 188 if line.startswith('#'): 189 continue 190 if 'type' not in parsed and line.startswith('type:'): 191 t = line[len('type:'):].strip() 192 if t not in ['feature', 'bugfix', 'deprecation', 'removal', 193 'documentation']: 194 raise Exception("Unsupported category %s" % t) 195 parsed['type'] = t 196 elif 'category' not in parsed and line.startswith('category:'): 197 parsed['category'] = line[len('category:'):].strip() 198 elif 'contributor' not in parsed and line.startswith('contributor:'): 199 parsed['contributor'] = line[len('contributor:'):].strip() 200 elif 'description' not in parsed and line.startswith('description:'): 201 # Assume that everything until the end of the file is part 202 # of the description, so we can break once we pull in the 203 # remaining lines. 204 first_line = line[len('description:'):].strip() 205 full_description = '\n'.join([first_line] + list(lines)) 206 parsed['description'] = full_description.strip() 207 break 208 return parsed 209 210 211def main(): 212 parser = argparse.ArgumentParser() 213 parser.add_argument('-t', '--type', dest='change_type', 214 default='', choices=('bugfix', 'feature', 215 'deprecation', 'documentation')) 216 parser.add_argument('-c', '--category', dest='category', 217 default='') 218 parser.add_argument('-u', '--contributor', dest='contributor', 219 default='') 220 parser.add_argument('-d', '--description', dest='description', 221 default='') 222 parser.add_argument('-r', '--repo', default='aws/aws-sdk-java-v2', 223 help='Optional repo name, e.g: aws/aws-sdk-java') 224 args = parser.parse_args() 225 sys.exit(new_changelog_entry(args)) 226 227 228if __name__ == '__main__': 229 main() 230