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