Completed
Push — master ( 28909c...90b6ea )
by Gonzalo
9s
created

GitHubRepo.is_merged()   A

Complexity

Conditions 2

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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