Completed
Pull Request — master (#20)
by Gonzalo
01:02
created

format_changelog()   D

Complexity

Conditions 8

Size

Total Lines 49

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 49
rs 4.7619
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, username, password, token, milestone, since_tag,
150
                     until_tag, output_format, issue_label_regex,
151
                     pr_label_regex, template_file):
152
    """Create changelog data."""
153
    # Instantiate Github API
154
    gh = GitHubRepo(
155
        username=username,
156
        password=password,
157
        token=token,
158
        repo=repo, )
159
160
    version = until_tag or None
161
    milestone_number = None
162
    closed_at = None
163
    since = None
164
    until = None
165
166
    # Set milestone or from tag
167
    if milestone and not since_tag:
168
        milestone_data = gh.milestone(milestone)
169
        milestone_number = milestone_data['number']
170
        closed_at = milestone_data['closed_at']
171
        version = milestone.replace('v', '')
172
    elif not milestone and since_tag:
173
        since = gh.tag(since_tag)['tagger']['date']
174
        if until_tag:
175
            until = gh.tag(until_tag)['tagger']['date']
176
            closed_at = until
177
178
    # This returns issues and pull requests
179
    issues = gh.issues(
180
        milestone=milestone_number, state='closed', since=since, until=until)
181
182
    # Filter by regex if available
183
    filtered_issues, filtered_prs = [], []
184
    issue_pattern = re.compile(issue_label_regex)
185
    pr_pattern = re.compile(pr_label_regex)
186
    for issue in issues:
187
        is_pr = bool(issue.get('pull_request'))
188
        is_issue = not is_pr
189
        labels = ' '.join(issue.get('_label_names'))
190
191
        if is_issue and issue_label_regex:
192
            issue_valid = bool(issue_pattern.search(labels))
193
            if issue_valid:
194
                filtered_issues.append(issue)
195
        elif is_pr and pr_label_regex:
196
            pr_valid = bool(pr_pattern.search(labels))
197
            if pr_valid:
198
                filtered_prs.append(issue)
199
        elif is_issue and not issue_label_regex:
200
            filtered_issues.append(issue)
201
        elif is_pr and not pr_label_regex:
202
            filtered_prs.append(issue)
203
204
    format_changelog(
205
        repo,
206
        filtered_issues,
207
        filtered_prs,
208
        version,
209
        closed_at=closed_at,
210
        output_format=output_format,
211
        template_file=template_file)
212
213
214
def format_changelog(repo,
215
                     issues,
216
                     prs,
217
                     version,
218
                     closed_at=None,
219
                     output_format='changelog',
220
                     output_file='CHANGELOG.temp',
221
                     template_file=None):
222
    """Create changelog data."""
223
    # Header
224
    if version and version[0] == 'v':
225
        version = version.replace('v', '')
226
    else:
227
        version = '<RELEASE_VERSION>'
228
229
    if closed_at:
230
        close_date = closed_at.split('T')[0]
231
    else:
232
        close_date = time.strftime("%Y/%m/%d")
233
234
    # Load template
235
    if template_file:
236
        filepath = template_file
237
    else:
238
        if output_format == 'changelog':
239
            filepath = CHANGELOG_TEMPLATE_PATH
240
        else:
241
            filepath = RELEASE_TEMPLATE_PATH
242
243
    with open(filepath) as f:
244
        data = f.read()
245
246
    repo_owner, repo_name = repo.split('/')
247
    template = Template(data)
248
    rendered = template.render(
249
        issues=issues,
250
        pull_requests=prs,
251
        version=version,
252
        close_date=close_date,
253
        repo_full_name=repo,
254
        repo_owner=repo_owner,
255
        repo_name=repo_name, )
256
257
    print('#' * 79)
258
    print(rendered)
259
    print('#' * 79)
260
261
    with open(output_file, 'w') as f:
262
        f.write(rendered)
263
264
265
class GitHubRepo(object):
266
    """Github repository wrapper."""
267
268
    def __init__(self, username=None, password=None, token=None, repo=None):
269
        """Github repository wrapper."""
270
        self.gh = GitHub(
271
            username=username,
272
            password=password,
273
            access_token=token, )
274
        repo_organization, repo_name = repo.split('/')
275
        self.repo = self.gh.repos(repo_organization)(repo_name)
276
277
    def tags(self):
278
        """Return all tags."""
279
        return self.repo('git')('refs')('tags').get()
280
281
    def tag(self, tag_name):
282
        """Get tag information."""
283
        refs = self.repo('git')('refs')('tags').get()
284
        sha = -1
285
        for ref in refs:
286
            ref_name = 'refs/tags/{tag}'.format(tag=tag_name)
287
            if 'object' in ref and ref['ref'] == ref_name:
288
                sha = ref['object']['sha']
289
                break
290
291
        if sha == -1:
292
            print("You didn't pass a valid tag name!")
293
            sys.exit(1)
294
295
        return self.repo('git')('tags')(sha).get()
296
297
    def milestones(self):
298
        """Return all milestones."""
299
        return self.repo.milestones.get(state='all')
300
301
    def milestone(self, milestone_title):
302
        """Return milestone with given title."""
303
        milestones = self.milestones()
304
        milestone_number = -1
305
        for milestone in milestones:
306
            if milestone['title'] == milestone_title:
307
                milestone_number = milestone['number']
308
                break
309
310
        if milestone_number == -1:
311
            print("You didn't pass a valid milestone name!")
312
            sys.exit(1)
313
314
        return milestone
315
316
    def issues(self,
317
               milestone=None,
318
               state=None,
319
               assignee=None,
320
               creator=None,
321
               mentioned=None,
322
               labels=None,
323
               sort=None,
324
               direction=None,
325
               since=None,
326
               until=None):
327
        """Return Issues and Pull Requests."""
328
        page = 1
329
        issues = []
330
        while True:
331
            result = self.repo.issues.get(page=page,
332
                                          per_page=100,
333
                                          milestone=milestone,
334
                                          state=state,
335
                                          assignee=assignee,
336
                                          creator=creator,
337
                                          mentioned=mentioned,
338
                                          labels=labels,
339
                                          sort=sort,
340
                                          firection=direction,
341
                                          since=since)
342
            if len(result) > 0:
343
                issues += result
344
                page = page + 1
345
            else:
346
                break
347
348
        # If since was provided, filter the issue
349
        if since:
350
            since_date = self.str_to_date(since)
351
            for issue in issues[:]:
352
                close_date = self.str_to_date(issue['closed_at'])
353
                if close_date < since_date:
354
                    issues.remove(issue)
355
356
        # If until was provided, filter the issue
357
        if until:
358
            until_date = self.str_to_date(until)
359
            for issue in issues[:]:
360
                close_date = self.str_to_date(issue['closed_at'])
361
                if close_date > until_date:
362
                    issues.remove(issue)
363
364
        # If it is a pr check if it is merged or closed, removed closed ones
365
        for issue in issues[:]:
366
            pr = issue.get('pull_request', '')
367
368
            # Add label names inside additional key
369
            issue['_label_names'] = [l['name'] for l in issue.get('labels')]
370
371
            if pr:
372
                number = issue['number']
373
                if not self.is_merged(number):
374
                    issues.remove(issue)
375
376
        return issues
377
378
    def is_merged(self, pr):
379
        """
380
        Return wether a PR was merged, or if it was closed and discarded.
381
382
        https://developer.github.com/v3/pulls/#get-if-a-pull-request-has-been-merged
383
        """
384
        merged = True
385
        try:
386
            self.repo('pulls')(str(pr))('merge').get()
387
        except Exception:
388
            merged = False
389
        return merged
390
391
    @staticmethod
392
    def str_to_date(string):
393
        """Convert ISO date string to datetime object."""
394
        parts = string.split('T')
395
        date_parts = parts[0]
396
        time_parts = parts[1][:-1]
397
        year, month, day = [int(i) for i in date_parts.split('-')]
398
        hour, minutes, seconds = [int(i) for i in time_parts.split(':')]
399
        return datetime.datetime(year, month, day, hour, minutes, seconds)
400
401
402
if __name__ == '__main__':  # yapf: disable
403
    main()
404