Completed
Push — master ( 90b6ea...63c6e5 )
by Gonzalo
7s
created

main()   C

Complexity

Conditions 7

Size

Total Lines 108

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 7
c 5
b 0
f 0
dl 0
loc 108
rs 5.3042

How to fix   Long Method   

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:

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
    parser.add_argument(
103
        '-t',
104
        '--token',
105
        action="store",
106
        dest="token",
107
        default='',
108
        help="Github access token")
109
    options = parser.parse_args()
110
111
    username = options.user
112
    password = options.password
113
    milestone = options.milestone
114
115
    if username and not password:
116
        password = getpass.getpass()
117
118
    # Check if repo given
119
    if not options.repository:
120
        print('Please define a repository name to this script. See its help')
121
        sys.exit(1)
122
123
    # Check if milestone or tag given
124
    if not milestone and not options.since_tag:
125
        print('\nQuerying all issues\n')
126
    elif milestone:
127
        print('\nQuerying issues for milestone {0}\n'.format(milestone))
128
129
    create_changelog(
130
        repo=options.repository,
131
        username=username,
132
        password=password,
133
        token=options.token,
134
        milestone=milestone,
135
        since_tag=options.since_tag,
136
        until_tag=options.until_tag,
137
        output_format=options.output_format,
138
        issue_label_regex=options.issue_label_regex,
139
        pr_label_regex=options.pr_label_regex)
140
141
142
def create_changelog(repo, username, password, token, milestone, since_tag,
143
                     until_tag, output_format, issue_label_regex,
144
                     pr_label_regex):
145
    """Create changelog data."""
146
    # Instantiate Github API
147
    gh = GitHubRepo(
148
        username=username,
149
        password=password,
150
        token=token,
151
        repo=repo, )
152
153
    version = until_tag or None
154
    milestone_number = None
155
    closed_at = None
156
    since = None
157
    until = None
158
159
    # Set milestone or from tag
160
    if milestone and not since_tag:
161
        milestone_data = gh.milestone(milestone)
162
        milestone_number = milestone_data['number']
163
        closed_at = milestone_data['closed_at']
164
        version = milestone.replace('v', '')
165
    elif not milestone and since_tag:
166
        since = gh.tag(since_tag)['tagger']['date']
167
        if until_tag:
168
            until = gh.tag(until_tag)['tagger']['date']
169
            closed_at = until
170
171
    # This returns issues and pull requests
172
    issues = gh.issues(
173
        milestone=milestone_number, state='closed', since=since, until=until)
174
175
    # Filter by regex if available
176
    filtered_issues, filtered_prs = [], []
177
    issue_pattern = re.compile(issue_label_regex)
178
    pr_pattern = re.compile(pr_label_regex)
179
    for issue in issues:
180
        is_pr = bool(issue.get('pull_request'))
181
        is_issue = not is_pr
182
        labels = ' '.join(issue.get('_label_names'))
183
184
        if is_issue and issue_label_regex:
185
            issue_valid = bool(issue_pattern.search(labels))
186
            if issue_valid:
187
                filtered_issues.append(issue)
188
        elif is_pr and pr_label_regex:
189
            pr_valid = bool(pr_pattern.search(labels))
190
            if pr_valid:
191
                filtered_prs.append(issue)
192
        elif is_issue and not issue_label_regex:
193
            filtered_issues.append(issue)
194
        elif is_pr and not pr_label_regex:
195
            filtered_prs.append(issue)
196
197
    format_changelog(
198
        repo,
199
        filtered_issues,
200
        filtered_prs,
201
        version,
202
        closed_at=closed_at,
203
        output_format=output_format)
204
205
206
def format_changelog(repo,
207
                     issues,
208
                     prs,
209
                     version,
210
                     closed_at=None,
211
                     output_format='changelog',
212
                     output_file='CHANGELOG.temp'):
213
    """Create changelog data."""
214
    lines = []
215
216
    # Header
217
    if version and version[0] == 'v':
218
        version = version.replace('v', '')
219
    else:
220
        '<RELEASE_VERSION>'
221
222
    if closed_at:
223
        close_date = closed_at.split('T')[0]
224
    else:
225
        close_date = time.strftime("%Y/%m/%d")
226
227
    quotes = '"' if version and ' ' in version else ''
228
    header = '## Version {q}{version}{q} ({date})\n'.format(
229
        version=version, date=close_date, q=quotes)
230
231
    lines.append(header)
232
233
    # --- Issues
234
    number_of_issues = 0
235
    issue_lines = ['\n### Issues Closed\n']
236
    for i in issues:
237
        number_of_issues += 1
238
        number = i['number']
239
        if output_format == 'changelog':
240
            issue_link = ISSUE_LONG.format(number=number, repo=repo)
241
        else:
242
            issue_link = ISSUE_SHORT.format(number=number)
243
        issue_lines.append(issue_link + ' - ' + i['title'])
244
245
    tense = 'was' if number_of_issues == 1 else 'were'
246
    plural = '' if number_of_issues == 1 else 's'
247
    issue_lines.append('\nIn this release {number} issue{plural} {tense} '
248
                       'closed\n'.format(
249
                           number=number_of_issues, tense=tense,
250
                           plural=plural))
251
    if number_of_issues > 0:
252
        lines = lines + issue_lines
253
254
    # --- Pull requests
255
    number_of_prs = 0
256
    pr_lines = ['\n### Pull Requests merged\n']
257
    for i in prs:
258
        pr_state = i.get('_pr_state', '')  # This key is added by GithubRepo
259
        if pr_state == 'merged':
260
            number_of_prs += 1
261
            number = i['number']
262
            if output_format == 'changelog':
263
                pr_link = PR_LONG.format(number=number, repo=repo)
264
            else:
265
                pr_link = PR_SHORT.format(number=number)
266
            pr_lines.append(pr_link + ' - ' + i['title'])
267
    tense = 'was' if number_of_prs == 1 else 'were'
268
    plural = '' if number_of_prs == 1 else 's'
269
    pr_lines.append('\nIn this release {number} pull request{plural} {tense} '
270
                    'merged\n'.format(
271
                        number=number_of_prs, tense=tense, plural=plural))
272
    if number_of_prs > 0:
273
        lines = lines + pr_lines
274
275
    # Print everything
276
    for line in lines:
277
        # Make the text file and console output identical
278
        if line.endswith('\n'):
279
            line = line[:-1]
280
        print(line)
281
    print()
282
283
    # Write to file
284
    text = ''.join(lines)
285
286
    if PY2:
287
        text = unicode(text).encode('utf-8')  # NOQA
288
289
    with open(output_file, 'w') as f:
290
        f.write(text)
291
292
293
class GitHubRepo(object):
294
    """Github repository wrapper."""
295
296
    def __init__(self, username=None, password=None, token=None, repo=None):
297
        """Github repository wrapper."""
298
        self.gh = GitHub(
299
            username=username,
300
            password=password,
301
            access_token=token, )
302
        repo_organization, repo_name = repo.split('/')
303
        self.repo = self.gh.repos(repo_organization)(repo_name)
304
305
    def tags(self):
306
        """Return all tags."""
307
        return self.repo('git')('refs')('tags').get()
308
309
    def tag(self, tag_name):
310
        """Get tag information."""
311
        refs = self.repo('git')('refs')('tags').get()
312
        sha = -1
313
        for ref in refs:
314
            ref_name = 'refs/tags/{tag}'.format(tag=tag_name)
315
            if 'object' in ref and ref['ref'] == ref_name:
316
                sha = ref['object']['sha']
317
                break
318
319
        if sha == -1:
320
            print("You didn't pass a valid tag name!")
321
            sys.exit(1)
322
323
        return self.repo('git')('tags')(sha).get()
324
325
    def milestones(self):
326
        """Return all milestones."""
327
        return self.repo.milestones.get(state='all')
328
329
    def milestone(self, milestone_title):
330
        """Return milestone with given title."""
331
        milestones = self.milestones()
332
        milestone_number = -1
333
        for milestone in milestones:
334
            if milestone['title'] == milestone_title:
335
                milestone_number = milestone['number']
336
                break
337
338
        if milestone_number == -1:
339
            print("You didn't pass a valid milestone name!")
340
            sys.exit(1)
341
342
        return milestone
343
344
    def issues(self,
345
               milestone=None,
346
               state=None,
347
               assignee=None,
348
               creator=None,
349
               mentioned=None,
350
               labels=None,
351
               sort=None,
352
               direction=None,
353
               since=None,
354
               until=None):
355
        """Return Issues and Pull Requests."""
356
        page = 1
357
        issues = []
358
        while True:
359
            result = self.repo.issues.get(page=page,
360
                                          per_page=100,
361
                                          milestone=milestone,
362
                                          state=state,
363
                                          assignee=assignee,
364
                                          creator=creator,
365
                                          mentioned=mentioned,
366
                                          labels=labels,
367
                                          sort=sort,
368
                                          firection=direction,
369
                                          since=since)
370
            if len(result) > 0:
371
                issues += result
372
                page = page + 1
373
            else:
374
                break
375
376
        # If it is a pr check if it is merged or closed
377
        for issue in issues:
378
            pr = issue.get('pull_request', '')
379
380
            # Add label names inside additional key
381
            issue['_label_names'] = [l['name'] for l in issue.get('labels')]
382
383
            if pr:
384
                number = issue['number']
385
                merged = self.is_merged(number)
386
                issue['_pr_state'] = 'merged' if merged else 'closed'
387
388
        # If since was provided, filter the issue
389
        if since:
390
            since_date = self.str_to_date(since)
391
            for issue in issues[:]:
392
                close_date = self.str_to_date(issue['closed_at'])
393
                if close_date < since_date:
394
                    issues.remove(issue)
395
396
        # If until was provided, filter the issue
397
        if until:
398
            until_date = self.str_to_date(until)
399
            for issue in issues[:]:
400
                close_date = self.str_to_date(issue['closed_at'])
401
                if close_date > until_date:
402
                    issues.remove(issue)
403
404
        return issues
405
406
    def is_merged(self, pr):
407
        """
408
        Return wether a PR was merged, or if it was closed and discarded.
409
410
        https://developer.github.com/v3/pulls/#get-if-a-pull-request-has-been-merged
411
        """
412
        merged = True
413
        try:
414
            self.repo('pulls')(str(pr))('merge').get()
415
        except Exception:
416
            merged = False
417
        return merged
418
419
    @staticmethod
420
    def str_to_date(string):
421
        """Convert ISO date string to datetime object."""
422
        parts = string.split('T')
423
        date_parts = parts[0]
424
        time_parts = parts[1][:-1]
425
        year, month, day = [int(i) for i in date_parts.split('-')]
426
        hour, minutes, seconds = [int(i) for i in time_parts.split(':')]
427
        return datetime.datetime(year, month, day, hour, minutes, seconds)
428
429
430
if __name__ == '__main__':  # yapf: disable
431
    main()
432