##// END OF EJS Templates
attempt to cache gh_api requests...
MinRK -
Show More
@@ -1,238 +1,245 b''
1 """Functions for Github authorisation."""
1 """Functions for Github API requests."""
2 from __future__ import print_function
2 from __future__ import print_function
3
3
4 try:
4 try:
5 input = raw_input
5 input = raw_input
6 except NameError:
6 except NameError:
7 pass
7 pass
8
8
9 import os
9 import os
10 import re
10 import re
11 import sys
11 import sys
12
12
13 import requests
13 import requests
14 import getpass
14 import getpass
15 import json
15 import json
16
16
17 try:
18 import requests_cache
19 except ImportError:
20 print("no cache")
21 else:
22 requests_cache.install_cache("gh_api")
23
17 # Keyring stores passwords by a 'username', but we're not storing a username and
24 # Keyring stores passwords by a 'username', but we're not storing a username and
18 # password
25 # password
19 fake_username = 'ipython_tools'
26 fake_username = 'ipython_tools'
20
27
21 class Obj(dict):
28 class Obj(dict):
22 """Dictionary with attribute access to names."""
29 """Dictionary with attribute access to names."""
23 def __getattr__(self, name):
30 def __getattr__(self, name):
24 try:
31 try:
25 return self[name]
32 return self[name]
26 except KeyError:
33 except KeyError:
27 raise AttributeError(name)
34 raise AttributeError(name)
28
35
29 def __setattr__(self, name, val):
36 def __setattr__(self, name, val):
30 self[name] = val
37 self[name] = val
31
38
32 token = None
39 token = None
33 def get_auth_token():
40 def get_auth_token():
34 global token
41 global token
35
42
36 if token is not None:
43 if token is not None:
37 return token
44 return token
38
45
39 import keyring
46 import keyring
40 token = keyring.get_password('github', fake_username)
47 token = keyring.get_password('github', fake_username)
41 if token is not None:
48 if token is not None:
42 return token
49 return token
43
50
44 print("Please enter your github username and password. These are not "
51 print("Please enter your github username and password. These are not "
45 "stored, only used to get an oAuth token. You can revoke this at "
52 "stored, only used to get an oAuth token. You can revoke this at "
46 "any time on Github.")
53 "any time on Github.")
47 user = input("Username: ")
54 user = input("Username: ")
48 pw = getpass.getpass("Password: ")
55 pw = getpass.getpass("Password: ")
49
56
50 auth_request = {
57 auth_request = {
51 "scopes": [
58 "scopes": [
52 "public_repo",
59 "public_repo",
53 "gist"
60 "gist"
54 ],
61 ],
55 "note": "IPython tools",
62 "note": "IPython tools",
56 "note_url": "https://github.com/ipython/ipython/tree/master/tools",
63 "note_url": "https://github.com/ipython/ipython/tree/master/tools",
57 }
64 }
58 response = requests.post('https://api.github.com/authorizations',
65 response = requests.post('https://api.github.com/authorizations',
59 auth=(user, pw), data=json.dumps(auth_request))
66 auth=(user, pw), data=json.dumps(auth_request))
60 response.raise_for_status()
67 response.raise_for_status()
61 token = json.loads(response.text)['token']
68 token = json.loads(response.text)['token']
62 keyring.set_password('github', fake_username, token)
69 keyring.set_password('github', fake_username, token)
63 return token
70 return token
64
71
65 def make_auth_header():
72 def make_auth_header():
66 return {'Authorization': 'token ' + get_auth_token()}
73 return {'Authorization': 'token ' + get_auth_token()}
67
74
68 def post_issue_comment(project, num, body):
75 def post_issue_comment(project, num, body):
69 url = 'https://api.github.com/repos/{project}/issues/{num}/comments'.format(project=project, num=num)
76 url = 'https://api.github.com/repos/{project}/issues/{num}/comments'.format(project=project, num=num)
70 payload = json.dumps({'body': body})
77 payload = json.dumps({'body': body})
71 requests.post(url, data=payload, headers=make_auth_header())
78 requests.post(url, data=payload, headers=make_auth_header())
72
79
73 def post_gist(content, description='', filename='file', auth=False):
80 def post_gist(content, description='', filename='file', auth=False):
74 """Post some text to a Gist, and return the URL."""
81 """Post some text to a Gist, and return the URL."""
75 post_data = json.dumps({
82 post_data = json.dumps({
76 "description": description,
83 "description": description,
77 "public": True,
84 "public": True,
78 "files": {
85 "files": {
79 filename: {
86 filename: {
80 "content": content
87 "content": content
81 }
88 }
82 }
89 }
83 }).encode('utf-8')
90 }).encode('utf-8')
84
91
85 headers = make_auth_header() if auth else {}
92 headers = make_auth_header() if auth else {}
86 response = requests.post("https://api.github.com/gists", data=post_data, headers=headers)
93 response = requests.post("https://api.github.com/gists", data=post_data, headers=headers)
87 response.raise_for_status()
94 response.raise_for_status()
88 response_data = json.loads(response.text)
95 response_data = json.loads(response.text)
89 return response_data['html_url']
96 return response_data['html_url']
90
97
91 def get_pull_request(project, num, auth=False):
98 def get_pull_request(project, num, auth=False):
92 """get pull request info by number
99 """get pull request info by number
93 """
100 """
94 url = "https://api.github.com/repos/{project}/pulls/{num}".format(project=project, num=num)
101 url = "https://api.github.com/repos/{project}/pulls/{num}".format(project=project, num=num)
95 if auth:
102 if auth:
96 header = make_auth_header()
103 header = make_auth_header()
97 else:
104 else:
98 header = None
105 header = None
99 response = requests.get(url, headers=header)
106 response = requests.get(url, headers=header)
100 response.raise_for_status()
107 response.raise_for_status()
101 return json.loads(response.text, object_hook=Obj)
108 return json.loads(response.text, object_hook=Obj)
102
109
103 element_pat = re.compile(r'<(.+?)>')
110 element_pat = re.compile(r'<(.+?)>')
104 rel_pat = re.compile(r'rel=[\'"](\w+)[\'"]')
111 rel_pat = re.compile(r'rel=[\'"](\w+)[\'"]')
105
112
106 def get_paged_request(url, headers=None):
113 def get_paged_request(url, headers=None):
107 """get a full list, handling APIv3's paging"""
114 """get a full list, handling APIv3's paging"""
108 results = []
115 results = []
109 while True:
116 while True:
110 print("fetching %s" % url, file=sys.stderr)
117 print("fetching %s" % url, file=sys.stderr)
111 response = requests.get(url, headers=headers)
118 response = requests.get(url, headers=headers)
112 response.raise_for_status()
119 response.raise_for_status()
113 results.extend(response.json())
120 results.extend(response.json())
114 if 'next' in response.links:
121 if 'next' in response.links:
115 url = response.links['next']['url']
122 url = response.links['next']['url']
116 else:
123 else:
117 break
124 break
118 return results
125 return results
119
126
120 def get_pulls_list(project, state="closed", auth=False):
127 def get_pulls_list(project, state="closed", auth=False):
121 """get pull request list
128 """get pull request list
122 """
129 """
123 url = "https://api.github.com/repos/{project}/pulls?state={state}&per_page=100".format(project=project, state=state)
130 url = "https://api.github.com/repos/{project}/pulls?state={state}&per_page=100".format(project=project, state=state)
124 if auth:
131 if auth:
125 headers = make_auth_header()
132 headers = make_auth_header()
126 else:
133 else:
127 headers = None
134 headers = None
128 pages = get_paged_request(url, headers=headers)
135 pages = get_paged_request(url, headers=headers)
129 return pages
136 return pages
130
137
131 def get_issues_list(project, state="closed", auth=False):
138 def get_issues_list(project, state="closed", auth=False):
132 """get pull request list
139 """get pull request list
133 """
140 """
134 url = "https://api.github.com/repos/{project}/pulls?state={state}&per_page=100".format(project=project, state=state)
141 url = "https://api.github.com/repos/{project}/pulls?state={state}&per_page=100".format(project=project, state=state)
135 if auth:
142 if auth:
136 headers = make_auth_header()
143 headers = make_auth_header()
137 else:
144 else:
138 headers = None
145 headers = None
139 pages = get_paged_request(url, headers=headers)
146 pages = get_paged_request(url, headers=headers)
140 return pages
147 return pages
141
148
142 # encode_multipart_formdata is from urllib3.filepost
149 # encode_multipart_formdata is from urllib3.filepost
143 # The only change is to iter_fields, to enforce S3's required key ordering
150 # The only change is to iter_fields, to enforce S3's required key ordering
144
151
145 def iter_fields(fields):
152 def iter_fields(fields):
146 fields = fields.copy()
153 fields = fields.copy()
147 for key in ('key', 'acl', 'Filename', 'success_action_status', 'AWSAccessKeyId',
154 for key in ('key', 'acl', 'Filename', 'success_action_status', 'AWSAccessKeyId',
148 'Policy', 'Signature', 'Content-Type', 'file'):
155 'Policy', 'Signature', 'Content-Type', 'file'):
149 yield (key, fields.pop(key))
156 yield (key, fields.pop(key))
150 for (k,v) in fields.items():
157 for (k,v) in fields.items():
151 yield k,v
158 yield k,v
152
159
153 def encode_multipart_formdata(fields, boundary=None):
160 def encode_multipart_formdata(fields, boundary=None):
154 """
161 """
155 Encode a dictionary of ``fields`` using the multipart/form-data mime format.
162 Encode a dictionary of ``fields`` using the multipart/form-data mime format.
156
163
157 :param fields:
164 :param fields:
158 Dictionary of fields or list of (key, value) field tuples. The key is
165 Dictionary of fields or list of (key, value) field tuples. The key is
159 treated as the field name, and the value as the body of the form-data
166 treated as the field name, and the value as the body of the form-data
160 bytes. If the value is a tuple of two elements, then the first element
167 bytes. If the value is a tuple of two elements, then the first element
161 is treated as the filename of the form-data section.
168 is treated as the filename of the form-data section.
162
169
163 Field names and filenames must be unicode.
170 Field names and filenames must be unicode.
164
171
165 :param boundary:
172 :param boundary:
166 If not specified, then a random boundary will be generated using
173 If not specified, then a random boundary will be generated using
167 :func:`mimetools.choose_boundary`.
174 :func:`mimetools.choose_boundary`.
168 """
175 """
169 # copy requests imports in here:
176 # copy requests imports in here:
170 from io import BytesIO
177 from io import BytesIO
171 from requests.packages.urllib3.filepost import (
178 from requests.packages.urllib3.filepost import (
172 choose_boundary, six, writer, b, get_content_type
179 choose_boundary, six, writer, b, get_content_type
173 )
180 )
174 body = BytesIO()
181 body = BytesIO()
175 if boundary is None:
182 if boundary is None:
176 boundary = choose_boundary()
183 boundary = choose_boundary()
177
184
178 for fieldname, value in iter_fields(fields):
185 for fieldname, value in iter_fields(fields):
179 body.write(b('--%s\r\n' % (boundary)))
186 body.write(b('--%s\r\n' % (boundary)))
180
187
181 if isinstance(value, tuple):
188 if isinstance(value, tuple):
182 filename, data = value
189 filename, data = value
183 writer(body).write('Content-Disposition: form-data; name="%s"; '
190 writer(body).write('Content-Disposition: form-data; name="%s"; '
184 'filename="%s"\r\n' % (fieldname, filename))
191 'filename="%s"\r\n' % (fieldname, filename))
185 body.write(b('Content-Type: %s\r\n\r\n' %
192 body.write(b('Content-Type: %s\r\n\r\n' %
186 (get_content_type(filename))))
193 (get_content_type(filename))))
187 else:
194 else:
188 data = value
195 data = value
189 writer(body).write('Content-Disposition: form-data; name="%s"\r\n'
196 writer(body).write('Content-Disposition: form-data; name="%s"\r\n'
190 % (fieldname))
197 % (fieldname))
191 body.write(b'Content-Type: text/plain\r\n\r\n')
198 body.write(b'Content-Type: text/plain\r\n\r\n')
192
199
193 if isinstance(data, int):
200 if isinstance(data, int):
194 data = str(data) # Backwards compatibility
201 data = str(data) # Backwards compatibility
195 if isinstance(data, six.text_type):
202 if isinstance(data, six.text_type):
196 writer(body).write(data)
203 writer(body).write(data)
197 else:
204 else:
198 body.write(data)
205 body.write(data)
199
206
200 body.write(b'\r\n')
207 body.write(b'\r\n')
201
208
202 body.write(b('--%s--\r\n' % (boundary)))
209 body.write(b('--%s--\r\n' % (boundary)))
203
210
204 content_type = b('multipart/form-data; boundary=%s' % boundary)
211 content_type = b('multipart/form-data; boundary=%s' % boundary)
205
212
206 return body.getvalue(), content_type
213 return body.getvalue(), content_type
207
214
208
215
209 def post_download(project, filename, name=None, description=""):
216 def post_download(project, filename, name=None, description=""):
210 """Upload a file to the GitHub downloads area"""
217 """Upload a file to the GitHub downloads area"""
211 if name is None:
218 if name is None:
212 name = os.path.basename(filename)
219 name = os.path.basename(filename)
213 with open(filename, 'rb') as f:
220 with open(filename, 'rb') as f:
214 filedata = f.read()
221 filedata = f.read()
215
222
216 url = "https://api.github.com/repos/{project}/downloads".format(project=project)
223 url = "https://api.github.com/repos/{project}/downloads".format(project=project)
217
224
218 payload = json.dumps(dict(name=name, size=len(filedata),
225 payload = json.dumps(dict(name=name, size=len(filedata),
219 description=description))
226 description=description))
220 response = requests.post(url, data=payload, headers=make_auth_header())
227 response = requests.post(url, data=payload, headers=make_auth_header())
221 response.raise_for_status()
228 response.raise_for_status()
222 reply = json.loads(response.content)
229 reply = json.loads(response.content)
223 s3_url = reply['s3_url']
230 s3_url = reply['s3_url']
224
231
225 fields = dict(
232 fields = dict(
226 key=reply['path'],
233 key=reply['path'],
227 acl=reply['acl'],
234 acl=reply['acl'],
228 success_action_status=201,
235 success_action_status=201,
229 Filename=reply['name'],
236 Filename=reply['name'],
230 AWSAccessKeyId=reply['accesskeyid'],
237 AWSAccessKeyId=reply['accesskeyid'],
231 Policy=reply['policy'],
238 Policy=reply['policy'],
232 Signature=reply['signature'],
239 Signature=reply['signature'],
233 file=(reply['name'], filedata),
240 file=(reply['name'], filedata),
234 )
241 )
235 fields['Content-Type'] = reply['mime_type']
242 fields['Content-Type'] = reply['mime_type']
236 data, content_type = encode_multipart_formdata(fields)
243 data, content_type = encode_multipart_formdata(fields)
237 s3r = requests.post(s3_url, data=data, headers={'Content-Type': content_type})
244 s3r = requests.post(s3_url, data=data, headers={'Content-Type': content_type})
238 return s3r
245 return s3r
@@ -1,189 +1,180 b''
1 #!/usr/bin/env python
1 #!/usr/bin/env python
2 """Simple tools to query github.com and gather stats about issues.
2 """Simple tools to query github.com and gather stats about issues.
3 """
3 """
4 #-----------------------------------------------------------------------------
4 #-----------------------------------------------------------------------------
5 # Imports
5 # Imports
6 #-----------------------------------------------------------------------------
6 #-----------------------------------------------------------------------------
7
7
8 from __future__ import print_function
8 from __future__ import print_function
9
9
10 import json
10 import json
11 import re
11 import re
12 import sys
12 import sys
13
13
14 from datetime import datetime, timedelta
14 from datetime import datetime, timedelta
15 from subprocess import check_output
15 from subprocess import check_output
16 from urllib import urlopen
16 from gh_api import get_paged_request, make_auth_header, get_pull_request
17
17
18 #-----------------------------------------------------------------------------
18 #-----------------------------------------------------------------------------
19 # Globals
19 # Globals
20 #-----------------------------------------------------------------------------
20 #-----------------------------------------------------------------------------
21
21
22 ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
22 ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
23 PER_PAGE = 100
23 PER_PAGE = 100
24
24
25 element_pat = re.compile(r'<(.+?)>')
26 rel_pat = re.compile(r'rel=[\'"](\w+)[\'"]')
27
28 #-----------------------------------------------------------------------------
25 #-----------------------------------------------------------------------------
29 # Functions
26 # Functions
30 #-----------------------------------------------------------------------------
27 #-----------------------------------------------------------------------------
31
28
32 def parse_link_header(headers):
33 link_s = headers.get('link', '')
34 urls = element_pat.findall(link_s)
35 rels = rel_pat.findall(link_s)
36 d = {}
37 for rel,url in zip(rels, urls):
38 d[rel] = url
39 return d
40
41 def get_paged_request(url):
42 """get a full list, handling APIv3's paging"""
43 results = []
44 while url:
45 print("fetching %s" % url, file=sys.stderr)
46 f = urlopen(url)
47 results.extend(json.load(f))
48 links = parse_link_header(f.headers)
49 url = links.get('next')
50 return results
51
52 def get_issues(project="ipython/ipython", state="closed", pulls=False):
29 def get_issues(project="ipython/ipython", state="closed", pulls=False):
53 """Get a list of the issues from the Github API."""
30 """Get a list of the issues from the Github API."""
54 which = 'pulls' if pulls else 'issues'
31 which = 'pulls' if pulls else 'issues'
55 url = "https://api.github.com/repos/%s/%s?state=%s&per_page=%i" % (project, which, state, PER_PAGE)
32 url = "https://api.github.com/repos/%s/%s?state=%s&per_page=%i" % (project, which, state, PER_PAGE)
56 return get_paged_request(url)
33 return get_paged_request(url, headers=make_auth_header())
57
34
58
35
59 def _parse_datetime(s):
36 def _parse_datetime(s):
60 """Parse dates in the format returned by the Github API."""
37 """Parse dates in the format returned by the Github API."""
61 if s:
38 if s:
62 return datetime.strptime(s, ISO8601)
39 return datetime.strptime(s, ISO8601)
63 else:
40 else:
64 return datetime.fromtimestamp(0)
41 return datetime.fromtimestamp(0)
65
42
66
43
67 def issues2dict(issues):
44 def issues2dict(issues):
68 """Convert a list of issues to a dict, keyed by issue number."""
45 """Convert a list of issues to a dict, keyed by issue number."""
69 idict = {}
46 idict = {}
70 for i in issues:
47 for i in issues:
71 idict[i['number']] = i
48 idict[i['number']] = i
72 return idict
49 return idict
73
50
74
51
75 def is_pull_request(issue):
52 def is_pull_request(issue):
76 """Return True if the given issue is a pull request."""
53 """Return True if the given issue is a pull request."""
77 return 'pull_request_url' in issue
54 return bool(issue.get('pull_request', {}).get('html_url', None))
55
56
57 def split_pulls(all_issues, project="ipython/ipython"):
58 """split a list of closed issues into non-PR Issues and Pull Requests"""
59 pulls = []
60 issues = []
61 for i in all_issues:
62 if is_pull_request(i):
63 pull = get_pull_request(project, i['number'], auth=True)
64 pulls.append(pull)
65 else:
66 issues.append(i)
67 return issues, pulls
78
68
79
69
80 def issues_closed_since(period=timedelta(days=365), project="ipython/ipython", pulls=False):
70
71 def issues_closed_since(period=timedelta(days=365), project="ipython/ipython"):
81 """Get all issues closed since a particular point in time. period
72 """Get all issues closed since a particular point in time. period
82 can either be a datetime object, or a timedelta object. In the
73 can either be a datetime object, or a timedelta object. In the
83 latter case, it is used as a time before the present."""
74 latter case, it is used as a time before the present.
75 """
84
76
85 which = 'pulls' if pulls else 'issues'
77 which = 'issues'
86
78
87 if isinstance(period, timedelta):
79 if isinstance(period, timedelta):
88 period = datetime.now() - period
80 since = datetime.now() - period
89 url = "https://api.github.com/repos/%s/%s?state=closed&sort=updated&since=%s&per_page=%i" % (project, which, period.strftime(ISO8601), PER_PAGE)
81 else:
90 allclosed = get_paged_request(url)
82 since = period
91 # allclosed = get_issues(project=project, state='closed', pulls=pulls, since=period)
83 url = "https://api.github.com/repos/%s/%s?state=closed&sort=updated&since=%s&per_page=%i" % (project, which, since.strftime(ISO8601), PER_PAGE)
92 filtered = [i for i in allclosed if _parse_datetime(i['closed_at']) > period]
84 allclosed = get_paged_request(url, headers=make_auth_header())
93
85
94 # exclude rejected PRs
86 issues, pulls = split_pulls(allclosed, project=project)
95 if pulls:
87 issues = [i for i in issues if _parse_datetime(i['closed_at']) > since]
96 filtered = [ pr for pr in filtered if pr['merged_at'] ]
88 pulls = [p for p in pulls if p['merged_at'] and _parse_datetime(p['merged_at']) > since]
97
89
98 return filtered
90 return issues, pulls
99
91
100
92
101 def sorted_by_field(issues, field='closed_at', reverse=False):
93 def sorted_by_field(issues, field='closed_at', reverse=False):
102 """Return a list of issues sorted by closing date date."""
94 """Return a list of issues sorted by closing date date."""
103 return sorted(issues, key = lambda i:i[field], reverse=reverse)
95 return sorted(issues, key = lambda i:i[field], reverse=reverse)
104
96
105
97
106 def report(issues, show_urls=False):
98 def report(issues, show_urls=False):
107 """Summary report about a list of issues, printing number and title.
99 """Summary report about a list of issues, printing number and title.
108 """
100 """
109 # titles may have unicode in them, so we must encode everything below
101 # titles may have unicode in them, so we must encode everything below
110 if show_urls:
102 if show_urls:
111 for i in issues:
103 for i in issues:
112 role = 'ghpull' if 'merged_at' in i else 'ghissue'
104 role = 'ghpull' if 'merged_at' in i else 'ghissue'
113 print('* :%s:`%d`: %s' % (role, i['number'],
105 print('* :%s:`%d`: %s' % (role, i['number'],
114 i['title'].encode('utf-8')))
106 i['title'].encode('utf-8')))
115 else:
107 else:
116 for i in issues:
108 for i in issues:
117 print('* %d: %s' % (i['number'], i['title'].encode('utf-8')))
109 print('* %d: %s' % (i['number'], i['title'].encode('utf-8')))
118
110
119 #-----------------------------------------------------------------------------
111 #-----------------------------------------------------------------------------
120 # Main script
112 # Main script
121 #-----------------------------------------------------------------------------
113 #-----------------------------------------------------------------------------
122
114
123 if __name__ == "__main__":
115 if __name__ == "__main__":
124 # Whether to add reST urls for all issues in printout.
116 # Whether to add reST urls for all issues in printout.
125 show_urls = True
117 show_urls = True
126
118
127 # By default, search one month back
119 # By default, search one month back
128 tag = None
120 tag = None
129 if len(sys.argv) > 1:
121 if len(sys.argv) > 1:
130 try:
122 try:
131 days = int(sys.argv[1])
123 days = int(sys.argv[1])
132 except:
124 except:
133 tag = sys.argv[1]
125 tag = sys.argv[1]
134 else:
126 else:
135 tag = check_output(['git', 'describe', '--abbrev=0']).strip()
127 tag = check_output(['git', 'describe', '--abbrev=0']).strip()
136
128
137 if tag:
129 if tag:
138 cmd = ['git', 'log', '-1', '--format=%ai', tag]
130 cmd = ['git', 'log', '-1', '--format=%ai', tag]
139 tagday, tz = check_output(cmd).strip().rsplit(' ', 1)
131 tagday, tz = check_output(cmd).strip().rsplit(' ', 1)
140 since = datetime.strptime(tagday, "%Y-%m-%d %H:%M:%S")
132 since = datetime.strptime(tagday, "%Y-%m-%d %H:%M:%S")# - timedelta(days=30 * 6)
141 else:
133 else:
142 since = datetime.now() - timedelta(days=days)
134 since = datetime.now() - timedelta(days=days)
143
135
144 print("fetching GitHub stats since %s (tag: %s)" % (since, tag), file=sys.stderr)
136 print("fetching GitHub stats since %s (tag: %s)" % (since, tag), file=sys.stderr)
145 # turn off to play interactively without redownloading, use %run -i
137 # turn off to play interactively without redownloading, use %run -i
146 if 1:
138 if 1:
147 issues = issues_closed_since(since, pulls=False)
139 issues, pulls = issues_closed_since(since)
148 pulls = issues_closed_since(since, pulls=True)
149
140
150 # For regular reports, it's nice to show them in reverse chronological order
141 # For regular reports, it's nice to show them in reverse chronological order
151 issues = sorted_by_field(issues, reverse=True)
142 issues = sorted_by_field(issues, reverse=True)
152 pulls = sorted_by_field(pulls, reverse=True)
143 pulls = sorted_by_field(pulls, reverse=True)
153
144
154 n_issues, n_pulls = map(len, (issues, pulls))
145 n_issues, n_pulls = map(len, (issues, pulls))
155 n_total = n_issues + n_pulls
146 n_total = n_issues + n_pulls
156
147
157 # Print summary report we can directly include into release notes.
148 # Print summary report we can directly include into release notes.
158 print()
149 print()
159 since_day = since.strftime("%Y/%m/%d")
150 since_day = since.strftime("%Y/%m/%d")
160 today = datetime.today().strftime("%Y/%m/%d")
151 today = datetime.today().strftime("%Y/%m/%d")
161 print("GitHub stats for %s - %s (tag: %s)" % (since_day, today, tag))
152 print("GitHub stats for %s - %s (tag: %s)" % (since_day, today, tag))
162 print()
153 print()
163 print("These lists are automatically generated, and may be incomplete or contain duplicates.")
154 print("These lists are automatically generated, and may be incomplete or contain duplicates.")
164 print()
155 print()
165 if tag:
156 if tag:
166 # print git info, in addition to GitHub info:
157 # print git info, in addition to GitHub info:
167 since_tag = tag+'..'
158 since_tag = tag+'..'
168 cmd = ['git', 'log', '--oneline', since_tag]
159 cmd = ['git', 'log', '--oneline', since_tag]
169 ncommits = len(check_output(cmd).splitlines())
160 ncommits = len(check_output(cmd).splitlines())
170
161
171 author_cmd = ['git', 'log', '--format=* %aN', since_tag]
162 author_cmd = ['git', 'log', '--format=* %aN', since_tag]
172 all_authors = check_output(author_cmd).splitlines()
163 all_authors = check_output(author_cmd).splitlines()
173 unique_authors = sorted(set(all_authors))
164 unique_authors = sorted(set(all_authors))
174
165
175 print("The following %i authors contributed %i commits." % (len(unique_authors), ncommits))
166 print("The following %i authors contributed %i commits." % (len(unique_authors), ncommits))
176 print()
167 print()
177 print('\n'.join(unique_authors))
168 print('\n'.join(unique_authors))
178 print()
169 print()
179
170
180 print()
171 print()
181 print("We closed a total of %d issues, %d pull requests and %d regular issues;\n"
172 print("We closed a total of %d issues, %d pull requests and %d regular issues;\n"
182 "this is the full list (generated with the script \n"
173 "this is the full list (generated with the script \n"
183 ":file:`tools/github_stats.py`):" % (n_total, n_pulls, n_issues))
174 ":file:`tools/github_stats.py`):" % (n_total, n_pulls, n_issues))
184 print()
175 print()
185 print('Pull Requests (%d):\n' % n_pulls)
176 print('Pull Requests (%d):\n' % n_pulls)
186 report(pulls, show_urls)
177 report(pulls, show_urls)
187 print()
178 print()
188 print('Issues (%d):\n' % n_issues)
179 print('Issues (%d):\n' % n_issues)
189 report(issues, show_urls)
180 report(issues, show_urls)
General Comments 0
You need to be logged in to leave comments. Login now