Completed
Pull Request — master (#17)
by Gonzalo
50s
created

create_changelog()   F

Complexity

Conditions 19

Size

Total Lines 60

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 19
c 3
b 0
f 1
dl 0
loc 60
rs 3.1212

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like create_changelog() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
# -----------------------------------------------------------------------------
3
# Copyright (c) The Spyder Development Team
4
#
5
# Licensed under the terms of the MIT License
6
# (See LICENSE.txt for details)
7
# -----------------------------------------------------------------------------
8
"""Build a list of issues and pull requests per Github milestone."""
9
10
from __future__ import print_function
11
12
# Standard library imports
13
import argparse
14
import datetime
15
import getpass
16
import re
17
import sys
18
import time
19
20
# Local imports
21
from loghub.external.github import GitHub
22
23
PY2 = sys.version[0] == '2'
24
25
# TEMPLATES
26
ISSUE_LONG = "* [Issue {number}](https://github.com/{repo}/issues/{number})"
27
ISSUE_SHORT = "* Issue #{number}"
28
PR_LONG = "* [PR {number}](https://github.com/{repo}/pull/{number})"
29
PR_SHORT = "* PR #{number}"
30
31
32
def main():
33
    """Main script."""
34
    # Cli options
35
    parser = argparse.ArgumentParser(
36
        description='Script to print the list of issues and pull requests '
37
        'closed in a given milestone')
38
    parser.add_argument(
39
        'repository',
40
        help="Repository name to generate the Changelog for, in the form "
41
        "user/repo or org/repo (e.g. spyder-ide/spyder)")
42
    parser.add_argument(
43
        '-m',
44
        '--milestone',
45
        action="store",
46
        dest="milestone",
47
        default='',
48
        help="Github milestone to get issues and pull requests for")
49
    parser.add_argument(
50
        '-il',
51
        '--issue-label-regex',
52
        action="store",
53
        dest="issue_label_regex",
54
        default='',
55
        help="Label issue filter using a regular expression filter")
56
    parser.add_argument(
57
        '-pl',
58
        '--pr-label-regex',
59
        action="store",
60
        dest="pr_label_regex",
61
        default='',
62
        help="Label pull requets filter using a regular expression filter")
63
    parser.add_argument(
64
        '-st',
65
        '--since-tag',
66
        action="store",
67
        dest="since_tag",
68
        default='',
69
        help="Github issues and pull requests since tag")
70
    parser.add_argument(
71
        '-ut',
72
        '--until-tag',
73
        action="store",
74
        dest="until_tag",
75
        default='',
76
        help="Github issues and pull requests until tag")
77
    parser.add_argument(
78
        '-f',
79
        '--format',
80
        action="store",
81
        dest="output_format",
82
        default='changelog',
83
        help="Format for print, either 'changelog' (for "
84
        "Changelog.md file) or 'release' (for the Github "
85
        "Releases page). Default is 'changelog'. The "
86
        "'release' option doesn't generate Markdown "
87
        "hyperlinks.")
88
    parser.add_argument(
89
        '-u',
90
        '--user',
91
        action="store",
92
        dest="user",
93
        default='',
94
        help="Github user name")
95
    parser.add_argument(
96
        '-p',
97
        '--password',
98
        action="store",
99
        dest="password",
100
        default='',
101
        help="Github user password")
102
    options = parser.parse_args()
103
104
    # Check if repo given
105
    if not options.repository:
106
        print('Please define a repository name to this script. See its help')
107
        sys.exit(1)
108
109
    # Check if milestone or tag given
110
    if not options.milestone and not options.since_tag:
111
        print('Please pass a milestone or a tag to this script. See its help')
112
        sys.exit(1)
113
114
    create_changelog(
115
        repo=options.repository,
116
        username=options.user,
117
        password=options.password,
118
        milestone=options.milestone,
119
        since_tag=options.since_tag,
120
        until_tag=options.until_tag,
121
        output_format=options.output_format,
122
        issue_label_regex=options.issue_label_regex,
123
        pr_label_regex=options.pr_label_regex)
124
125
126
def create_changelog(repo, username, password, milestone, since_tag, until_tag,
127
                     output_format, issue_label_regex, pr_label_regex):
128
    """Create changelog data."""
129
    if username and not password:
130
        password = getpass.getpass()
131
132
    # Instantiate Github API
133
    gh = GitHubRepo(username=username, password=password, repo=repo)
134
135
    version = until_tag or None
136
    milestone_number = None
137
    closed_at = None
138
    since = None
139
    until = None
140
141
    # Set milestone or from tag
142
    if milestone and not since_tag:
143
        milestone_data = gh.milestone(milestone)
144
        milestone_number = milestone_data['number']
145
        closed_at = milestone_data['closed_at']
146
        version = milestone.replace('v', '')
147
    elif not milestone and since_tag:
148
        since = gh.tag(since_tag)['tagger']['date']
149
        if until_tag:
150
            until = gh.tag(until_tag)['tagger']['date']
151
            closed_at = until
152
153
    # This returns issues and pull requests
154
    issues = gh.issues(
155
        milestone=milestone_number, state='closed', since=since, until=until)
156
157
    # Filter by regex if available
158
    filtered_issues, filtered_prs = [], []
159
    issue_pattern = re.compile(issue_label_regex)
160
    pr_pattern = re.compile(pr_label_regex)
161
    for issue in issues:
162
        is_pr = bool(issue.get('pull_request'))
163
        is_issue = not is_pr
164
        labels = ' '.join(issue.get('_label_names'))
165
166
        if is_issue and issue_label_regex:
167
            issue_valid = bool(issue_pattern.search(labels))
168
            if issue_valid:
169
                filtered_issues.append(issue)
170
        elif is_pr and pr_label_regex:
171
            pr_valid = bool(pr_pattern.search(labels))
172
            if pr_valid:
173
                filtered_prs.append(issue)
174
        elif is_issue and not issue_label_regex:
175
            filtered_issues.append(issue)
176
        elif is_pr and not pr_label_regex:
177
            filtered_prs.append(issue)
178
179
    format_changelog(
180
        repo,
181
        filtered_issues,
182
        filtered_prs,
183
        version,
184
        closed_at=closed_at,
185
        output_format=output_format)
186
187
188
def format_changelog(repo,
189
                     issues,
190
                     prs,
191
                     version,
192
                     closed_at=None,
193
                     output_format='changelog',
194
                     output_file='CHANGELOG.temp'):
195
    """Create changelog data."""
196
    lines = []
197
198
    # Header
199
    if version and version[0] == 'v':
200
        version = version.replace('v', '')
201
    else:
202
        '<RELEASE_VERSION>'
203
204
    if closed_at:
205
        close_date = closed_at.split('T')[0]
206
    else:
207
        close_date = time.strftime("%Y/%m/%d")
208
209
    quotes = '"' if version and ' ' in version else ''
210
    header = '## Version {q}{version}{q} ({date})\n'.format(
211
        version=version, date=close_date, q=quotes)
212
213
    lines.append(header)
214
215
    # --- Issues
216
    number_of_issues = 0
217
    issue_lines = ['\n### Issues Closed\n']
218
    for i in issues:
219
        number_of_issues += 1
220
        number = i['number']
221
        if output_format == 'changelog':
222
            issue_link = ISSUE_LONG.format(number=number, repo=repo)
223
        else:
224
            issue_link = ISSUE_SHORT.format(number=number)
225
        issue_lines.append(issue_link + ' - ' + i['title'])
226
227
    tense = 'was' if number_of_issues == 1 else 'were'
228
    plural = '' if number_of_issues == 1 else 's'
229
    issue_lines.append('\nIn this release {number} issue{plural} {tense} '
230
                       'closed\n'.format(
231
                           number=number_of_issues, tense=tense,
232
                           plural=plural))
233
    if number_of_issues > 0:
234
        lines = lines + issue_lines
235
236
    # --- Pull requests
237
    number_of_prs = 0
238
    pr_lines = ['\n### Pull Requests merged\n']
239
    for i in prs:
240
        pr_state = i.get('_pr_state', '')  # This key is added by GithubRepo
241
        if pr_state == 'merged':
242
            number_of_prs += 1
243
            number = i['number']
244
            if output_format == 'changelog':
245
                pr_link = PR_LONG.format(number=number, repo=repo)
246
            else:
247
                pr_link = PR_SHORT.format(number=number)
248
            pr_lines.append(pr_link + ' - ' + i['title'])
249
    tense = 'was' if number_of_prs == 1 else 'were'
250
    plural = '' if number_of_prs == 1 else 's'
251
    pr_lines.append('\nIn this release {number} pull request{plural} {tense} '
252
                    'merged\n'.format(
253
                        number=number_of_prs, tense=tense, plural=plural))
254
    if number_of_prs > 0:
255
        lines = lines + pr_lines
256
257
    # Print everything
258
    for line in lines:
259
        # Make the text file and console output identical
260
        if line.endswith('\n'):
261
            line = line[:-1]
262
        print(line)
263
    print()
264
265
    # Write to file
266
    text = ''.join(lines)
267
268
    if PY2:
269
        text = unicode(text).encode('utf-8')  # NOQA
270
271
    with open(output_file, 'w') as f:
272
        f.write(text)
273
274
275
class GitHubRepo(object):
276
    """Github repository wrapper."""
277
278
    def __init__(self, username=None, password=None, repo=None):
279
        """Github repository wrapper."""
280
        self.gh = GitHub(username=username, password=password)
281
        repo_organization, repo_name = repo.split('/')
282
        self.repo = self.gh.repos(repo_organization)(repo_name)
283
284
    def tags(self):
285
        """Return all tags."""
286
        return self.repo('git')('refs')('tags').get()
287
288
    def tag(self, tag_name):
289
        """Get tag information."""
290
        refs = self.repo('git')('refs')('tags').get()
291
        sha = -1
292
        for ref in refs:
293
            ref_name = 'refs/tags/{tag}'.format(tag=tag_name)
294
            if 'object' in ref and ref['ref'] == ref_name:
295
                sha = ref['object']['sha']
296
                break
297
298
        if sha == -1:
299
            print("You didn't pass a valid tag name!")
300
            sys.exit(1)
301
302
        return self.repo('git')('tags')(sha).get()
303
304
    def milestones(self):
305
        """Return all milestones."""
306
        return self.repo.milestones.get(state='all')
307
308
    def milestone(self, milestone_title):
309
        """Return milestone with given title."""
310
        milestones = self.milestones()
311
        milestone_number = -1
312
        for milestone in milestones:
313
            if milestone['title'] == milestone_title:
314
                milestone_number = milestone['number']
315
                break
316
317
        if milestone_number == -1:
318
            print("You didn't pass a valid milestone name!")
319
            sys.exit(1)
320
321
        return milestone
322
323
    def issues(self,
324
               milestone=None,
325
               state=None,
326
               assignee=None,
327
               creator=None,
328
               mentioned=None,
329
               labels=None,
330
               sort=None,
331
               direction=None,
332
               since=None,
333
               until=None):
334
        """Return Issues and Pull Requests."""
335
        page = 1
336
        issues = []
337
        while True:
338
            result = self.repo.issues.get(page=page,
339
                                          per_page=100,
340
                                          milestone=milestone,
341
                                          state=state,
342
                                          assignee=assignee,
343
                                          creator=creator,
344
                                          mentioned=mentioned,
345
                                          labels=labels,
346
                                          sort=sort,
347
                                          firection=direction,
348
                                          since=since)
349
            if len(result) > 0:
350
                issues += result
351
                page = page + 1
352
            else:
353
                break
354
355
        # If it is a pr check if it is merged or closed
356
        for issue in issues:
357
            pr = issue.get('pull_request', '')
358
359
            # Add label names inside additional key
360
            issue['_label_names'] = [l['name'] for l in issue.get('labels')]
361
362
            if pr:
363
                number = issue['number']
364
                merged = self.is_merged(number)
365
                issue['_pr_state'] = 'merged' if merged else 'closed'
366
367
#                import json
368
#                print(json.dumps(issue, sort_keys=True, indent=4,
369
#                                 separators=(',', ': ')))
370
#            else:
371
#                import json
372
#                print(json.dumps(issue, sort_keys=True, indent=4,
373
#                                 separators=(',', ': ')))
374
375
# If since was provided, filter the issue
376
        if since:
377
            since_date = self.str_to_date(since)
378
            for issue in issues[:]:
379
                close_date = self.str_to_date(issue['closed_at'])
380
                if close_date < since_date:
381
                    issues.remove(issue)
382
383
        # If until was provided, filter the issue
384
        if until:
385
            until_date = self.str_to_date(until)
386
            for issue in issues[:]:
387
                close_date = self.str_to_date(issue['closed_at'])
388
                if close_date > until_date:
389
                    issues.remove(issue)
390
391
        return issues
392
393
    def is_merged(self, pr):
394
        """
395
        Return wether a PR was merged, or if it was closed and discarded.
396
397
        https://developer.github.com/v3/pulls/#get-if-a-pull-request-has-been-merged
398
        """
399
        merged = True
400
        try:
401
            self.repo('pulls')(str(pr))('merge').get()
402
        except Exception as e:
403
            merged = False
404
        return merged
405
406
    @staticmethod
407
    def str_to_date(string):
408
        """Convert ISO date string to datetime object."""
409
        parts = string.split('T')
410
        date_parts = parts[0]
411
        time_parts = parts[1][:-1]
412
        year, month, day = [int(i) for i in date_parts.split('-')]
413
        hour, minutes, seconds = [int(i) for i in time_parts.split(':')]
414
        return datetime.datetime(year, month, day, hour, minutes, seconds)
415
416
417
if __name__ == '__main__':  # yapf: disable
418
    main()
419