new-change 6.8 KB

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