|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check that a pull request adds a changelog fragment. |
| 3 | +
|
| 4 | +Reads the files the pull request changed on stdin (one path per line, as |
| 5 | +produced by `git diff --name-only`) and exits non-zero when the pull request |
| 6 | +changes hand-written SDK sources but adds no fragment under .changelog/. |
| 7 | +
|
| 8 | +Fragments accumulate in .changelog/ until a release consumes them, so the check |
| 9 | +looks at what THIS pull request added rather than whether the directory is |
| 10 | +non-empty. |
| 11 | +
|
| 12 | +Usage:: |
| 13 | +
|
| 14 | + git diff --name-only "$(git merge-base origin/main HEAD)" HEAD \ |
| 15 | + | python3 validate-changelog --repo-root /path/to/aws-sdk-cpp |
| 16 | +""" |
| 17 | +import argparse |
| 18 | +import json |
| 19 | +import os |
| 20 | +import sys |
| 21 | + |
| 22 | +# The three constants below mirror tools/scripts/new-change, which lives in the |
| 23 | +# SDK repository -- keep them in sync by hand. |
| 24 | +VALID_TYPES = [ |
| 25 | + 'feature', |
| 26 | + 'bugfix', |
| 27 | + 'deprecation', |
| 28 | + 'removal', |
| 29 | + 'documentation', |
| 30 | + 'dependency', |
| 31 | + 'breaking-change', |
| 32 | +] |
| 33 | + |
| 34 | +REQUIRED_FIELDS = ['type', 'category', 'description'] |
| 35 | + |
| 36 | +# Reject anything outside this set: the formatter ignores unknown keys, so a |
| 37 | +# misspelled 'descripton' would drop the entry from the changelog with no warning. |
| 38 | +KNOWN_FIELDS = REQUIRED_FIELDS + ['contributor'] |
| 39 | + |
| 40 | +CHANGELOG_DIR_NAME = '.changelog' |
| 41 | + |
| 42 | +# Path prefixes that require a fragment. Anchored, so 'src/' does not also |
| 43 | +# match 'generated/src/'. |
| 44 | +BEHAVIOR_PATH_PREFIXES = ( |
| 45 | + 'src/', |
| 46 | + 'cmake/', |
| 47 | + 'tools/scripts/', |
| 48 | +) |
| 49 | + |
| 50 | +# Checked first, so these win. Generated output and API models get their release |
| 51 | +# notes from Trebuchet service metadata, not hand-authored fragments. |
| 52 | +EXEMPT_PATH_PREFIXES = ( |
| 53 | + 'generated/', |
| 54 | + 'tools/code-generation/api-descriptions/', |
| 55 | + 'tools/code-generation/smithy/api-descriptions/', |
| 56 | + 'tools/code-generation/endpoints/', |
| 57 | + 'tools/code-generation/defaults/', |
| 58 | + 'tools/code-generation/partitions/', |
| 59 | +) |
| 60 | + |
| 61 | + |
| 62 | +def requires_fragment(changed_files): |
| 63 | + """Return the changed files that require a changelog fragment.""" |
| 64 | + triggering = [] |
| 65 | + for path in changed_files: |
| 66 | + if path.startswith(EXEMPT_PATH_PREFIXES): |
| 67 | + continue |
| 68 | + if path.startswith(BEHAVIOR_PATH_PREFIXES): |
| 69 | + triggering.append(path) |
| 70 | + return triggering |
| 71 | + |
| 72 | + |
| 73 | +def added_fragments(changed_files, repo_root): |
| 74 | + """Return the changed files that are fragments present under .changelog/. |
| 75 | +
|
| 76 | + A deleted fragment still shows up in the diff, so filter to paths that |
| 77 | + exist on disk -- otherwise deleting a fragment would satisfy the gate. |
| 78 | + """ |
| 79 | + prefix = CHANGELOG_DIR_NAME + '/' |
| 80 | + return [ |
| 81 | + p for p in changed_files |
| 82 | + if p.startswith(prefix) and not os.path.basename(p).startswith('.') |
| 83 | + and not os.path.basename(p).upper().startswith('README') |
| 84 | + and os.path.isfile(os.path.join(repo_root, p)) |
| 85 | + ] |
| 86 | + |
| 87 | + |
| 88 | +def validate_fragment(path, repo_root): |
| 89 | + """Validate one fragment. Returns a list of error strings.""" |
| 90 | + full = os.path.join(repo_root, path) |
| 91 | + |
| 92 | + if not os.path.isfile(full): |
| 93 | + # Deleted or renamed by this pull request; nothing to validate. |
| 94 | + return [] |
| 95 | + |
| 96 | + if not path.endswith('.json'): |
| 97 | + return ['%s: fragments must be .json files ' |
| 98 | + '(run tools/scripts/new-change)' % path] |
| 99 | + |
| 100 | + try: |
| 101 | + with open(full) as f: |
| 102 | + fragment = json.load(f) |
| 103 | + except ValueError as e: |
| 104 | + return ['%s: not valid JSON (%s)' % (path, e)] |
| 105 | + except OSError as e: |
| 106 | + return ['%s: could not be read (%s)' % (path, e)] |
| 107 | + |
| 108 | + if not isinstance(fragment, dict): |
| 109 | + return ['%s: must contain a JSON object' % path] |
| 110 | + |
| 111 | + errors = [] |
| 112 | + for field in REQUIRED_FIELDS: |
| 113 | + value = fragment.get(field) |
| 114 | + if not isinstance(value, str) or not value.strip(): |
| 115 | + errors.append( |
| 116 | + "%s: missing or empty required field '%s'" % (path, field)) |
| 117 | + |
| 118 | + change_type = fragment.get('type') |
| 119 | + if isinstance(change_type, str) and change_type.strip() \ |
| 120 | + and change_type.strip() not in VALID_TYPES: |
| 121 | + errors.append("%s: invalid type '%s'. Must be one of: %s" |
| 122 | + % (path, change_type.strip(), ', '.join(VALID_TYPES))) |
| 123 | + |
| 124 | + unknown = sorted(set(fragment) - set(KNOWN_FIELDS)) |
| 125 | + if unknown: |
| 126 | + errors.append( |
| 127 | + '%s: unrecognized field(s) %s. Expected only: %s. Hand-edited ' |
| 128 | + 'fragments are easy to typo -- prefer tools/scripts/new-change.' |
| 129 | + % (path, ', '.join("'%s'" % f for f in unknown), |
| 130 | + ', '.join(KNOWN_FIELDS))) |
| 131 | + |
| 132 | + return errors |
| 133 | + |
| 134 | + |
| 135 | +def main(): |
| 136 | + parser = argparse.ArgumentParser( |
| 137 | + description='Check that a pull request adds a changelog fragment') |
| 138 | + parser.add_argument( |
| 139 | + '--repo-root', default='.', |
| 140 | + help='Root of the SDK repository holding %s/. Defaults to the working ' |
| 141 | + 'directory.' % CHANGELOG_DIR_NAME) |
| 142 | + args = parser.parse_args() |
| 143 | + |
| 144 | + repo_root = os.path.abspath(args.repo_root) |
| 145 | + changed_files = [line.strip() for line in sys.stdin if line.strip()] |
| 146 | + |
| 147 | + fragments = added_fragments(changed_files, repo_root) |
| 148 | + |
| 149 | + # Validate any fragment this pull request touched, even if none was |
| 150 | + # required: a malformed fragment should fail here rather than be silently |
| 151 | + # dropped when the release assembles the changelog. |
| 152 | + errors = [] |
| 153 | + for path in fragments: |
| 154 | + errors.extend(validate_fragment(path, repo_root)) |
| 155 | + if errors: |
| 156 | + sys.stderr.write('Invalid changelog fragment(s):\n') |
| 157 | + for error in errors: |
| 158 | + sys.stderr.write(' - %s\n' % error) |
| 159 | + return 1 |
| 160 | + |
| 161 | + triggering = requires_fragment(changed_files) |
| 162 | + if not triggering: |
| 163 | + print('No changelog fragment required for this change.') |
| 164 | + return 0 |
| 165 | + |
| 166 | + if not fragments: |
| 167 | + sys.stderr.write( |
| 168 | + 'This pull request changes SDK behavior but adds no changelog ' |
| 169 | + 'fragment.\n\nFiles that require a fragment:\n') |
| 170 | + for path in triggering[:20]: |
| 171 | + sys.stderr.write(' - %s\n' % path) |
| 172 | + if len(triggering) > 20: |
| 173 | + sys.stderr.write(' ... and %d more\n' % (len(triggering) - 20)) |
| 174 | + sys.stderr.write( |
| 175 | + '\nRun `tools/scripts/new-change` to generate one, then commit the ' |
| 176 | + 'file it creates in %s/.\n' |
| 177 | + 'See the Changelog section of CONTRIBUTING.md for details.\n' |
| 178 | + % CHANGELOG_DIR_NAME) |
| 179 | + return 1 |
| 180 | + |
| 181 | + print('Found %d changelog fragment(s): %s' |
| 182 | + % (len(fragments), ', '.join(fragments))) |
| 183 | + return 0 |
| 184 | + |
| 185 | + |
| 186 | +if __name__ == '__main__': |
| 187 | + sys.exit(main()) |
0 commit comments