|
| 1 | +from os.path import abspath, dirname |
| 2 | +import sys |
| 3 | +import time |
| 4 | + |
| 5 | +from fnmatch import fnmatchcase |
| 6 | + |
| 7 | +try: |
| 8 | + from github import Github |
| 9 | +except ImportError: |
| 10 | + raise ImportError('Required PyGitHub module missing: pip install PyGithub') |
| 11 | + |
| 12 | +sys.path.insert(0, dirname(dirname(dirname(abspath(__file__))))) |
| 13 | +from tasks import get_version_from_file, VERSION_RE |
| 14 | + |
| 15 | + |
| 16 | +class ReleaseNoteGenerator(object): |
| 17 | + """Create release notes template based on issues on GitHub. |
| 18 | +
|
| 19 | + Requires PyGithub <https://github.com/jacquev6/PyGithub>. |
| 20 | + """ |
| 21 | + repository = 'robotframework/robotframework' |
| 22 | + |
| 23 | + def __init__(self, stream=sys.stdout): |
| 24 | + self._stream = stream |
| 25 | + |
| 26 | + def generate(self, version=get_version_from_file(), login=None, |
| 27 | + password=None): |
| 28 | + milestone, preview, preview_number = self._split_version(version) |
| 29 | + issues = self._get_issues(milestone, preview, preview_number, login, |
| 30 | + password) |
| 31 | + self._write_intro(version) |
| 32 | + self._write_most_important_enhancements(issues) |
| 33 | + self._write_backwards_incompatible_changes(issues) |
| 34 | + self._write_deprecated_features(issues) |
| 35 | + self._write_acknowledgements(issues) |
| 36 | + self._write_issue_table(issues, milestone, preview) |
| 37 | + |
| 38 | + def _split_version(self, version): |
| 39 | + match = VERSION_RE.match(version) |
| 40 | + if not match: |
| 41 | + raise ValueError("Invalid version '{}'.".format(version)) |
| 42 | + milestone, _, _, _, preview, preview_number = match.groups() |
| 43 | + return milestone, preview, preview_number |
| 44 | + |
| 45 | + def _get_issues(self, milestone, preview, preview_number, login=None, |
| 46 | + password=None): |
| 47 | + repo = Github(login_or_token=login, |
| 48 | + password=password).get_repo(self.repository) |
| 49 | + milestone = self._get_milestone(repo, milestone) |
| 50 | + issues = [Issue(issue) for issue in repo.get_issues(milestone=milestone, state='all')] |
| 51 | + preview_matcher = PreviewMatcher(preview, preview_number) |
| 52 | + if preview_matcher: |
| 53 | + issues = [issue for issue in issues if preview_matcher.matches(issue.labels)] |
| 54 | + return sorted(issues) |
| 55 | + |
| 56 | + def _get_milestone(self, repo, milestone): |
| 57 | + for m in repo.get_milestones(state='all'): |
| 58 | + if m.title == milestone: |
| 59 | + return m |
| 60 | + raise ValueError("Milestone '{}' not found from repository '{}'!" |
| 61 | + .format(milestone, repo.name)) |
| 62 | + |
| 63 | + def _write_intro(self, version): |
| 64 | + self._write_header("Robot Framework {}".format(version), level=1) |
| 65 | + intro = ''' |
| 66 | +Robot Framework {version} is a new release with **UPDATE** enhancements and bug |
| 67 | +fixes. It was released on {date}. |
| 68 | +
|
| 69 | +Questions and comments related to the release can be sent to the |
| 70 | +`robotframework-users <http://groups.google.com/group/robotframework-users>`_ |
| 71 | +and possible bugs submitted to the |
| 72 | +`issue tracker <https://github.com/robotframework/robotframework/issues>`__. |
| 73 | +
|
| 74 | +If you have `pip <http://pip-installer.org>`_ installed, just run |
| 75 | +``pip install --update robotframework``. Otherwise see `installation |
| 76 | +instructions <../../INSTALL.rst>`_. |
| 77 | +'''.strip() |
| 78 | + self._write(intro, version=version, underline='=' * len(version), |
| 79 | + date=time.strftime("%A %B %d, %Y")) |
| 80 | + |
| 81 | + def _write_most_important_enhancements(self, issues): |
| 82 | + self._write_issues_with_label('Most important enhancements', |
| 83 | + issues, 'prio-critical', 'prio-high') |
| 84 | + |
| 85 | + def _write_backwards_incompatible_changes(self, issues): |
| 86 | + self._write_issues_with_label('Backwards incompatible changes', |
| 87 | + issues, 'bwic') |
| 88 | + |
| 89 | + def _write_deprecated_features(self, issues): |
| 90 | + self._write_issues_with_label('Deprecated features', issues, 'depr') |
| 91 | + |
| 92 | + def _write_acknowledgements(self, issues): |
| 93 | + self._write_header('Acknowledgements') |
| 94 | + self._write('**UPDATE** based on AUTHORS.txt.') |
| 95 | + |
| 96 | + def _write_issue_table(self, issues, milestone, preview): |
| 97 | + self._write_header('Full list of fixes and enhancements') |
| 98 | + self._write(''' |
| 99 | +.. list-table:: |
| 100 | + :header-rows: 1 |
| 101 | +
|
| 102 | + * - ID |
| 103 | + - Type |
| 104 | + - Priority |
| 105 | + - Summary |
| 106 | +'''.strip()) |
| 107 | + prefix1 = ' * - ' |
| 108 | + prefix2 = ' - ' |
| 109 | + if preview: |
| 110 | + self._write(' - Added') |
| 111 | + for issue in issues: |
| 112 | + self._write(prefix1 + issue.id) |
| 113 | + for item in (issue.type, issue.priority, issue.summary): |
| 114 | + self._write(prefix2 + item) |
| 115 | + if preview: |
| 116 | + self._write(prefix2 + issue.preview) |
| 117 | + self._write() |
| 118 | + self._write('Altogether {} issues. View on `issue tracker ' |
| 119 | + '<https://github.com/{}/issues?q=milestone%3A{}>`__.', |
| 120 | + len(issues), self.repository, milestone) |
| 121 | + |
| 122 | + def _write_header(self, header, level=2): |
| 123 | + if level > 1: |
| 124 | + self._write() |
| 125 | + underline = {1: '=', 2: '-', 3: '~', 4: "'"}[level] * len(header) |
| 126 | + self._write(header) |
| 127 | + self._write(underline, newlines=2) |
| 128 | + |
| 129 | + def _write_issues_with_label(self, header, issues, *labels): |
| 130 | + issues = [issue for issue in issues |
| 131 | + if any(label in issue.labels for label in labels)] |
| 132 | + if not issues: |
| 133 | + return |
| 134 | + self._write_header(header) |
| 135 | + self._write('**EXPLAIN** or remove these.', newlines=2) |
| 136 | + for issue in issues: |
| 137 | + self._write('- {} {}', issue.id, issue.summary, newlines=0) |
| 138 | + if issue.preview: |
| 139 | + self._write(' ({})', issue.preview) |
| 140 | + else: |
| 141 | + self._write() |
| 142 | + |
| 143 | + def _write(self, message='', *args, **kwargs): |
| 144 | + message += ('\n' * kwargs.pop('newlines', 1)) |
| 145 | + if args or kwargs: |
| 146 | + message = message.format(*args, **kwargs) |
| 147 | + self._stream.write(message) |
| 148 | + |
| 149 | + |
| 150 | +class Issue(object): |
| 151 | + PRIORITIES = ['critical', 'high', 'medium', 'low', ''] |
| 152 | + |
| 153 | + def __init__(self, issue): |
| 154 | + self.id = '#{}'.format(issue.number) |
| 155 | + self.summary = issue.title |
| 156 | + self.labels = [label.name for label in issue.get_labels()] |
| 157 | + self.type = self._get_label('bug', 'enhancement') |
| 158 | + self.priority = self._get_priority() |
| 159 | + |
| 160 | + def _get_label(self, *values): |
| 161 | + for value in values: |
| 162 | + if value in self.labels: |
| 163 | + return value |
| 164 | + return None |
| 165 | + |
| 166 | + def _get_priority(self): |
| 167 | + labels = ['prio-' + p for p in self.PRIORITIES if p] |
| 168 | + priority = self._get_label(*labels) |
| 169 | + return priority.split('-')[1] if priority else '' |
| 170 | + |
| 171 | + def __cmp__(self, other): |
| 172 | + return cmp(self.order, other.order) |
| 173 | + |
| 174 | + @property |
| 175 | + def order(self): |
| 176 | + return (self.PRIORITIES.index(self.priority), |
| 177 | + 0 if self.type == 'bug' else 1, |
| 178 | + self.id) |
| 179 | + |
| 180 | + @property |
| 181 | + def preview(self): |
| 182 | + for label in self.labels: |
| 183 | + if label.startswith(('alpha ', 'beta ', 'rc ')): |
| 184 | + return label |
| 185 | + return '' |
| 186 | + |
| 187 | + |
| 188 | +class PreviewMatcher(object): |
| 189 | + |
| 190 | + def __init__(self, preview, number): |
| 191 | + self._patterns = self._get_patterns(preview, number) |
| 192 | + |
| 193 | + def _get_patterns(self, preview, number): |
| 194 | + if not preview: |
| 195 | + return () |
| 196 | + return {'a': (self._range('alpha', number),), |
| 197 | + 'b': ('alpha ?', self._range('beta', number)), |
| 198 | + 'rc': ('alpha ?', 'beta ?', self._range('rc', number))}[preview] |
| 199 | + |
| 200 | + def _range(self, name, number): |
| 201 | + return '%s [%s]' % (name, ''.join(str(i+1) for i in range(int(number)))) |
| 202 | + |
| 203 | + def matches(self, labels): |
| 204 | + return any(fnmatchcase(l, p) for p in self._patterns for l in labels) |
| 205 | + |
| 206 | + def __nonzero__(self): |
| 207 | + return bool(self._patterns) |
| 208 | + |
| 209 | + |
| 210 | +if __name__ == '__main__': |
| 211 | + generator = ReleaseNoteGenerator() |
| 212 | + generator.generate(*sys.argv[1:]) |
0 commit comments