Completed
Push — master ( 444748...82adda )
by Gonzalo
8s
created

format_changelog()   C

Complexity

Conditions 8

Size

Total Lines 51

Duplication

Lines 0
Ratio 0 %

Importance

Changes 12
Bugs 2 Features 0
Metric Value
cc 8
c 12
b 2
f 0
dl 0
loc 51
rs 5.2591

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