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