Completed
Pull Request — master (#20)
by Gonzalo
54s
created

format_changelog()   D

Complexity

Conditions 8

Size

Total Lines 44

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