Completed
Pull Request — master (#13)
by Gonzalo
53s
created

GitHubRepo.issues()   F

Complexity

Conditions 9

Size

Total Lines 49

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
c 2
b 0
f 0
dl 0
loc 49
rs 3.75
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 sys
17
import time
18
19
# Local imports
20
from loghub.external.github import GitHub
21
22
PY2 = sys.version[0] == '2'
23
24
# TEMPLATES
25
ISSUE_LONG = "* [Issue {number}](https://github.com/{repo}/issues/{number})"
26
ISSUE_SHORT = "* Issue #{number}"
27
PR_LONG = "* [PR {number}](https://github.com/{repo}/pull/{number})"
28
PR_SHORT = "* PR #{number}"
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
        '-st',
50
        '--since-tag',
51
        action="store",
52
        dest="since_tag",
53
        default='',
54
        help="Github issues and pull requests since tag")
55
    parser.add_argument(
56
        '-ut',
57
        '--until-tag',
58
        action="store",
59
        dest="until_tag",
60
        default='',
61
        help="Github issues and pull requests until tag")
62
    parser.add_argument(
63
        '-f',
64
        '--format',
65
        action="store",
66
        dest="output_format",
67
        default='changelog',
68
        help="Format for print, either 'changelog' (for "
69
        "Changelog.md file) or 'release' (for the Github "
70
        "Releases page). Default is 'changelog'. The "
71
        "'release' option doesn't generate Markdown "
72
        "hyperlinks.")
73
    parser.add_argument(
74
        '-u',
75
        '--user',
76
        action="store",
77
        dest="user",
78
        default='',
79
        help="Github user name")
80
    parser.add_argument(
81
        '-p',
82
        '--password',
83
        action="store",
84
        dest="password",
85
        default='',
86
        help="Github user password")
87
    options = parser.parse_args()
88
89
    # Check if repo given
90
    if not options.repository:
91
        print('Please define a repository name to this script. See its help')
92
        sys.exit(1)
93
94
    # Check if milestone or tag given
95
    if not options.milestone and not options.since_tag:
96
        print('Please pass a milestone or a tag to this script. See its help')
97
        sys.exit(1)
98
99
    create_changelog(
100
        repo=options.repository,
101
        username=options.user,
102
        password=options.password,
103
        milestone=options.milestone,
104
        since_tag=options.since_tag,
105
        until_tag=options.until_tag,
106
        output_format=options.output_format, )
107
108
109
def create_changelog(repo, username, password, milestone, since_tag, until_tag,
110
                     output_format):
111
    """Create changelog data."""
112
    if username and not password:
113
        password = getpass.getpass()
114
115
    # Instantiate Github API
116
    gh = GitHubRepo(username=username, password=password, repo=repo)
117
118
    version = until_tag or None
119
    milestone_number = None
120
    closed_at = None
121
    since = None
122
    until = None
123
124
    # Set milestone or from tag
125
    if milestone and not since_tag:
126
        milestone_data = gh.milestone(milestone)
127
        milestone_number = milestone_data['number']
128
        closed_at = milestone_data['closed_at']
129
        version = milestone.replace('v', '')
130
    elif not milestone and since_tag:
131
        since = gh.tag(since_tag)['tagger']['date']
132
        if until_tag:
133
            until = gh.tag(until_tag)['tagger']['date']
134
            closed_at = until
135
136
    # This returns issues and pull requests
137
    issues = gh.issues(
138
        milestone=milestone_number, state='closed', since=since, until=until)
139
140
    format_changelog(
141
        repo,
142
        issues,
143
        version,
144
        closed_at=closed_at,
145
        output_format=output_format)
146
147
148
def format_changelog(repo,
149
                     issues,
150
                     version,
151
                     closed_at=None,
152
                     output_format='changelog',
153
                     output_file='CHANGELOG.temp'):
154
    """Create changelog data."""
155
    lines = []
156
157
    # Header
158
    if version and version[0] == 'v':
159
        version = version.replace('v', '')
160
    else:
161
        '<RELEASE_VERSION>'
162
163
    if closed_at:
164
        close_date = closed_at.split('T')[0]
165
    else:
166
        close_date = time.strftime("%Y/%m/%d")
167
168
    quotes = '"' if version and ' ' in version else ''
169
    header = '## Version {q}{version}{q} ({date})\n'.format(
170
        version=version, date=close_date, q=quotes)
171
172
    lines.append(header)
173
    lines.append('### Issues closed\n')
174
175
    # --- Issues
176
    number_of_issues = 0
177
    issue_lines = ['**Issues**\n']
178
    for i in issues:
179
        pr = i.get('pull_request', '')
180
        if not pr:
181
            number_of_issues += 1
182
            number = i['number']
183
            if output_format == 'changelog':
184
                issue_link = ISSUE_LONG.format(number=number, repo=repo)
185
            else:
186
                issue_link = ISSUE_SHORT.format(number=number)
187
            issue_lines.append(issue_link + ' - ' + i['title'])
188
189
    tense = 'was' if number_of_issues == 1 else 'were'
190
    plural = '' if number_of_issues == 1 else 's'
191
    issue_lines.append('\nIn this release {number} issue{plural} {tense} '
192
                       'closed\n'.format(
193
                           number=number_of_issues, tense=tense,
194
                           plural=plural))
195
    if number_of_issues > 0:
196
        lines = lines + issue_lines
197
198
    # --- Pull requests
199
    number_of_prs = 0
200
    pr_lines = ['**Pull requests**\n']
201
    for i in issues:
202
        pr = i.get('pull_request', '')
203
        if pr:
204
            number_of_prs += 1
205
            number = i['number']
206
            if output_format == 'changelog':
207
                pr_link = PR_LONG.format(number=number, repo=repo)
208
            else:
209
                pr_link = PR_SHORT.format(number=number)
210
            pr_lines.append(pr_link + ' - ' + i['title'])
211
    tense = 'was' if number_of_prs == 1 else 'were'
212
    plural = '' if number_of_prs == 1 else 's'
213
    pr_lines.append('\nIn this release {number} pull request{plural} {tense} '
214
                    'merged\n'.format(
215
                        number=number_of_prs, tense=tense, plural=plural))
216
    if number_of_prs > 0:
217
        lines = lines + pr_lines
218
219
    # Print everything
220
    for line in lines:
221
        # Make the text file and console output identical
222
        if line.endswith('\n'):
223
            line = line[:-1]
224
        print(line)
225
    print()
226
227
    # Write to file
228
    text = ''.join(lines)
229
230
    if PY2:
231
        text = unicode(text).encode('utf-8')  # NOQA
232
233
    with open(output_file, 'w') as f:
234
        f.write(text)
235
236
237
class GitHubRepo(object):
238
    """Github repository wrapper."""
239
240
    def __init__(self, username=None, password=None, repo=None):
241
        """Github repository wrapper."""
242
        self.gh = GitHub(username=username, password=password)
243
        repo_organization, repo_name = repo.split('/')
244
        self.repo = self.gh.repos(repo_organization)(repo_name)
245
246
    def tags(self):
247
        """Return all tags."""
248
        return self.repo('git')('refs')('tags').get()
249
250
    def tag(self, tag_name):
251
        """Get tag information."""
252
        refs = self.repo('git')('refs')('tags').get()
253
        sha = -1
254
        for ref in refs:
255
            ref_name = 'refs/tags/{tag}'.format(tag=tag_name)
256
            if 'object' in ref and ref['ref'] == ref_name:
257
                sha = ref['object']['sha']
258
                break
259
260
        if sha == -1:
261
            print("You didn't pass a valid tag name!")
262
            sys.exit(1)
263
264
        return self.repo('git')('tags')(sha).get()
265
266
    def milestones(self):
267
        """Return all milestones."""
268
        return self.repo.milestones.get(state='all')
269
270
    def milestone(self, milestone_title):
271
        """Return milestone with given title."""
272
        milestones = self.milestones()
273
        milestone_number = -1
274
        for milestone in milestones:
275
            if milestone['title'] == milestone_title:
276
                milestone_number = milestone['number']
277
                break
278
279
        if milestone_number == -1:
280
            print("You didn't pass a valid milestone name!")
281
            sys.exit(1)
282
283
        return milestone
284
285
    def issues(self,
286
               milestone=None,
287
               state=None,
288
               assignee=None,
289
               creator=None,
290
               mentioned=None,
291
               labels=None,
292
               sort=None,
293
               direction=None,
294
               since=None,
295
               until=None):
296
        """Return Issues and Pull Requests."""
297
        page = 1
298
        issues = []
299
        while True:
300
            result = self.repo.issues.get(page=page,
301
                                          per_page=100,
302
                                          milestone=milestone,
303
                                          state=state,
304
                                          assignee=assignee,
305
                                          creator=creator,
306
                                          mentioned=mentioned,
307
                                          labels=labels,
308
                                          sort=sort,
309
                                          firection=direction,
310
                                          since=since)
311
            if len(result) > 0:
312
                issues += result
313
                page = page + 1
314
            else:
315
                break
316
317
        # If since was provided, filter the issue
318
        if since:
319
            since_date = self.str_to_date(since)
320
            for issue in issues[:]:
321
                close_date = self.str_to_date(issue['closed_at'])
322
                if close_date < since_date:
323
                    issues.remove(issue)
324
325
        # If until was provided, filter the issue
326
        if until:
327
            until_date = self.str_to_date(until)
328
            for issue in issues[:]:
329
                close_date = self.str_to_date(issue['closed_at'])
330
                if close_date > until_date:
331
                    issues.remove(issue)
332
333
        return issues
334
335
    @staticmethod
336
    def str_to_date(string):
337
        """Convert ISO date string to datetime object."""
338
        parts = string.split('T')
339
        date_parts = parts[0]
340
        time_parts = parts[1][:-1]
341
        year, month, day = [int(i) for i in date_parts.split('-')]
342
        hour, minutes, seconds = [int(i) for i in time_parts.split(':')]
343
        return datetime.datetime(year, month, day, hour, minutes, seconds)
344
345
346
if __name__ == '__main__':
347
    main()
348