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