This repository has been archived by the owner on Dec 5, 2018. It is now read-only.
forked from nsfmc/kiln-review
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathreview.py
executable file
·419 lines (346 loc) · 16.1 KB
/
review.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# codereview.py
#
# Author: Craig Silverstein <[email protected]>
#
# Based on code that is
# Copyright Marcos Ojeda <[email protected]> on 2012-01-23.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
"""Wrap 'hg push' to create a code review in Kiln.
This extension adds the --rr (for 'review reviewer') flag to hg push,
and requires all push requests to specify --rr. After doing the hg
push, the extension will create a new review request on kilnhg, and
set the reviewers to be those people specified with the --rr flag.
The user may specify --rr none to override the review functionality.
In that case, this extension will just do a normal 'hg push'.
This script requires the following fields to be set in the hg
config file (.hgrc or the like):
[auth]
kiln.prefix: the kilnhg url for this project (eg http://khanacademy.kilnhg.org)
kiln.username: your kilnhg username (eg [email protected])
kiln.password: your kilnhg password (eg likeidtellyou)
"""
import json
import mercurial.cmdutil
import mercurial.commands
import mercurial.extensions
import mercurial.hg
import mercurial.node
import mercurial.scmutil
import mercurial.ui
import mercurial.util
import os
import sys
import time
import urllib
import urllib2
def _slurp(url, params, post=False):
"""Fetch contents of a url-with-query-params dict (either GET or POST)."""
try:
params = urllib.urlencode(params, doseq=True) # vals can be lists
if post:
handle = urllib2.urlopen(url, params)
else:
handle = urllib2.urlopen(url + '?' + params)
try:
content = handle.read()
finally:
handle.close()
except urllib2.URLError, why:
# Would be nice to show params too, but they may contain a password.
raise mercurial.util.Abort('Error communicating with kilnhg:'
' url "%s", error "%s"' % (url, why))
return json.loads(content)
# via https://developers.fogbugz.com/default.asp?W157
def _kiln_url(command):
"""Create a kiln api call to your kiln project, for the given command."""
url_prefix = mercurial.ui.ui().config('auth', 'kiln.prefix')
return '%s/Api/2.0/%s' % (url_prefix, command)
def _slurp_from_kiln(command, params, post=False):
"""Create a kiln url from command, and fetches its contents."""
return _slurp(_kiln_url(command), params, post)
def _get_authtoken_from_kilnauth(ui):
"""Attempts to use kilnauth, if it is installed, to find a kiln token."""
kilnauth_path = ui.config('extensions', 'kilnauth')
if kilnauth_path:
sys.path.append(os.path.dirname(os.path.expanduser(kilnauth_path)))
try:
import kilnauth
except ImportError:
return None
class FakeRepo:
def local(self):
return True
kilnauth.reposetup(ui, FakeRepo())
hostname = ui.config('auth', 'kiln.prefix')
if '://' in hostname:
hostname = hostname[hostname.find('://') + 3:]
if hostname.endswith('/'):
hostname = hostname[:-1]
for cookie in kilnauth.get_cookiejar(ui):
if cookie.domain == hostname and cookie.name == 'fbToken':
return cookie.value
return None
def _get_authtoken(ui):
"""Returns credentials for accessing the kilnhg website."""
# If we can, we extract the token from kilnauth, if not we
# fall back to hard-coded username/password in the config.
retval = _get_authtoken_from_kilnauth(ui)
if retval:
return retval
username = mercurial.ui.ui().config('auth', 'kiln.username')
password = mercurial.ui.ui().config('auth', 'kiln.password')
retval = _slurp_from_kiln('Auth/Login',
{'sUser': username, 'sPassword': password})
if 'errors' in retval:
err = ('Cannot access kiln. To fix:\n'
' 1) Make sure you have "kilnauth" in your .hgrc\n'
' 2) Make sure you have a valid cookie (run hg identify '
'https://khanacademy.kilnhg.com/Code/Private/Group/backup-and-analytics '
'to force that)\n'
' 3) If neither of these solve the problem, you can bypass '
'kilnauth by hard-coding your username and password in your '
'.hgrc: add [auth]kiln.username (your kiln email address) and '
'[auth]kiln.password.')
raise mercurial.util.Abort(err)
return retval
def _get_repo_to_push_to(repo, preferred_repo):
"""Of all the repositories in the user's path, return the one to push to.
The 'best' if the passed-in preferred_repo, which comes from the
'hg push' commandline if present, or 'default-push' or
'default'.
Arguments:
repo: the hg repository being used. We need it only for its config.
preferred_repo: the argument to 'hg push', or None if no
argument is given. This is the same as the DEST argument
to 'hg push'.
"""
# We do all case-insensitive comparisons here, and convert to a dict.
repos = dict((x.lower(), y.lower())
for (x, y) in repo.ui.configitems('paths'))
if preferred_repo:
return repos.get(preferred_repo.lower(), preferred_repo.lower())
if 'default-push' in repos:
return repos['default-push']
if 'default' in repos:
return repos['default']
return None
def _get_reviewers(ui, auth_token, reviewers):
"""Given a list of desired reviewers, return a list of kiln people objects.
The reviewers are specified as a list of 'names', where a name is
a subset of either the perons's physical name as recorded on the
kilnhg site, or their email address as recorded on the kilnhg
site.
This function downloads a list of all the kiln 'person records'
that are visible to the current user, and for each specified
reviewer, finds the corresponding 'person record' for it. In
the case of ambiguity, it presents the user with a choice.
Arguments:
ui: the hg-to-console ui element
auth_token: the token used to authenticate the user, from _get_authtoken
reviewers: a list of reviewer-names, as described above. Each
element of the list can also be a comma-separated list of
names, for instance [ 'tom', 'dick,harry' ]
Returns:
A set of kiln people records, one for each reviewer specified
in reviewers. A peopel record has an 'sName, 'sEmail', and
'ixPerson' field.
Raises:
Abort if no person-record is found for any of the reviewers.
"""
all_people = _slurp_from_kiln('Person', {'token': auth_token})
# Convert the list to a set, dealing with commas as we go.
all_reviewers = set()
for reviewer_entry in reviewers:
for one_review in reviewer_entry.split(','):
all_reviewers.add(one_review.strip().lower())
# For each asked-for reviewer, find the set of people records that
# reviewer could be referring to. Hopefully it's exactly one!
disambiguated_reviewers = {} # map from email (unique id) to person-record
for reviewer in all_reviewers:
candidate_reviewers = [] # all people whose name match 'reviewer'
for person in all_people:
if (reviewer in person["sName"].lower() or
reviewer in person["sEmail"].lower()):
candidate_reviewers.append(person)
if not candidate_reviewers: # no person matched the reviewer
raise mercurial.util.Abort('No reviewer found matching "%s"'
% reviewer)
elif len(candidate_reviewers) > 1:
ui.status('\nHmm...There are a few folks matching "%s"\n'
% reviewer)
choices = ['%s. %s (%s)\n' % (i + 1, p['sName'], p['sEmail'])
for (i, p) in enumerate(candidate_reviewers)]
for choice in choices:
ui.status(choice)
pick = ui.promptchoice('Which "%s" did you mean?' % reviewer,
["&" + c for c in choices])
picked_reviewer = candidate_reviewers[pick]
else:
picked_reviewer = candidate_reviewers[0]
disambiguated_reviewers[picked_reviewer['sEmail']] = picked_reviewer
return disambiguated_reviewers.values()
def _get_repo_index_for_repo_url(repo, auth_token, repo_url):
"""For a given repository, return its ixRepo, or None if not readable."""
url_prefix = repo.ui.config('auth', 'kiln.prefix')
all_projects = _slurp_from_kiln("Project", {'token': auth_token})
for project in all_projects:
for repo_group in project['repoGroups']:
for repo in repo_group['repos']:
url = '%s/code/%s/%s/%s' % (url_prefix, repo['sProjectSlug'],
repo['sGroupSlug'], repo['sSlug'])
if url.lower() == repo_url.lower():
return repo['ixRepo']
raise mercurial.util.Abort('No repository found matching %s' % repo_url)
def _make_review(params):
"""Create a review on the hgkiln website.
Arguments:
params: the parameters to the "create review" API call.
See https://developers.fogbugz.com/default.asp?W167
We use ixReviewers, ixRepo, maybe revs and title and desc.
Returns:
The return message from the "create review" API call: None on
failure, and <something else> on success.
"""
return _slurp_from_kiln("Review/Create", params, post=True)
def push_with_review(origfn, ui, repo, *args, **opts):
"""overrides 'hg push' to add creating a code review for the push on kiln.
Review creates a brand new code review on kiln for a changeset on kiln.
If no revision is specified, the code review defaults to the most recent
changeset.
Specify people to peek at your review by passing a comma-separated list
of people to review your code, by passing multiple --rr flags, or both.
hg push --rr tim,alex,ben --rr joey
You can specify revisions by passing a hash-range,
hg push --rrev 13bs32abc:tip
or by passing individual changesets
hg push --rrev 75c471319a5b --rrev 41056495619c
Using --reditor will open up your favorite editor and includes all
the changeset descriptions for any revisions selected as the code
review comment.
All the flags supported by 'hg push' are passed through to push.
"""
# First order of business: If the user passed in --rr none, just
# fall back onto the native push.
if opts.get('rr') == ['none']:
return origfn(ui, repo, *args, **opts)
# TODO(csilvers): also bypass review if
# a) this push is a conflict-free merge push
# b) they have a review number in the commit notes (this means
# there's already a review for them in kilnhg).
url_prefix = repo.ui.config('auth', 'kiln.prefix')
if url_prefix is None:
ui.warn("In order to work, in your hgrc please set:\n\n")
ui.warn("[auth]\n")
ui.warn("kiln.prefix = https://<kilnrepo.kilnhg.com>"
" # no trailing /\n")
ui.warn("kiln.username = <username>@<domain.com>\n")
ui.warn("kiln.password = <password>\n")
return 0
# dest is the commandline argument. At most one should be specified.
dest = None
if args:
if len(args) > 1:
raise mercurial.util.Abort('At most one dest should be specified.')
dest = args[0]
review_params = {}
auth_token = _get_authtoken(ui)
review_params['token'] = auth_token
# -rtitle: title
title = opts.pop('rtitle', None)
if title:
review_params['sTitle'] = title
# -rcomment: comment
comment = opts.pop('rcomment', None)
if comment:
review_params['sDescription'] = comment
# -rrev: revs
revs = opts.pop('rrev', None)
if revs:
changesets = [repo[rev].hex()[:12]
for rev in mercurial.scmutil.revrange(repo, revs)]
else:
# TODO(csilvers): don't use an internal function from hg.
changeset_nodes = mercurial.hg._outgoing(ui, repo, dest,
{'rev': opts.get('rev')})
if not changeset_nodes:
raise mercurial.util.Abort('No changesets found to push/review. '
'Use --rrev to specify changesets '
'manually.')
changesets = [mercurial.node.hex(n)[:12] for n in changeset_nodes]
review_params['revs'] = changesets
# -rr: people
people = opts.pop('rr', None)
if not people:
raise mercurial.util.Abort('Must specify at least one reviewer via '
'--rr. Pass "--rr none" to bypass review.')
assert people != ['none'] # should have been checked above
# The typical use case is to push a review with one or two changes.
# A common user error is pushing a merge, which can result in a lot of
# changes in the review accidentally. Try to detect that.
if len(changesets) > 2:
print ("You're about to create a review with %s changesets." %
len(changesets))
confirmed = raw_input("Are you sure? [y/N]: ")
if confirmed.lower() not in ['y', 'yes']:
raise mercurial.util.Abort("Bailed.")
reviewers = _get_reviewers(ui, auth_token, people)
review_params['ixReviewers'] = [r['ixPerson'] for r in reviewers]
# -e: editor
editor = opts.pop('editor', None)
if editor:
# If -rcomment was also specified, default the editor-text to that.
# Otherwise, use the text from the changesets being reviewed.
if 'sDescription' in review_params:
default_comment = review_params['sDescription']
else:
changeset_descs = [repo[rev].description() for rev in changesets]
default_comment = "\n".join(changeset_descs)
current_user = (repo.ui.config('auth', 'kiln.username') or
repo.ui.config('ui', 'username'))
review_params['sDescription'] = ui.edit(default_comment, current_user)
repo_url_to_push_to = _get_repo_to_push_to(repo, dest)
review_params['ixRepo'] = _get_repo_index_for_repo_url(repo, auth_token,
repo_url_to_push_to)
# First do the push, then do the review.
origfn(ui, repo, *args, **opts)
ui.status('Creating review...\n')
# Sleep for two seconds after the push and before we attempt to create the
# review.
# TODO(kamens): remove this if/when Kiln team gets back with their
# explanation.
time.sleep(2)
review_status = _make_review(review_params)
assert review_status, 'Kiln API is returning None??'
errors = review_status.get('errors')
if errors and errors[0]['codeError'] == 'InvalidChangesets':
ui.status(
'Got "invalid changesets" error back from Kiln -- '
'trying again in 5...\n')
time.sleep(5)
review_status = _make_review(review_params)
if 'sReview' not in review_status:
ui.status('FAILED: %s\n' % review_status)
return 0
ui.status('done!\n')
ui.status('%s/Review/%s\n' % (url_prefix, review_status['sReview']))
return 1
def uisetup(ui):
"""The magic command to set up pre-hg hooks. We override 'hg push'."""
entry = mercurial.extensions.wrapcommand(mercurial.commands.table, 'push',
push_with_review)
extra_opts = [
('', 'rr', [],
('people to include in the review, comma separated,'
' or "none" for no review')),
('', 'rrev', [],
'revisions for review (defaults to `hg outgoing`)'),
('', 'rtitle', '',
'use text as default title for code review'),
('', 'rcomment', '',
'use text as default comment for code review'),
('', 'reditor', False,
'invoke your editor to input the code review comment'),
]
entry[1].extend(extra_opts)