blob: b980929d588a93ec60da21ad6bbe2c098f7fac56 [file] [log] [blame]
Ian Hodson2ee91b42012-05-14 12:29:36 +01001# coding=utf-8
2# (The line above is necessary so that I can use 世界 in the
3# *comment* below without Python getting all bent out of shape.)
4
5# Copyright 2007-2009 Google Inc.
6#
7# Licensed under the Apache License, Version 2.0 (the "License");
8# you may not use this file except in compliance with the License.
9# You may obtain a copy of the License at
10#
11# http://www.apache.org/licenses/LICENSE-2.0
12#
13# Unless required by applicable law or agreed to in writing, software
14# distributed under the License is distributed on an "AS IS" BASIS,
15# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16# See the License for the specific language governing permissions and
17# limitations under the License.
18
19'''Mercurial interface to codereview.appspot.com.
20
21To configure, set the following options in
22your repository's .hg/hgrc file.
23
24 [extensions]
25 codereview = path/to/codereview.py
26
27 [codereview]
28 server = codereview.appspot.com
29
30The server should be running Rietveld; see http://code.google.com/p/rietveld/.
31
32In addition to the new commands, this extension introduces
33the file pattern syntax @nnnnnn, where nnnnnn is a change list
34number, to mean the files included in that change list, which
35must be associated with the current client.
36
37For example, if change 123456 contains the files x.go and y.go,
38"hg diff @123456" is equivalent to"hg diff x.go y.go".
39'''
40
41from mercurial import cmdutil, commands, hg, util, error, match, discovery
42from mercurial.node import nullrev, hex, nullid, short
43import os, re, time
44import stat
45import subprocess
46import threading
47from HTMLParser import HTMLParser
48
49# The standard 'json' package is new in Python 2.6.
50# Before that it was an external package named simplejson.
51try:
52 # Standard location in 2.6 and beyond.
53 import json
54except Exception, e:
55 try:
56 # Conventional name for earlier package.
57 import simplejson as json
58 except:
59 try:
60 # Was also bundled with django, which is commonly installed.
61 from django.utils import simplejson as json
62 except:
63 # We give up.
64 raise e
65
66try:
67 hgversion = util.version()
68except:
69 from mercurial.version import version as v
70 hgversion = v.get_version()
71
72# in Mercurial 1.9 the cmdutil.match and cmdutil.revpair moved to scmutil
73if hgversion >= '1.9':
74 from mercurial import scmutil
75else:
76 scmutil = cmdutil
77
78oldMessage = """
79The code review extension requires Mercurial 1.3 or newer.
80
81To install a new Mercurial,
82
83 sudo easy_install mercurial
84
85works on most systems.
86"""
87
88linuxMessage = """
89You may need to clear your current Mercurial installation by running:
90
91 sudo apt-get remove mercurial mercurial-common
92 sudo rm -rf /etc/mercurial
93"""
94
95if hgversion < '1.3':
96 msg = oldMessage
97 if os.access("/etc/mercurial", 0):
98 msg += linuxMessage
99 raise util.Abort(msg)
100
101def promptyesno(ui, msg):
102 # Arguments to ui.prompt changed between 1.3 and 1.3.1.
103 # Even so, some 1.3.1 distributions seem to have the old prompt!?!?
104 # What a terrible way to maintain software.
105 try:
106 return ui.promptchoice(msg, ["&yes", "&no"], 0) == 0
107 except AttributeError:
108 return ui.prompt(msg, ["&yes", "&no"], "y") != "n"
109
110def incoming(repo, other):
111 fui = FakeMercurialUI()
112 ret = commands.incoming(fui, repo, *[other.path], **{'bundle': '', 'force': False})
113 if ret and ret != 1:
114 raise util.Abort(ret)
115 out = fui.output
116 return out
117
118def outgoing(repo):
119 fui = FakeMercurialUI()
120 ret = commands.outgoing(fui, repo, *[], **{})
121 if ret and ret != 1:
122 raise util.Abort(ret)
123 out = fui.output
124 return out
125
126# To experiment with Mercurial in the python interpreter:
127# >>> repo = hg.repository(ui.ui(), path = ".")
128
129#######################################################################
130# Normally I would split this into multiple files, but it simplifies
131# import path headaches to keep it all in one file. Sorry.
132
133import sys
134if __name__ == "__main__":
135 print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
136 sys.exit(2)
137
138server = "codereview.appspot.com"
139server_url_base = None
140defaultcc = None
141contributors = {}
142missing_codereview = None
143real_rollback = None
144releaseBranch = None
145
146#######################################################################
147# RE: UNICODE STRING HANDLING
148#
149# Python distinguishes between the str (string of bytes)
150# and unicode (string of code points) types. Most operations
151# work on either one just fine, but some (like regexp matching)
152# require unicode, and others (like write) require str.
153#
154# As befits the language, Python hides the distinction between
155# unicode and str by converting between them silently, but
156# *only* if all the bytes/code points involved are 7-bit ASCII.
157# This means that if you're not careful, your program works
158# fine on "hello, world" and fails on "hello, 世界". And of course,
159# the obvious way to be careful - use static types - is unavailable.
160# So the only way is trial and error to find where to put explicit
161# conversions.
162#
163# Because more functions do implicit conversion to str (string of bytes)
164# than do implicit conversion to unicode (string of code points),
165# the convention in this module is to represent all text as str,
166# converting to unicode only when calling a unicode-only function
167# and then converting back to str as soon as possible.
168
169def typecheck(s, t):
170 if type(s) != t:
171 raise util.Abort("type check failed: %s has type %s != %s" % (repr(s), type(s), t))
172
173# If we have to pass unicode instead of str, ustr does that conversion clearly.
174def ustr(s):
175 typecheck(s, str)
176 return s.decode("utf-8")
177
178# Even with those, Mercurial still sometimes turns unicode into str
179# and then tries to use it as ascii. Change Mercurial's default.
180def set_mercurial_encoding_to_utf8():
181 from mercurial import encoding
182 encoding.encoding = 'utf-8'
183
184set_mercurial_encoding_to_utf8()
185
186# Even with those we still run into problems.
187# I tried to do things by the book but could not convince
188# Mercurial to let me check in a change with UTF-8 in the
189# CL description or author field, no matter how many conversions
190# between str and unicode I inserted and despite changing the
191# default encoding. I'm tired of this game, so set the default
192# encoding for all of Python to 'utf-8', not 'ascii'.
193def default_to_utf8():
194 import sys
195 stdout, __stdout__ = sys.stdout, sys.__stdout__
196 reload(sys) # site.py deleted setdefaultencoding; get it back
197 sys.stdout, sys.__stdout__ = stdout, __stdout__
198 sys.setdefaultencoding('utf-8')
199
200default_to_utf8()
201
202#######################################################################
203# Change list parsing.
204#
205# Change lists are stored in .hg/codereview/cl.nnnnnn
206# where nnnnnn is the number assigned by the code review server.
207# Most data about a change list is stored on the code review server
208# too: the description, reviewer, and cc list are all stored there.
209# The only thing in the cl.nnnnnn file is the list of relevant files.
210# Also, the existence of the cl.nnnnnn file marks this repository
211# as the one where the change list lives.
212
213emptydiff = """Index: ~rietveld~placeholder~
214===================================================================
215diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
216new file mode 100644
217"""
218
219class CL(object):
220 def __init__(self, name):
221 typecheck(name, str)
222 self.name = name
223 self.desc = ''
224 self.files = []
225 self.reviewer = []
226 self.cc = []
227 self.url = ''
228 self.local = False
229 self.web = False
230 self.copied_from = None # None means current user
231 self.mailed = False
232 self.private = False
233 self.lgtm = []
234
235 def DiskText(self):
236 cl = self
237 s = ""
238 if cl.copied_from:
239 s += "Author: " + cl.copied_from + "\n\n"
240 if cl.private:
241 s += "Private: " + str(self.private) + "\n"
242 s += "Mailed: " + str(self.mailed) + "\n"
243 s += "Description:\n"
244 s += Indent(cl.desc, "\t")
245 s += "Files:\n"
246 for f in cl.files:
247 s += "\t" + f + "\n"
248 typecheck(s, str)
249 return s
250
251 def EditorText(self):
252 cl = self
253 s = _change_prolog
254 s += "\n"
255 if cl.copied_from:
256 s += "Author: " + cl.copied_from + "\n"
257 if cl.url != '':
258 s += 'URL: ' + cl.url + ' # cannot edit\n\n'
259 if cl.private:
260 s += "Private: True\n"
261 s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
262 s += "CC: " + JoinComma(cl.cc) + "\n"
263 s += "\n"
264 s += "Description:\n"
265 if cl.desc == '':
266 s += "\t<enter description here>\n"
267 else:
268 s += Indent(cl.desc, "\t")
269 s += "\n"
270 if cl.local or cl.name == "new":
271 s += "Files:\n"
272 for f in cl.files:
273 s += "\t" + f + "\n"
274 s += "\n"
275 typecheck(s, str)
276 return s
277
278 def PendingText(self):
279 cl = self
280 s = cl.name + ":" + "\n"
281 s += Indent(cl.desc, "\t")
282 s += "\n"
283 if cl.copied_from:
284 s += "\tAuthor: " + cl.copied_from + "\n"
285 s += "\tReviewer: " + JoinComma(cl.reviewer) + "\n"
286 for (who, line) in cl.lgtm:
287 s += "\t\t" + who + ": " + line + "\n"
288 s += "\tCC: " + JoinComma(cl.cc) + "\n"
289 s += "\tFiles:\n"
290 for f in cl.files:
291 s += "\t\t" + f + "\n"
292 typecheck(s, str)
293 return s
294
295 def Flush(self, ui, repo):
296 if self.name == "new":
297 self.Upload(ui, repo, gofmt_just_warn=True, creating=True)
298 dir = CodeReviewDir(ui, repo)
299 path = dir + '/cl.' + self.name
300 f = open(path+'!', "w")
301 f.write(self.DiskText())
302 f.close()
303 if sys.platform == "win32" and os.path.isfile(path):
304 os.remove(path)
305 os.rename(path+'!', path)
306 if self.web and not self.copied_from:
307 EditDesc(self.name, desc=self.desc,
308 reviewers=JoinComma(self.reviewer), cc=JoinComma(self.cc),
309 private=self.private)
310
311 def Delete(self, ui, repo):
312 dir = CodeReviewDir(ui, repo)
313 os.unlink(dir + "/cl." + self.name)
314
315 def Subject(self):
316 s = line1(self.desc)
317 if len(s) > 60:
318 s = s[0:55] + "..."
319 if self.name != "new":
320 s = "code review %s: %s" % (self.name, s)
321 typecheck(s, str)
322 return s
323
324 def Upload(self, ui, repo, send_mail=False, gofmt=True, gofmt_just_warn=False, creating=False, quiet=False):
325 if not self.files and not creating:
326 ui.warn("no files in change list\n")
327 if ui.configbool("codereview", "force_gofmt", True) and gofmt:
328 CheckFormat(ui, repo, self.files, just_warn=gofmt_just_warn)
329 set_status("uploading CL metadata + diffs")
330 os.chdir(repo.root)
331 form_fields = [
332 ("content_upload", "1"),
333 ("reviewers", JoinComma(self.reviewer)),
334 ("cc", JoinComma(self.cc)),
335 ("description", self.desc),
336 ("base_hashes", ""),
337 ]
338
339 if self.name != "new":
340 form_fields.append(("issue", self.name))
341 vcs = None
342 # We do not include files when creating the issue,
343 # because we want the patch sets to record the repository
344 # and base revision they are diffs against. We use the patch
345 # set message for that purpose, but there is no message with
346 # the first patch set. Instead the message gets used as the
347 # new CL's overall subject. So omit the diffs when creating
348 # and then we'll run an immediate upload.
349 # This has the effect that every CL begins with an empty "Patch set 1".
350 if self.files and not creating:
351 vcs = MercurialVCS(upload_options, ui, repo)
352 data = vcs.GenerateDiff(self.files)
353 files = vcs.GetBaseFiles(data)
354 if len(data) > MAX_UPLOAD_SIZE:
355 uploaded_diff_file = []
356 form_fields.append(("separate_patches", "1"))
357 else:
358 uploaded_diff_file = [("data", "data.diff", data)]
359 else:
360 uploaded_diff_file = [("data", "data.diff", emptydiff)]
361
362 if vcs and self.name != "new":
363 form_fields.append(("subject", "diff -r " + vcs.base_rev + " " + getremote(ui, repo, {}).path))
364 else:
365 # First upload sets the subject for the CL itself.
366 form_fields.append(("subject", self.Subject()))
367 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
368 response_body = MySend("/upload", body, content_type=ctype)
369 patchset = None
370 msg = response_body
371 lines = msg.splitlines()
372 if len(lines) >= 2:
373 msg = lines[0]
374 patchset = lines[1].strip()
375 patches = [x.split(" ", 1) for x in lines[2:]]
376 if response_body.startswith("Issue updated.") and quiet:
377 pass
378 else:
379 ui.status(msg + "\n")
380 set_status("uploaded CL metadata + diffs")
381 if not response_body.startswith("Issue created.") and not response_body.startswith("Issue updated."):
382 raise util.Abort("failed to update issue: " + response_body)
383 issue = msg[msg.rfind("/")+1:]
384 self.name = issue
385 if not self.url:
386 self.url = server_url_base + self.name
387 if not uploaded_diff_file:
388 set_status("uploading patches")
389 patches = UploadSeparatePatches(issue, rpc, patchset, data, upload_options)
390 if vcs:
391 set_status("uploading base files")
392 vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
393 if send_mail:
394 set_status("sending mail")
395 MySend("/" + issue + "/mail", payload="")
396 self.web = True
397 set_status("flushing changes to disk")
398 self.Flush(ui, repo)
399 return
400
401 def Mail(self, ui, repo):
402 pmsg = "Hello " + JoinComma(self.reviewer)
403 if self.cc:
404 pmsg += " (cc: %s)" % (', '.join(self.cc),)
405 pmsg += ",\n"
406 pmsg += "\n"
407 repourl = getremote(ui, repo, {}).path
408 if not self.mailed:
409 pmsg += "I'd like you to review this change to\n" + repourl + "\n"
410 else:
411 pmsg += "Please take another look.\n"
412 typecheck(pmsg, str)
413 PostMessage(ui, self.name, pmsg, subject=self.Subject())
414 self.mailed = True
415 self.Flush(ui, repo)
416
417def GoodCLName(name):
418 typecheck(name, str)
419 return re.match("^[0-9]+$", name)
420
421def ParseCL(text, name):
422 typecheck(text, str)
423 typecheck(name, str)
424 sname = None
425 lineno = 0
426 sections = {
427 'Author': '',
428 'Description': '',
429 'Files': '',
430 'URL': '',
431 'Reviewer': '',
432 'CC': '',
433 'Mailed': '',
434 'Private': '',
435 }
436 for line in text.split('\n'):
437 lineno += 1
438 line = line.rstrip()
439 if line != '' and line[0] == '#':
440 continue
441 if line == '' or line[0] == ' ' or line[0] == '\t':
442 if sname == None and line != '':
443 return None, lineno, 'text outside section'
444 if sname != None:
445 sections[sname] += line + '\n'
446 continue
447 p = line.find(':')
448 if p >= 0:
449 s, val = line[:p].strip(), line[p+1:].strip()
450 if s in sections:
451 sname = s
452 if val != '':
453 sections[sname] += val + '\n'
454 continue
455 return None, lineno, 'malformed section header'
456
457 for k in sections:
458 sections[k] = StripCommon(sections[k]).rstrip()
459
460 cl = CL(name)
461 if sections['Author']:
462 cl.copied_from = sections['Author']
463 cl.desc = sections['Description']
464 for line in sections['Files'].split('\n'):
465 i = line.find('#')
466 if i >= 0:
467 line = line[0:i].rstrip()
468 line = line.strip()
469 if line == '':
470 continue
471 cl.files.append(line)
472 cl.reviewer = SplitCommaSpace(sections['Reviewer'])
473 cl.cc = SplitCommaSpace(sections['CC'])
474 cl.url = sections['URL']
475 if sections['Mailed'] != 'False':
476 # Odd default, but avoids spurious mailings when
477 # reading old CLs that do not have a Mailed: line.
478 # CLs created with this update will always have
479 # Mailed: False on disk.
480 cl.mailed = True
481 if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
482 cl.private = True
483 if cl.desc == '<enter description here>':
484 cl.desc = ''
485 return cl, 0, ''
486
487def SplitCommaSpace(s):
488 typecheck(s, str)
489 s = s.strip()
490 if s == "":
491 return []
492 return re.split(", *", s)
493
494def CutDomain(s):
495 typecheck(s, str)
496 i = s.find('@')
497 if i >= 0:
498 s = s[0:i]
499 return s
500
501def JoinComma(l):
502 for s in l:
503 typecheck(s, str)
504 return ", ".join(l)
505
506def ExceptionDetail():
507 s = str(sys.exc_info()[0])
508 if s.startswith("<type '") and s.endswith("'>"):
509 s = s[7:-2]
510 elif s.startswith("<class '") and s.endswith("'>"):
511 s = s[8:-2]
512 arg = str(sys.exc_info()[1])
513 if len(arg) > 0:
514 s += ": " + arg
515 return s
516
517def IsLocalCL(ui, repo, name):
518 return GoodCLName(name) and os.access(CodeReviewDir(ui, repo) + "/cl." + name, 0)
519
520# Load CL from disk and/or the web.
521def LoadCL(ui, repo, name, web=True):
522 typecheck(name, str)
523 set_status("loading CL " + name)
524 if not GoodCLName(name):
525 return None, "invalid CL name"
526 dir = CodeReviewDir(ui, repo)
527 path = dir + "cl." + name
528 if os.access(path, 0):
529 ff = open(path)
530 text = ff.read()
531 ff.close()
532 cl, lineno, err = ParseCL(text, name)
533 if err != "":
534 return None, "malformed CL data: "+err
535 cl.local = True
536 else:
537 cl = CL(name)
538 if web:
539 set_status("getting issue metadata from web")
540 d = JSONGet(ui, "/api/" + name + "?messages=true")
541 set_status(None)
542 if d is None:
543 return None, "cannot load CL %s from server" % (name,)
544 if 'owner_email' not in d or 'issue' not in d or str(d['issue']) != name:
545 return None, "malformed response loading CL data from code review server"
546 cl.dict = d
547 cl.reviewer = d.get('reviewers', [])
548 cl.cc = d.get('cc', [])
549 if cl.local and cl.copied_from and cl.desc:
550 # local copy of CL written by someone else
551 # and we saved a description. use that one,
552 # so that committers can edit the description
553 # before doing hg submit.
554 pass
555 else:
556 cl.desc = d.get('description', "")
557 cl.url = server_url_base + name
558 cl.web = True
559 cl.private = d.get('private', False) != False
560 cl.lgtm = []
561 for m in d.get('messages', []):
562 if m.get('approval', False) == True:
563 who = re.sub('@.*', '', m.get('sender', ''))
564 text = re.sub("\n(.|\n)*", '', m.get('text', ''))
565 cl.lgtm.append((who, text))
566
567 set_status("loaded CL " + name)
568 return cl, ''
569
570global_status = None
571
572def set_status(s):
573 # print >>sys.stderr, "\t", time.asctime(), s
574 global global_status
575 global_status = s
576
577class StatusThread(threading.Thread):
578 def __init__(self):
579 threading.Thread.__init__(self)
580 def run(self):
581 # pause a reasonable amount of time before
582 # starting to display status messages, so that
583 # most hg commands won't ever see them.
584 time.sleep(30)
585
586 # now show status every 15 seconds
587 while True:
588 time.sleep(15 - time.time() % 15)
589 s = global_status
590 if s is None:
591 continue
592 if s == "":
593 s = "(unknown status)"
594 print >>sys.stderr, time.asctime(), s
595
596def start_status_thread():
597 t = StatusThread()
598 t.setDaemon(True) # allowed to exit if t is still running
599 t.start()
600
601class LoadCLThread(threading.Thread):
602 def __init__(self, ui, repo, dir, f, web):
603 threading.Thread.__init__(self)
604 self.ui = ui
605 self.repo = repo
606 self.dir = dir
607 self.f = f
608 self.web = web
609 self.cl = None
610 def run(self):
611 cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
612 if err != '':
613 self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
614 return
615 self.cl = cl
616
617# Load all the CLs from this repository.
618def LoadAllCL(ui, repo, web=True):
619 dir = CodeReviewDir(ui, repo)
620 m = {}
621 files = [f for f in os.listdir(dir) if f.startswith('cl.')]
622 if not files:
623 return m
624 active = []
625 first = True
626 for f in files:
627 t = LoadCLThread(ui, repo, dir, f, web)
628 t.start()
629 if web and first:
630 # first request: wait in case it needs to authenticate
631 # otherwise we get lots of user/password prompts
632 # running in parallel.
633 t.join()
634 if t.cl:
635 m[t.cl.name] = t.cl
636 first = False
637 else:
638 active.append(t)
639 for t in active:
640 t.join()
641 if t.cl:
642 m[t.cl.name] = t.cl
643 return m
644
645# Find repository root. On error, ui.warn and return None
646def RepoDir(ui, repo):
647 url = repo.url();
648 if not url.startswith('file:'):
649 ui.warn("repository %s is not in local file system\n" % (url,))
650 return None
651 url = url[5:]
652 if url.endswith('/'):
653 url = url[:-1]
654 typecheck(url, str)
655 return url
656
657# Find (or make) code review directory. On error, ui.warn and return None
658def CodeReviewDir(ui, repo):
659 dir = RepoDir(ui, repo)
660 if dir == None:
661 return None
662 dir += '/.hg/codereview/'
663 if not os.path.isdir(dir):
664 try:
665 os.mkdir(dir, 0700)
666 except:
667 ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
668 return None
669 typecheck(dir, str)
670 return dir
671
672# Turn leading tabs into spaces, so that the common white space
673# prefix doesn't get confused when people's editors write out
674# some lines with spaces, some with tabs. Only a heuristic
675# (some editors don't use 8 spaces either) but a useful one.
676def TabsToSpaces(line):
677 i = 0
678 while i < len(line) and line[i] == '\t':
679 i += 1
680 return ' '*(8*i) + line[i:]
681
682# Strip maximal common leading white space prefix from text
683def StripCommon(text):
684 typecheck(text, str)
685 ws = None
686 for line in text.split('\n'):
687 line = line.rstrip()
688 if line == '':
689 continue
690 line = TabsToSpaces(line)
691 white = line[:len(line)-len(line.lstrip())]
692 if ws == None:
693 ws = white
694 else:
695 common = ''
696 for i in range(min(len(white), len(ws))+1):
697 if white[0:i] == ws[0:i]:
698 common = white[0:i]
699 ws = common
700 if ws == '':
701 break
702 if ws == None:
703 return text
704 t = ''
705 for line in text.split('\n'):
706 line = line.rstrip()
707 line = TabsToSpaces(line)
708 if line.startswith(ws):
709 line = line[len(ws):]
710 if line == '' and t == '':
711 continue
712 t += line + '\n'
713 while len(t) >= 2 and t[-2:] == '\n\n':
714 t = t[:-1]
715 typecheck(t, str)
716 return t
717
718# Indent text with indent.
719def Indent(text, indent):
720 typecheck(text, str)
721 typecheck(indent, str)
722 t = ''
723 for line in text.split('\n'):
724 t += indent + line + '\n'
725 typecheck(t, str)
726 return t
727
728# Return the first line of l
729def line1(text):
730 typecheck(text, str)
731 return text.split('\n')[0]
732
733_change_prolog = """# Change list.
734# Lines beginning with # are ignored.
735# Multi-line values should be indented.
736"""
737
738#######################################################################
739# Mercurial helper functions
740
741# Get effective change nodes taking into account applied MQ patches
742def effective_revpair(repo):
743 try:
744 return scmutil.revpair(repo, ['qparent'])
745 except:
746 return scmutil.revpair(repo, None)
747
748# Return list of changed files in repository that match pats.
749# Warn about patterns that did not match.
750def matchpats(ui, repo, pats, opts):
751 matcher = scmutil.match(repo, pats, opts)
752 node1, node2 = effective_revpair(repo)
753 modified, added, removed, deleted, unknown, ignored, clean = repo.status(node1, node2, matcher, ignored=True, clean=True, unknown=True)
754 return (modified, added, removed, deleted, unknown, ignored, clean)
755
756# Return list of changed files in repository that match pats.
757# The patterns came from the command line, so we warn
758# if they have no effect or cannot be understood.
759def ChangedFiles(ui, repo, pats, opts, taken=None):
760 taken = taken or {}
761 # Run each pattern separately so that we can warn about
762 # patterns that didn't do anything useful.
763 for p in pats:
764 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, [p], opts)
765 redo = False
766 for f in unknown:
767 promptadd(ui, repo, f)
768 redo = True
769 for f in deleted:
770 promptremove(ui, repo, f)
771 redo = True
772 if redo:
773 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, [p], opts)
774 for f in modified + added + removed:
775 if f in taken:
776 ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
777 if not modified and not added and not removed:
778 ui.warn("warning: %s did not match any modified files\n" % (p,))
779
780 # Again, all at once (eliminates duplicates)
781 modified, added, removed = matchpats(ui, repo, pats, opts)[:3]
782 l = modified + added + removed
783 l.sort()
784 if taken:
785 l = Sub(l, taken.keys())
786 return l
787
788# Return list of changed files in repository that match pats and still exist.
789def ChangedExistingFiles(ui, repo, pats, opts):
790 modified, added = matchpats(ui, repo, pats, opts)[:2]
791 l = modified + added
792 l.sort()
793 return l
794
795# Return list of files claimed by existing CLs
796def Taken(ui, repo):
797 all = LoadAllCL(ui, repo, web=False)
798 taken = {}
799 for _, cl in all.items():
800 for f in cl.files:
801 taken[f] = cl
802 return taken
803
804# Return list of changed files that are not claimed by other CLs
805def DefaultFiles(ui, repo, pats, opts):
806 return ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
807
808def Sub(l1, l2):
809 return [l for l in l1 if l not in l2]
810
811def Add(l1, l2):
812 l = l1 + Sub(l2, l1)
813 l.sort()
814 return l
815
816def Intersect(l1, l2):
817 return [l for l in l1 if l in l2]
818
819def getremote(ui, repo, opts):
820 # save $http_proxy; creating the HTTP repo object will
821 # delete it in an attempt to "help"
822 proxy = os.environ.get('http_proxy')
823 source = hg.parseurl(ui.expandpath("default"), None)[0]
824 try:
825 remoteui = hg.remoteui # hg 1.6
826 except:
827 remoteui = cmdutil.remoteui
828 other = hg.repository(remoteui(repo, opts), source)
829 if proxy is not None:
830 os.environ['http_proxy'] = proxy
831 return other
832
833desc_re = '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
834
835desc_msg = '''Your CL description appears not to use the standard form.
836
837The first line of your change description is conventionally a
838one-line summary of the change, prefixed by the primary affected package,
839and is used as the subject for code review mail; the rest of the description
840elaborates.
841
842Examples:
843
844 encoding/rot13: new package
845
846 math: add IsInf, IsNaN
847
848 net: fix cname in LookupHost
849
850 unicode: update to Unicode 5.0.2
851
852'''
853
854
855def promptremove(ui, repo, f):
856 if promptyesno(ui, "hg remove %s (y/n)?" % (f,)):
857 if commands.remove(ui, repo, 'path:'+f) != 0:
858 ui.warn("error removing %s" % (f,))
859
860def promptadd(ui, repo, f):
861 if promptyesno(ui, "hg add %s (y/n)?" % (f,)):
862 if commands.add(ui, repo, 'path:'+f) != 0:
863 ui.warn("error adding %s" % (f,))
864
865def EditCL(ui, repo, cl):
866 set_status(None) # do not show status
867 s = cl.EditorText()
868 while True:
869 s = ui.edit(s, ui.username())
870
871 # We can't trust Mercurial + Python not to die before making the change,
872 # so, by popular demand, just scribble the most recent CL edit into
873 # $(hg root)/last-change so that if Mercurial does die, people
874 # can look there for their work.
875 try:
876 f = open(repo.root+"/last-change", "w")
877 f.write(s)
878 f.close()
879 except:
880 pass
881
882 clx, line, err = ParseCL(s, cl.name)
883 if err != '':
884 if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
885 return "change list not modified"
886 continue
887
888 # Check description.
889 if clx.desc == '':
890 if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
891 continue
892 elif re.search('<enter reason for undo>', clx.desc):
893 if promptyesno(ui, "change list description omits reason for undo\nre-edit (y/n)?"):
894 continue
895 elif not re.match(desc_re, clx.desc.split('\n')[0]):
896 if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
897 continue
898
899 # Check file list for files that need to be hg added or hg removed
900 # or simply aren't understood.
901 pats = ['path:'+f for f in clx.files]
902 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, pats, {})
903 files = []
904 for f in clx.files:
905 if f in modified or f in added or f in removed:
906 files.append(f)
907 continue
908 if f in deleted:
909 promptremove(ui, repo, f)
910 files.append(f)
911 continue
912 if f in unknown:
913 promptadd(ui, repo, f)
914 files.append(f)
915 continue
916 if f in ignored:
917 ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
918 continue
919 if f in clean:
920 ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
921 files.append(f)
922 continue
923 p = repo.root + '/' + f
924 if os.path.isfile(p):
925 ui.warn("warning: %s is a file but not known to hg\n" % (f,))
926 files.append(f)
927 continue
928 if os.path.isdir(p):
929 ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
930 continue
931 ui.warn("error: %s does not exist; omitting\n" % (f,))
932 clx.files = files
933
934 cl.desc = clx.desc
935 cl.reviewer = clx.reviewer
936 cl.cc = clx.cc
937 cl.files = clx.files
938 cl.private = clx.private
939 break
940 return ""
941
942# For use by submit, etc. (NOT by change)
943# Get change list number or list of files from command line.
944# If files are given, make a new change list.
945def CommandLineCL(ui, repo, pats, opts, defaultcc=None):
946 if len(pats) > 0 and GoodCLName(pats[0]):
947 if len(pats) != 1:
948 return None, "cannot specify change number and file names"
949 if opts.get('message'):
950 return None, "cannot use -m with existing CL"
951 cl, err = LoadCL(ui, repo, pats[0], web=True)
952 if err != "":
953 return None, err
954 else:
955 cl = CL("new")
956 cl.local = True
957 cl.files = ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
958 if not cl.files:
959 return None, "no files changed"
960 if opts.get('reviewer'):
961 cl.reviewer = Add(cl.reviewer, SplitCommaSpace(opts.get('reviewer')))
962 if opts.get('cc'):
963 cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
964 if defaultcc:
965 cl.cc = Add(cl.cc, defaultcc)
966 if cl.name == "new":
967 if opts.get('message'):
968 cl.desc = opts.get('message')
969 else:
970 err = EditCL(ui, repo, cl)
971 if err != '':
972 return None, err
973 return cl, ""
974
975# reposetup replaces cmdutil.match with this wrapper,
976# which expands the syntax @clnumber to mean the files
977# in that CL.
978original_match = None
979global_repo = None
980global_ui = None
981def ReplacementForCmdutilMatch(ctx, pats=None, opts=None, globbed=False, default='relpath'):
982 taken = []
983 files = []
984 pats = pats or []
985 opts = opts or {}
986
987 for p in pats:
988 if p.startswith('@'):
989 taken.append(p)
990 clname = p[1:]
991 if not GoodCLName(clname):
992 raise util.Abort("invalid CL name " + clname)
993 cl, err = LoadCL(global_repo.ui, global_repo, clname, web=False)
994 if err != '':
995 raise util.Abort("loading CL " + clname + ": " + err)
996 if not cl.files:
997 raise util.Abort("no files in CL " + clname)
998 files = Add(files, cl.files)
999 pats = Sub(pats, taken) + ['path:'+f for f in files]
1000
1001 # work-around for http://selenic.com/hg/rev/785bbc8634f8
1002 if hgversion >= '1.9' and not hasattr(ctx, 'match'):
1003 ctx = ctx[None]
1004 return original_match(ctx, pats=pats, opts=opts, globbed=globbed, default=default)
1005
1006def RelativePath(path, cwd):
1007 n = len(cwd)
1008 if path.startswith(cwd) and path[n] == '/':
1009 return path[n+1:]
1010 return path
1011
1012def CheckFormat(ui, repo, files, just_warn=False):
1013 set_status("running gofmt")
1014 CheckGofmt(ui, repo, files, just_warn)
1015 CheckTabfmt(ui, repo, files, just_warn)
1016
1017# Check that gofmt run on the list of files does not change them
1018def CheckGofmt(ui, repo, files, just_warn):
1019 files = [f for f in files if (f.startswith('src/') or f.startswith('test/bench/')) and f.endswith('.go')]
1020 if not files:
1021 return
1022 cwd = os.getcwd()
1023 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1024 files = [f for f in files if os.access(f, 0)]
1025 if not files:
1026 return
1027 try:
1028 cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
1029 cmd.stdin.close()
1030 except:
1031 raise util.Abort("gofmt: " + ExceptionDetail())
1032 data = cmd.stdout.read()
1033 errors = cmd.stderr.read()
1034 cmd.wait()
1035 set_status("done with gofmt")
1036 if len(errors) > 0:
1037 ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
1038 return
1039 if len(data) > 0:
1040 msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
1041 if just_warn:
1042 ui.warn("warning: " + msg + "\n")
1043 else:
1044 raise util.Abort(msg)
1045 return
1046
1047# Check that *.[chys] files indent using tabs.
1048def CheckTabfmt(ui, repo, files, just_warn):
1049 files = [f for f in files if f.startswith('src/') and re.search(r"\.[chys]$", f)]
1050 if not files:
1051 return
1052 cwd = os.getcwd()
1053 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1054 files = [f for f in files if os.access(f, 0)]
1055 badfiles = []
1056 for f in files:
1057 try:
1058 for line in open(f, 'r'):
1059 # Four leading spaces is enough to complain about,
1060 # except that some Plan 9 code uses four spaces as the label indent,
1061 # so allow that.
1062 if line.startswith(' ') and not re.match(' [A-Za-z0-9_]+:', line):
1063 badfiles.append(f)
1064 break
1065 except:
1066 # ignore cannot open file, etc.
1067 pass
1068 if len(badfiles) > 0:
1069 msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
1070 if just_warn:
1071 ui.warn("warning: " + msg + "\n")
1072 else:
1073 raise util.Abort(msg)
1074 return
1075
1076#######################################################################
1077# Mercurial commands
1078
1079# every command must take a ui and and repo as arguments.
1080# opts is a dict where you can find other command line flags
1081#
1082# Other parameters are taken in order from items on the command line that
1083# don't start with a dash. If no default value is given in the parameter list,
1084# they are required.
1085#
1086
1087def change(ui, repo, *pats, **opts):
1088 """create, edit or delete a change list
1089
1090 Create, edit or delete a change list.
1091 A change list is a group of files to be reviewed and submitted together,
1092 plus a textual description of the change.
1093 Change lists are referred to by simple alphanumeric names.
1094
1095 Changes must be reviewed before they can be submitted.
1096
1097 In the absence of options, the change command opens the
1098 change list for editing in the default editor.
1099
1100 Deleting a change with the -d or -D flag does not affect
1101 the contents of the files listed in that change. To revert
1102 the files listed in a change, use
1103
1104 hg revert @123456
1105
1106 before running hg change -d 123456.
1107 """
1108
1109 if missing_codereview:
1110 return missing_codereview
1111
1112 dirty = {}
1113 if len(pats) > 0 and GoodCLName(pats[0]):
1114 name = pats[0]
1115 if len(pats) != 1:
1116 return "cannot specify CL name and file patterns"
1117 pats = pats[1:]
1118 cl, err = LoadCL(ui, repo, name, web=True)
1119 if err != '':
1120 return err
1121 if not cl.local and (opts["stdin"] or not opts["stdout"]):
1122 return "cannot change non-local CL " + name
1123 else:
1124 if repo[None].branch() != "default":
1125 return "cannot run hg change outside default branch"
1126 name = "new"
1127 cl = CL("new")
1128 dirty[cl] = True
1129 files = ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
1130
1131 if opts["delete"] or opts["deletelocal"]:
1132 if opts["delete"] and opts["deletelocal"]:
1133 return "cannot use -d and -D together"
1134 flag = "-d"
1135 if opts["deletelocal"]:
1136 flag = "-D"
1137 if name == "new":
1138 return "cannot use "+flag+" with file patterns"
1139 if opts["stdin"] or opts["stdout"]:
1140 return "cannot use "+flag+" with -i or -o"
1141 if not cl.local:
1142 return "cannot change non-local CL " + name
1143 if opts["delete"]:
1144 if cl.copied_from:
1145 return "original author must delete CL; hg change -D will remove locally"
1146 PostMessage(ui, cl.name, "*** Abandoned ***", send_mail=cl.mailed)
1147 EditDesc(cl.name, closed=True, private=cl.private)
1148 cl.Delete(ui, repo)
1149 return
1150
1151 if opts["stdin"]:
1152 s = sys.stdin.read()
1153 clx, line, err = ParseCL(s, name)
1154 if err != '':
1155 return "error parsing change list: line %d: %s" % (line, err)
1156 if clx.desc is not None:
1157 cl.desc = clx.desc;
1158 dirty[cl] = True
1159 if clx.reviewer is not None:
1160 cl.reviewer = clx.reviewer
1161 dirty[cl] = True
1162 if clx.cc is not None:
1163 cl.cc = clx.cc
1164 dirty[cl] = True
1165 if clx.files is not None:
1166 cl.files = clx.files
1167 dirty[cl] = True
1168 if clx.private != cl.private:
1169 cl.private = clx.private
1170 dirty[cl] = True
1171
1172 if not opts["stdin"] and not opts["stdout"]:
1173 if name == "new":
1174 cl.files = files
1175 err = EditCL(ui, repo, cl)
1176 if err != "":
1177 return err
1178 dirty[cl] = True
1179
1180 for d, _ in dirty.items():
1181 name = d.name
1182 d.Flush(ui, repo)
1183 if name == "new":
1184 d.Upload(ui, repo, quiet=True)
1185
1186 if opts["stdout"]:
1187 ui.write(cl.EditorText())
1188 elif opts["pending"]:
1189 ui.write(cl.PendingText())
1190 elif name == "new":
1191 if ui.quiet:
1192 ui.write(cl.name)
1193 else:
1194 ui.write("CL created: " + cl.url + "\n")
1195 return
1196
1197def code_login(ui, repo, **opts):
1198 """log in to code review server
1199
1200 Logs in to the code review server, saving a cookie in
1201 a file in your home directory.
1202 """
1203 if missing_codereview:
1204 return missing_codereview
1205
1206 MySend(None)
1207
1208def clpatch(ui, repo, clname, **opts):
1209 """import a patch from the code review server
1210
1211 Imports a patch from the code review server into the local client.
1212 If the local client has already modified any of the files that the
1213 patch modifies, this command will refuse to apply the patch.
1214
1215 Submitting an imported patch will keep the original author's
1216 name as the Author: line but add your own name to a Committer: line.
1217 """
1218 if repo[None].branch() != "default":
1219 return "cannot run hg clpatch outside default branch"
1220 return clpatch_or_undo(ui, repo, clname, opts, mode="clpatch")
1221
1222def undo(ui, repo, clname, **opts):
1223 """undo the effect of a CL
1224
1225 Creates a new CL that undoes an earlier CL.
1226 After creating the CL, opens the CL text for editing so that
1227 you can add the reason for the undo to the description.
1228 """
1229 if repo[None].branch() != "default":
1230 return "cannot run hg undo outside default branch"
1231 return clpatch_or_undo(ui, repo, clname, opts, mode="undo")
1232
1233def release_apply(ui, repo, clname, **opts):
1234 """apply a CL to the release branch
1235
1236 Creates a new CL copying a previously committed change
1237 from the main branch to the release branch.
1238 The current client must either be clean or already be in
1239 the release branch.
1240
1241 The release branch must be created by starting with a
1242 clean client, disabling the code review plugin, and running:
1243
1244 hg update weekly.YYYY-MM-DD
1245 hg branch release-branch.rNN
1246 hg commit -m 'create release-branch.rNN'
1247 hg push --new-branch
1248
1249 Then re-enable the code review plugin.
1250
1251 People can test the release branch by running
1252
1253 hg update release-branch.rNN
1254
1255 in a clean client. To return to the normal tree,
1256
1257 hg update default
1258
1259 Move changes since the weekly into the release branch
1260 using hg release-apply followed by the usual code review
1261 process and hg submit.
1262
1263 When it comes time to tag the release, record the
1264 final long-form tag of the release-branch.rNN
1265 in the *default* branch's .hgtags file. That is, run
1266
1267 hg update default
1268
1269 and then edit .hgtags as you would for a weekly.
1270
1271 """
1272 c = repo[None]
1273 if not releaseBranch:
1274 return "no active release branches"
1275 if c.branch() != releaseBranch:
1276 if c.modified() or c.added() or c.removed():
1277 raise util.Abort("uncommitted local changes - cannot switch branches")
1278 err = hg.clean(repo, releaseBranch)
1279 if err:
1280 return err
1281 try:
1282 err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1283 if err:
1284 raise util.Abort(err)
1285 except Exception, e:
1286 hg.clean(repo, "default")
1287 raise e
1288 return None
1289
1290def rev2clname(rev):
1291 # Extract CL name from revision description.
1292 # The last line in the description that is a codereview URL is the real one.
1293 # Earlier lines might be part of the user-written description.
1294 all = re.findall('(?m)^http://codereview.appspot.com/([0-9]+)$', rev.description())
1295 if len(all) > 0:
1296 return all[-1]
1297 return ""
1298
1299undoHeader = """undo CL %s / %s
1300
1301<enter reason for undo>
1302
1303««« original CL description
1304"""
1305
1306undoFooter = """
1307»»»
1308"""
1309
1310backportHeader = """[%s] %s
1311
1312««« CL %s / %s
1313"""
1314
1315backportFooter = """
1316»»»
1317"""
1318
1319# Implementation of clpatch/undo.
1320def clpatch_or_undo(ui, repo, clname, opts, mode):
1321 if missing_codereview:
1322 return missing_codereview
1323
1324 if mode == "undo" or mode == "backport":
1325 if hgversion < '1.4':
1326 # Don't have cmdutil.match (see implementation of sync command).
1327 return "hg is too old to run hg %s - update to 1.4 or newer" % mode
1328
1329 # Find revision in Mercurial repository.
1330 # Assume CL number is 7+ decimal digits.
1331 # Otherwise is either change log sequence number (fewer decimal digits),
1332 # hexadecimal hash, or tag name.
1333 # Mercurial will fall over long before the change log
1334 # sequence numbers get to be 7 digits long.
1335 if re.match('^[0-9]{7,}$', clname):
1336 found = False
1337 matchfn = scmutil.match(repo, [], {'rev': None})
1338 def prep(ctx, fns):
1339 pass
1340 for ctx in cmdutil.walkchangerevs(repo, matchfn, {'rev': None}, prep):
1341 rev = repo[ctx.rev()]
1342 # Last line with a code review URL is the actual review URL.
1343 # Earlier ones might be part of the CL description.
1344 n = rev2clname(rev)
1345 if n == clname:
1346 found = True
1347 break
1348 if not found:
1349 return "cannot find CL %s in local repository" % clname
1350 else:
1351 rev = repo[clname]
1352 if not rev:
1353 return "unknown revision %s" % clname
1354 clname = rev2clname(rev)
1355 if clname == "":
1356 return "cannot find CL name in revision description"
1357
1358 # Create fresh CL and start with patch that would reverse the change.
1359 vers = short(rev.node())
1360 cl = CL("new")
1361 desc = str(rev.description())
1362 if mode == "undo":
1363 cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1364 else:
1365 cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1366 v1 = vers
1367 v0 = short(rev.parents()[0].node())
1368 if mode == "undo":
1369 arg = v1 + ":" + v0
1370 else:
1371 vers = v0
1372 arg = v0 + ":" + v1
1373 patch = RunShell(["hg", "diff", "--git", "-r", arg])
1374
1375 else: # clpatch
1376 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1377 if err != "":
1378 return err
1379 if patch == emptydiff:
1380 return "codereview issue %s has no diff" % clname
1381
1382 # find current hg version (hg identify)
1383 ctx = repo[None]
1384 parents = ctx.parents()
1385 id = '+'.join([short(p.node()) for p in parents])
1386
1387 # if version does not match the patch version,
1388 # try to update the patch line numbers.
1389 if vers != "" and id != vers:
1390 # "vers in repo" gives the wrong answer
1391 # on some versions of Mercurial. Instead, do the actual
1392 # lookup and catch the exception.
1393 try:
1394 repo[vers].description()
1395 except:
1396 return "local repository is out of date; sync to get %s" % (vers)
1397 patch1, err = portPatch(repo, patch, vers, id)
1398 if err != "":
1399 if not opts["ignore_hgpatch_failure"]:
1400 return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1401 else:
1402 patch = patch1
1403 argv = ["hgpatch"]
1404 if opts["no_incoming"] or mode == "backport":
1405 argv += ["--checksync=false"]
1406 try:
1407 cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1408 except:
1409 return "hgpatch: " + ExceptionDetail()
1410
1411 out, err = cmd.communicate(patch)
1412 if cmd.returncode != 0 and not opts["ignore_hgpatch_failure"]:
1413 return "hgpatch failed"
1414 cl.local = True
1415 cl.files = out.strip().split()
1416 if not cl.files and not opts["ignore_hgpatch_failure"]:
1417 return "codereview issue %s has no changed files" % clname
1418 files = ChangedFiles(ui, repo, [], opts)
1419 extra = Sub(cl.files, files)
1420 if extra:
1421 ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1422 cl.Flush(ui, repo)
1423 if mode == "undo":
1424 err = EditCL(ui, repo, cl)
1425 if err != "":
1426 return "CL created, but error editing: " + err
1427 cl.Flush(ui, repo)
1428 else:
1429 ui.write(cl.PendingText() + "\n")
1430
1431# portPatch rewrites patch from being a patch against
1432# oldver to being a patch against newver.
1433def portPatch(repo, patch, oldver, newver):
1434 lines = patch.splitlines(True) # True = keep \n
1435 delta = None
1436 for i in range(len(lines)):
1437 line = lines[i]
1438 if line.startswith('--- a/'):
1439 file = line[6:-1]
1440 delta = fileDeltas(repo, file, oldver, newver)
1441 if not delta or not line.startswith('@@ '):
1442 continue
1443 # @@ -x,y +z,w @@ means the patch chunk replaces
1444 # the original file's line numbers x up to x+y with the
1445 # line numbers z up to z+w in the new file.
1446 # Find the delta from x in the original to the same
1447 # line in the current version and add that delta to both
1448 # x and z.
1449 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1450 if not m:
1451 return None, "error parsing patch line numbers"
1452 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1453 d, err = lineDelta(delta, n1, len1)
1454 if err != "":
1455 return "", err
1456 n1 += d
1457 n2 += d
1458 lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1459
1460 newpatch = ''.join(lines)
1461 return newpatch, ""
1462
1463# fileDelta returns the line number deltas for the given file's
1464# changes from oldver to newver.
1465# The deltas are a list of (n, len, newdelta) triples that say
1466# lines [n, n+len) were modified, and after that range the
1467# line numbers are +newdelta from what they were before.
1468def fileDeltas(repo, file, oldver, newver):
1469 cmd = ["hg", "diff", "--git", "-r", oldver + ":" + newver, "path:" + file]
1470 data = RunShell(cmd, silent_ok=True)
1471 deltas = []
1472 for line in data.splitlines():
1473 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1474 if not m:
1475 continue
1476 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1477 deltas.append((n1, len1, n2+len2-(n1+len1)))
1478 return deltas
1479
1480# lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1481# It returns an error if those lines were rewritten by the patch.
1482def lineDelta(deltas, n, len):
1483 d = 0
1484 for (old, oldlen, newdelta) in deltas:
1485 if old >= n+len:
1486 break
1487 if old+len > n:
1488 return 0, "patch and recent changes conflict"
1489 d = newdelta
1490 return d, ""
1491
1492def download(ui, repo, clname, **opts):
1493 """download a change from the code review server
1494
1495 Download prints a description of the given change list
1496 followed by its diff, downloaded from the code review server.
1497 """
1498 if missing_codereview:
1499 return missing_codereview
1500
1501 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1502 if err != "":
1503 return err
1504 ui.write(cl.EditorText() + "\n")
1505 ui.write(patch + "\n")
1506 return
1507
1508def file(ui, repo, clname, pat, *pats, **opts):
1509 """assign files to or remove files from a change list
1510
1511 Assign files to or (with -d) remove files from a change list.
1512
1513 The -d option only removes files from the change list.
1514 It does not edit them or remove them from the repository.
1515 """
1516 if missing_codereview:
1517 return missing_codereview
1518
1519 pats = tuple([pat] + list(pats))
1520 if not GoodCLName(clname):
1521 return "invalid CL name " + clname
1522
1523 dirty = {}
1524 cl, err = LoadCL(ui, repo, clname, web=False)
1525 if err != '':
1526 return err
1527 if not cl.local:
1528 return "cannot change non-local CL " + clname
1529
1530 files = ChangedFiles(ui, repo, pats, opts)
1531
1532 if opts["delete"]:
1533 oldfiles = Intersect(files, cl.files)
1534 if oldfiles:
1535 if not ui.quiet:
1536 ui.status("# Removing files from CL. To undo:\n")
1537 ui.status("# cd %s\n" % (repo.root))
1538 for f in oldfiles:
1539 ui.status("# hg file %s %s\n" % (cl.name, f))
1540 cl.files = Sub(cl.files, oldfiles)
1541 cl.Flush(ui, repo)
1542 else:
1543 ui.status("no such files in CL")
1544 return
1545
1546 if not files:
1547 return "no such modified files"
1548
1549 files = Sub(files, cl.files)
1550 taken = Taken(ui, repo)
1551 warned = False
1552 for f in files:
1553 if f in taken:
1554 if not warned and not ui.quiet:
1555 ui.status("# Taking files from other CLs. To undo:\n")
1556 ui.status("# cd %s\n" % (repo.root))
1557 warned = True
1558 ocl = taken[f]
1559 if not ui.quiet:
1560 ui.status("# hg file %s %s\n" % (ocl.name, f))
1561 if ocl not in dirty:
1562 ocl.files = Sub(ocl.files, files)
1563 dirty[ocl] = True
1564 cl.files = Add(cl.files, files)
1565 dirty[cl] = True
1566 for d, _ in dirty.items():
1567 d.Flush(ui, repo)
1568 return
1569
1570def gofmt(ui, repo, *pats, **opts):
1571 """apply gofmt to modified files
1572
1573 Applies gofmt to the modified files in the repository that match
1574 the given patterns.
1575 """
1576 if missing_codereview:
1577 return missing_codereview
1578
1579 files = ChangedExistingFiles(ui, repo, pats, opts)
1580 files = [f for f in files if f.endswith(".go")]
1581 if not files:
1582 return "no modified go files"
1583 cwd = os.getcwd()
1584 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1585 try:
1586 cmd = ["gofmt", "-l"]
1587 if not opts["list"]:
1588 cmd += ["-w"]
1589 if os.spawnvp(os.P_WAIT, "gofmt", cmd + files) != 0:
1590 raise util.Abort("gofmt did not exit cleanly")
1591 except error.Abort, e:
1592 raise
1593 except:
1594 raise util.Abort("gofmt: " + ExceptionDetail())
1595 return
1596
1597def mail(ui, repo, *pats, **opts):
1598 """mail a change for review
1599
1600 Uploads a patch to the code review server and then sends mail
1601 to the reviewer and CC list asking for a review.
1602 """
1603 if missing_codereview:
1604 return missing_codereview
1605
1606 cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1607 if err != "":
1608 return err
1609 cl.Upload(ui, repo, gofmt_just_warn=True)
1610 if not cl.reviewer:
1611 # If no reviewer is listed, assign the review to defaultcc.
1612 # This makes sure that it appears in the
1613 # codereview.appspot.com/user/defaultcc
1614 # page, so that it doesn't get dropped on the floor.
1615 if not defaultcc:
1616 return "no reviewers listed in CL"
1617 cl.cc = Sub(cl.cc, defaultcc)
1618 cl.reviewer = defaultcc
1619 cl.Flush(ui, repo)
1620
1621 if cl.files == []:
1622 return "no changed files, not sending mail"
1623
1624 cl.Mail(ui, repo)
1625
1626def pending(ui, repo, *pats, **opts):
1627 """show pending changes
1628
1629 Lists pending changes followed by a list of unassigned but modified files.
1630 """
1631 if missing_codereview:
1632 return missing_codereview
1633
1634 m = LoadAllCL(ui, repo, web=True)
1635 names = m.keys()
1636 names.sort()
1637 for name in names:
1638 cl = m[name]
1639 ui.write(cl.PendingText() + "\n")
1640
1641 files = DefaultFiles(ui, repo, [], opts)
1642 if len(files) > 0:
1643 s = "Changed files not in any CL:\n"
1644 for f in files:
1645 s += "\t" + f + "\n"
1646 ui.write(s)
1647
1648def reposetup(ui, repo):
1649 global original_match
1650 if original_match is None:
1651 global global_repo, global_ui
1652 global_repo = repo
1653 global_ui = ui
1654 start_status_thread()
1655 original_match = scmutil.match
1656 scmutil.match = ReplacementForCmdutilMatch
1657 RietveldSetup(ui, repo)
1658
1659def CheckContributor(ui, repo, user=None):
1660 set_status("checking CONTRIBUTORS file")
1661 user, userline = FindContributor(ui, repo, user, warn=False)
1662 if not userline:
1663 raise util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1664 return userline
1665
1666def FindContributor(ui, repo, user=None, warn=True):
1667 if not user:
1668 user = ui.config("ui", "username")
1669 if not user:
1670 raise util.Abort("[ui] username is not configured in .hgrc")
1671 user = user.lower()
1672 m = re.match(r".*<(.*)>", user)
1673 if m:
1674 user = m.group(1)
1675
1676 if user not in contributors:
1677 if warn:
1678 ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1679 return user, None
1680
1681 user, email = contributors[user]
1682 return email, "%s <%s>" % (user, email)
1683
1684def submit(ui, repo, *pats, **opts):
1685 """submit change to remote repository
1686
1687 Submits change to remote repository.
1688 Bails out if the local repository is not in sync with the remote one.
1689 """
1690 if missing_codereview:
1691 return missing_codereview
1692
1693 # We already called this on startup but sometimes Mercurial forgets.
1694 set_mercurial_encoding_to_utf8()
1695
1696 other = getremote(ui, repo, opts)
1697 repo.ui.quiet = True
1698 if not opts["no_incoming"] and incoming(repo, other):
1699 return "local repository out of date; must sync before submit"
1700
1701 cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1702 if err != "":
1703 return err
1704
1705 user = None
1706 if cl.copied_from:
1707 user = cl.copied_from
1708 userline = CheckContributor(ui, repo, user)
1709 typecheck(userline, str)
1710
1711 about = ""
1712 if cl.reviewer:
1713 about += "R=" + JoinComma([CutDomain(s) for s in cl.reviewer]) + "\n"
1714 if opts.get('tbr'):
1715 tbr = SplitCommaSpace(opts.get('tbr'))
1716 cl.reviewer = Add(cl.reviewer, tbr)
1717 about += "TBR=" + JoinComma([CutDomain(s) for s in tbr]) + "\n"
1718 if cl.cc:
1719 about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1720
1721 if not cl.reviewer:
1722 return "no reviewers listed in CL"
1723
1724 if not cl.local:
1725 return "cannot submit non-local CL"
1726
1727 # upload, to sync current patch and also get change number if CL is new.
1728 if not cl.copied_from:
1729 cl.Upload(ui, repo, gofmt_just_warn=True)
1730
1731 # check gofmt for real; allowed upload to warn in order to save CL.
1732 cl.Flush(ui, repo)
1733 CheckFormat(ui, repo, cl.files)
1734
1735 about += "%s%s\n" % (server_url_base, cl.name)
1736
1737 if cl.copied_from:
1738 about += "\nCommitter: " + CheckContributor(ui, repo, None) + "\n"
1739 typecheck(about, str)
1740
1741 if not cl.mailed and not cl.copied_from: # in case this is TBR
1742 cl.Mail(ui, repo)
1743
1744 # submit changes locally
1745 date = opts.get('date')
1746 if date:
1747 opts['date'] = util.parsedate(date)
1748 typecheck(opts['date'], str)
1749 opts['message'] = cl.desc.rstrip() + "\n\n" + about
1750 typecheck(opts['message'], str)
1751
1752 if opts['dryrun']:
1753 print "NOT SUBMITTING:"
1754 print "User: ", userline
1755 print "Message:"
1756 print Indent(opts['message'], "\t")
1757 print "Files:"
1758 print Indent('\n'.join(cl.files), "\t")
1759 return "dry run; not submitted"
1760
1761 set_status("pushing " + cl.name + " to remote server")
1762
1763 other = getremote(ui, repo, opts)
1764 if outgoing(repo):
1765 raise util.Abort("local repository corrupt or out-of-phase with remote: found outgoing changes")
1766
1767 m = match.exact(repo.root, repo.getcwd(), cl.files)
1768 node = repo.commit(ustr(opts['message']), ustr(userline), opts.get('date'), m)
1769 if not node:
1770 return "nothing changed"
1771
1772 # push to remote; if it fails for any reason, roll back
1773 try:
1774 log = repo.changelog
1775 rev = log.rev(node)
1776 parents = log.parentrevs(rev)
1777 if (rev-1 not in parents and
1778 (parents == (nullrev, nullrev) or
1779 len(log.heads(log.node(parents[0]))) > 1 and
1780 (parents[1] == nullrev or len(log.heads(log.node(parents[1]))) > 1))):
1781 # created new head
1782 raise util.Abort("local repository out of date; must sync before submit")
1783
1784 # push changes to remote.
1785 # if it works, we're committed.
1786 # if not, roll back
1787 r = repo.push(other, False, None)
1788 if r == 0:
1789 raise util.Abort("local repository out of date; must sync before submit")
1790 except:
1791 real_rollback()
1792 raise
1793
1794 # we're committed. upload final patch, close review, add commit message
1795 changeURL = short(node)
1796 url = other.url()
1797 m = re.match("^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?", url)
1798 if m:
1799 changeURL = "http://code.google.com/p/%s/source/detail?r=%s" % (m.group(2), changeURL)
1800 else:
1801 print >>sys.stderr, "URL: ", url
1802 pmsg = "*** Submitted as " + changeURL + " ***\n\n" + opts['message']
1803
1804 # When posting, move reviewers to CC line,
1805 # so that the issue stops showing up in their "My Issues" page.
1806 PostMessage(ui, cl.name, pmsg, reviewers="", cc=JoinComma(cl.reviewer+cl.cc))
1807
1808 if not cl.copied_from:
1809 EditDesc(cl.name, closed=True, private=cl.private)
1810 cl.Delete(ui, repo)
1811
1812 c = repo[None]
1813 if c.branch() == releaseBranch and not c.modified() and not c.added() and not c.removed():
1814 ui.write("switching from %s to default branch.\n" % releaseBranch)
1815 err = hg.clean(repo, "default")
1816 if err:
1817 return err
1818 return None
1819
1820def sync(ui, repo, **opts):
1821 """synchronize with remote repository
1822
1823 Incorporates recent changes from the remote repository
1824 into the local repository.
1825 """
1826 if missing_codereview:
1827 return missing_codereview
1828
1829 if not opts["local"]:
1830 ui.status = sync_note
1831 ui.note = sync_note
1832 other = getremote(ui, repo, opts)
1833 modheads = repo.pull(other)
1834 err = commands.postincoming(ui, repo, modheads, True, "tip")
1835 if err:
1836 return err
1837 commands.update(ui, repo, rev="default")
1838 sync_changes(ui, repo)
1839
1840def sync_note(msg):
1841 # we run sync (pull -u) in verbose mode to get the
1842 # list of files being updated, but that drags along
1843 # a bunch of messages we don't care about.
1844 # omit them.
1845 if msg == 'resolving manifests\n':
1846 return
1847 if msg == 'searching for changes\n':
1848 return
1849 if msg == "couldn't find merge tool hgmerge\n":
1850 return
1851 sys.stdout.write(msg)
1852
1853def sync_changes(ui, repo):
1854 # Look through recent change log descriptions to find
1855 # potential references to http://.*/our-CL-number.
1856 # Double-check them by looking at the Rietveld log.
1857 def Rev(rev):
1858 desc = repo[rev].description().strip()
1859 for clname in re.findall('(?m)^http://(?:[^\n]+)/([0-9]+)$', desc):
1860 if IsLocalCL(ui, repo, clname) and IsRietveldSubmitted(ui, clname, repo[rev].hex()):
1861 ui.warn("CL %s submitted as %s; closing\n" % (clname, repo[rev]))
1862 cl, err = LoadCL(ui, repo, clname, web=False)
1863 if err != "":
1864 ui.warn("loading CL %s: %s\n" % (clname, err))
1865 continue
1866 if not cl.copied_from:
1867 EditDesc(cl.name, closed=True, private=cl.private)
1868 cl.Delete(ui, repo)
1869
1870 if hgversion < '1.4':
1871 get = util.cachefunc(lambda r: repo[r].changeset())
1872 changeiter, matchfn = cmdutil.walkchangerevs(ui, repo, [], get, {'rev': None})
1873 n = 0
1874 for st, rev, fns in changeiter:
1875 if st != 'iter':
1876 continue
1877 n += 1
1878 if n > 100:
1879 break
1880 Rev(rev)
1881 else:
1882 matchfn = scmutil.match(repo, [], {'rev': None})
1883 def prep(ctx, fns):
1884 pass
1885 for ctx in cmdutil.walkchangerevs(repo, matchfn, {'rev': None}, prep):
1886 Rev(ctx.rev())
1887
1888 # Remove files that are not modified from the CLs in which they appear.
1889 all = LoadAllCL(ui, repo, web=False)
1890 changed = ChangedFiles(ui, repo, [], {})
1891 for _, cl in all.items():
1892 extra = Sub(cl.files, changed)
1893 if extra:
1894 ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
1895 for f in extra:
1896 ui.warn("\t%s\n" % (f,))
1897 cl.files = Sub(cl.files, extra)
1898 cl.Flush(ui, repo)
1899 if not cl.files:
1900 if not cl.copied_from:
1901 ui.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl.name, cl.name))
1902 else:
1903 ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
1904 return
1905
1906def upload(ui, repo, name, **opts):
1907 """upload diffs to the code review server
1908
1909 Uploads the current modifications for a given change to the server.
1910 """
1911 if missing_codereview:
1912 return missing_codereview
1913
1914 repo.ui.quiet = True
1915 cl, err = LoadCL(ui, repo, name, web=True)
1916 if err != "":
1917 return err
1918 if not cl.local:
1919 return "cannot upload non-local change"
1920 cl.Upload(ui, repo)
1921 print "%s%s\n" % (server_url_base, cl.name)
1922 return
1923
1924review_opts = [
1925 ('r', 'reviewer', '', 'add reviewer'),
1926 ('', 'cc', '', 'add cc'),
1927 ('', 'tbr', '', 'add future reviewer'),
1928 ('m', 'message', '', 'change description (for new change)'),
1929]
1930
1931cmdtable = {
1932 # The ^ means to show this command in the help text that
1933 # is printed when running hg with no arguments.
1934 "^change": (
1935 change,
1936 [
1937 ('d', 'delete', None, 'delete existing change list'),
1938 ('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
1939 ('i', 'stdin', None, 'read change list from standard input'),
1940 ('o', 'stdout', None, 'print change list to standard output'),
1941 ('p', 'pending', None, 'print pending summary to standard output'),
1942 ],
1943 "[-d | -D] [-i] [-o] change# or FILE ..."
1944 ),
1945 "^clpatch": (
1946 clpatch,
1947 [
1948 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
1949 ('', 'no_incoming', None, 'disable check for incoming changes'),
1950 ],
1951 "change#"
1952 ),
1953 # Would prefer to call this codereview-login, but then
1954 # hg help codereview prints the help for this command
1955 # instead of the help for the extension.
1956 "code-login": (
1957 code_login,
1958 [],
1959 "",
1960 ),
1961 "^download": (
1962 download,
1963 [],
1964 "change#"
1965 ),
1966 "^file": (
1967 file,
1968 [
1969 ('d', 'delete', None, 'delete files from change list (but not repository)'),
1970 ],
1971 "[-d] change# FILE ..."
1972 ),
1973 "^gofmt": (
1974 gofmt,
1975 [
1976 ('l', 'list', None, 'list files that would change, but do not edit them'),
1977 ],
1978 "FILE ..."
1979 ),
1980 "^pending|p": (
1981 pending,
1982 [],
1983 "[FILE ...]"
1984 ),
1985 "^mail": (
1986 mail,
1987 review_opts + [
1988 ] + commands.walkopts,
1989 "[-r reviewer] [--cc cc] [change# | file ...]"
1990 ),
1991 "^release-apply": (
1992 release_apply,
1993 [
1994 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
1995 ('', 'no_incoming', None, 'disable check for incoming changes'),
1996 ],
1997 "change#"
1998 ),
1999 # TODO: release-start, release-tag, weekly-tag
2000 "^submit": (
2001 submit,
2002 review_opts + [
2003 ('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
2004 ('n', 'dryrun', None, 'make change only locally (for testing)'),
2005 ] + commands.walkopts + commands.commitopts + commands.commitopts2,
2006 "[-r reviewer] [--cc cc] [change# | file ...]"
2007 ),
2008 "^sync": (
2009 sync,
2010 [
2011 ('', 'local', None, 'do not pull changes from remote repository')
2012 ],
2013 "[--local]",
2014 ),
2015 "^undo": (
2016 undo,
2017 [
2018 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2019 ('', 'no_incoming', None, 'disable check for incoming changes'),
2020 ],
2021 "change#"
2022 ),
2023 "^upload": (
2024 upload,
2025 [],
2026 "change#"
2027 ),
2028}
2029
2030
2031#######################################################################
2032# Wrappers around upload.py for interacting with Rietveld
2033
2034# HTML form parser
2035class FormParser(HTMLParser):
2036 def __init__(self):
2037 self.map = {}
2038 self.curtag = None
2039 self.curdata = None
2040 HTMLParser.__init__(self)
2041 def handle_starttag(self, tag, attrs):
2042 if tag == "input":
2043 key = None
2044 value = ''
2045 for a in attrs:
2046 if a[0] == 'name':
2047 key = a[1]
2048 if a[0] == 'value':
2049 value = a[1]
2050 if key is not None:
2051 self.map[key] = value
2052 if tag == "textarea":
2053 key = None
2054 for a in attrs:
2055 if a[0] == 'name':
2056 key = a[1]
2057 if key is not None:
2058 self.curtag = key
2059 self.curdata = ''
2060 def handle_endtag(self, tag):
2061 if tag == "textarea" and self.curtag is not None:
2062 self.map[self.curtag] = self.curdata
2063 self.curtag = None
2064 self.curdata = None
2065 def handle_charref(self, name):
2066 self.handle_data(unichr(int(name)))
2067 def handle_entityref(self, name):
2068 import htmlentitydefs
2069 if name in htmlentitydefs.entitydefs:
2070 self.handle_data(htmlentitydefs.entitydefs[name])
2071 else:
2072 self.handle_data("&" + name + ";")
2073 def handle_data(self, data):
2074 if self.curdata is not None:
2075 self.curdata += data
2076
2077def JSONGet(ui, path):
2078 try:
2079 data = MySend(path, force_auth=False)
2080 typecheck(data, str)
2081 d = fix_json(json.loads(data))
2082 except:
2083 ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2084 return None
2085 return d
2086
2087# Clean up json parser output to match our expectations:
2088# * all strings are UTF-8-encoded str, not unicode.
2089# * missing fields are missing, not None,
2090# so that d.get("foo", defaultvalue) works.
2091def fix_json(x):
2092 if type(x) in [str, int, float, bool, type(None)]:
2093 pass
2094 elif type(x) is unicode:
2095 x = x.encode("utf-8")
2096 elif type(x) is list:
2097 for i in range(len(x)):
2098 x[i] = fix_json(x[i])
2099 elif type(x) is dict:
2100 todel = []
2101 for k in x:
2102 if x[k] is None:
2103 todel.append(k)
2104 else:
2105 x[k] = fix_json(x[k])
2106 for k in todel:
2107 del x[k]
2108 else:
2109 raise util.Abort("unknown type " + str(type(x)) + " in fix_json")
2110 if type(x) is str:
2111 x = x.replace('\r\n', '\n')
2112 return x
2113
2114def IsRietveldSubmitted(ui, clname, hex):
2115 dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2116 if dict is None:
2117 return False
2118 for msg in dict.get("messages", []):
2119 text = msg.get("text", "")
2120 m = re.match('\*\*\* Submitted as [^*]*?([0-9a-f]+) \*\*\*', text)
2121 if m is not None and len(m.group(1)) >= 8 and hex.startswith(m.group(1)):
2122 return True
2123 return False
2124
2125def IsRietveldMailed(cl):
2126 for msg in cl.dict.get("messages", []):
2127 if msg.get("text", "").find("I'd like you to review this change") >= 0:
2128 return True
2129 return False
2130
2131def DownloadCL(ui, repo, clname):
2132 set_status("downloading CL " + clname)
2133 cl, err = LoadCL(ui, repo, clname, web=True)
2134 if err != "":
2135 return None, None, None, "error loading CL %s: %s" % (clname, err)
2136
2137 # Find most recent diff
2138 diffs = cl.dict.get("patchsets", [])
2139 if not diffs:
2140 return None, None, None, "CL has no patch sets"
2141 patchid = diffs[-1]
2142
2143 patchset = JSONGet(ui, "/api/" + clname + "/" + str(patchid))
2144 if patchset is None:
2145 return None, None, None, "error loading CL patchset %s/%d" % (clname, patchid)
2146 if patchset.get("patchset", 0) != patchid:
2147 return None, None, None, "malformed patchset information"
2148
2149 vers = ""
2150 msg = patchset.get("message", "").split()
2151 if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2152 vers = msg[2]
2153 diff = "/download/issue" + clname + "_" + str(patchid) + ".diff"
2154
2155 diffdata = MySend(diff, force_auth=False)
2156
2157 # Print warning if email is not in CONTRIBUTORS file.
2158 email = cl.dict.get("owner_email", "")
2159 if not email:
2160 return None, None, None, "cannot find owner for %s" % (clname)
2161 him = FindContributor(ui, repo, email)
2162 me = FindContributor(ui, repo, None)
2163 if him == me:
2164 cl.mailed = IsRietveldMailed(cl)
2165 else:
2166 cl.copied_from = email
2167
2168 return cl, vers, diffdata, ""
2169
2170def MySend(request_path, payload=None,
2171 content_type="application/octet-stream",
2172 timeout=None, force_auth=True,
2173 **kwargs):
2174 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2175 try:
2176 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2177 except Exception, e:
2178 if type(e) != urllib2.HTTPError or e.code != 500: # only retry on HTTP 500 error
2179 raise
2180 print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2181 time.sleep(2)
2182 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2183
2184# Like upload.py Send but only authenticates when the
2185# redirect is to www.google.com/accounts. This keeps
2186# unnecessary redirects from happening during testing.
2187def MySend1(request_path, payload=None,
2188 content_type="application/octet-stream",
2189 timeout=None, force_auth=True,
2190 **kwargs):
2191 """Sends an RPC and returns the response.
2192
2193 Args:
2194 request_path: The path to send the request to, eg /api/appversion/create.
2195 payload: The body of the request, or None to send an empty request.
2196 content_type: The Content-Type header to use.
2197 timeout: timeout in seconds; default None i.e. no timeout.
2198 (Note: for large requests on OS X, the timeout doesn't work right.)
2199 kwargs: Any keyword arguments are converted into query string parameters.
2200
2201 Returns:
2202 The response body, as a string.
2203 """
2204 # TODO: Don't require authentication. Let the server say
2205 # whether it is necessary.
2206 global rpc
2207 if rpc == None:
2208 rpc = GetRpcServer(upload_options)
2209 self = rpc
2210 if not self.authenticated and force_auth:
2211 self._Authenticate()
2212 if request_path is None:
2213 return
2214
2215 old_timeout = socket.getdefaulttimeout()
2216 socket.setdefaulttimeout(timeout)
2217 try:
2218 tries = 0
2219 while True:
2220 tries += 1
2221 args = dict(kwargs)
2222 url = "http://%s%s" % (self.host, request_path)
2223 if args:
2224 url += "?" + urllib.urlencode(args)
2225 req = self._CreateRequest(url=url, data=payload)
2226 req.add_header("Content-Type", content_type)
2227 try:
2228 f = self.opener.open(req)
2229 response = f.read()
2230 f.close()
2231 # Translate \r\n into \n, because Rietveld doesn't.
2232 response = response.replace('\r\n', '\n')
2233 # who knows what urllib will give us
2234 if type(response) == unicode:
2235 response = response.encode("utf-8")
2236 typecheck(response, str)
2237 return response
2238 except urllib2.HTTPError, e:
2239 if tries > 3:
2240 raise
2241 elif e.code == 401:
2242 self._Authenticate()
2243 elif e.code == 302:
2244 loc = e.info()["location"]
2245 if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2246 return ''
2247 self._Authenticate()
2248 else:
2249 raise
2250 finally:
2251 socket.setdefaulttimeout(old_timeout)
2252
2253def GetForm(url):
2254 f = FormParser()
2255 f.feed(ustr(MySend(url))) # f.feed wants unicode
2256 f.close()
2257 # convert back to utf-8 to restore sanity
2258 m = {}
2259 for k,v in f.map.items():
2260 m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2261 return m
2262
2263def EditDesc(issue, subject=None, desc=None, reviewers=None, cc=None, closed=False, private=False):
2264 set_status("uploading change to description")
2265 form_fields = GetForm("/" + issue + "/edit")
2266 if subject is not None:
2267 form_fields['subject'] = subject
2268 if desc is not None:
2269 form_fields['description'] = desc
2270 if reviewers is not None:
2271 form_fields['reviewers'] = reviewers
2272 if cc is not None:
2273 form_fields['cc'] = cc
2274 if closed:
2275 form_fields['closed'] = "checked"
2276 if private:
2277 form_fields['private'] = "checked"
2278 ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2279 response = MySend("/" + issue + "/edit", body, content_type=ctype)
2280 if response != "":
2281 print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2282 sys.exit(2)
2283
2284def PostMessage(ui, issue, message, reviewers=None, cc=None, send_mail=True, subject=None):
2285 set_status("uploading message")
2286 form_fields = GetForm("/" + issue + "/publish")
2287 if reviewers is not None:
2288 form_fields['reviewers'] = reviewers
2289 if cc is not None:
2290 form_fields['cc'] = cc
2291 if send_mail:
2292 form_fields['send_mail'] = "checked"
2293 else:
2294 del form_fields['send_mail']
2295 if subject is not None:
2296 form_fields['subject'] = subject
2297 form_fields['message'] = message
2298
2299 form_fields['message_only'] = '1' # Don't include draft comments
2300 if reviewers is not None or cc is not None:
2301 form_fields['message_only'] = '' # Must set '' in order to override cc/reviewer
2302 ctype = "applications/x-www-form-urlencoded"
2303 body = urllib.urlencode(form_fields)
2304 response = MySend("/" + issue + "/publish", body, content_type=ctype)
2305 if response != "":
2306 print response
2307 sys.exit(2)
2308
2309class opt(object):
2310 pass
2311
2312def nocommit(*pats, **opts):
2313 """(disabled when using this extension)"""
2314 raise util.Abort("codereview extension enabled; use mail, upload, or submit instead of commit")
2315
2316def nobackout(*pats, **opts):
2317 """(disabled when using this extension)"""
2318 raise util.Abort("codereview extension enabled; use undo instead of backout")
2319
2320def norollback(*pats, **opts):
2321 """(disabled when using this extension)"""
2322 raise util.Abort("codereview extension enabled; use undo instead of rollback")
2323
2324def RietveldSetup(ui, repo):
2325 global defaultcc, upload_options, rpc, server, server_url_base, force_google_account, verbosity, contributors
2326 global missing_codereview
2327
2328 repo_config_path = ''
2329 # Read repository-specific options from lib/codereview/codereview.cfg
2330 try:
2331 repo_config_path = repo.root + '/lib/codereview/codereview.cfg'
2332 f = open(repo_config_path)
2333 for line in f:
2334 if line.startswith('defaultcc: '):
2335 defaultcc = SplitCommaSpace(line[10:])
2336 except:
2337 # If there are no options, chances are good this is not
2338 # a code review repository; stop now before we foul
2339 # things up even worse. Might also be that repo doesn't
2340 # even have a root. See issue 959.
2341 if repo_config_path == '':
2342 missing_codereview = 'codereview disabled: repository has no root'
2343 else:
2344 missing_codereview = 'codereview disabled: cannot open ' + repo_config_path
2345 return
2346
2347 # Should only modify repository with hg submit.
2348 # Disable the built-in Mercurial commands that might
2349 # trip things up.
2350 cmdutil.commit = nocommit
2351 global real_rollback
2352 real_rollback = repo.rollback
2353 repo.rollback = norollback
2354 # would install nobackout if we could; oh well
2355
2356 try:
2357 f = open(repo.root + '/CONTRIBUTORS', 'r')
2358 except:
2359 raise util.Abort("cannot open %s: %s" % (repo.root+'/CONTRIBUTORS', ExceptionDetail()))
2360 for line in f:
2361 # CONTRIBUTORS is a list of lines like:
2362 # Person <email>
2363 # Person <email> <alt-email>
2364 # The first email address is the one used in commit logs.
2365 if line.startswith('#'):
2366 continue
2367 m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
2368 if m:
2369 name = m.group(1)
2370 email = m.group(2)[1:-1]
2371 contributors[email.lower()] = (name, email)
2372 for extra in m.group(3).split():
2373 contributors[extra[1:-1].lower()] = (name, email)
2374
2375 if not ui.verbose:
2376 verbosity = 0
2377
2378 # Config options.
2379 x = ui.config("codereview", "server")
2380 if x is not None:
2381 server = x
2382
2383 # TODO(rsc): Take from ui.username?
2384 email = None
2385 x = ui.config("codereview", "email")
2386 if x is not None:
2387 email = x
2388
2389 server_url_base = "http://" + server + "/"
2390
2391 testing = ui.config("codereview", "testing")
2392 force_google_account = ui.configbool("codereview", "force_google_account", False)
2393
2394 upload_options = opt()
2395 upload_options.email = email
2396 upload_options.host = None
2397 upload_options.verbose = 0
2398 upload_options.description = None
2399 upload_options.description_file = None
2400 upload_options.reviewers = None
2401 upload_options.cc = None
2402 upload_options.message = None
2403 upload_options.issue = None
2404 upload_options.download_base = False
2405 upload_options.revision = None
2406 upload_options.send_mail = False
2407 upload_options.vcs = None
2408 upload_options.server = server
2409 upload_options.save_cookies = True
2410
2411 if testing:
2412 upload_options.save_cookies = False
2413 upload_options.email = "test@example.com"
2414
2415 rpc = None
2416
2417 global releaseBranch
2418 tags = repo.branchtags().keys()
2419 if 'release-branch.r100' in tags:
2420 # NOTE(rsc): This tags.sort is going to get the wrong
2421 # answer when comparing release-branch.r99 with
2422 # release-branch.r100. If we do ten releases a year
2423 # that gives us 4 years before we have to worry about this.
2424 raise util.Abort('tags.sort needs to be fixed for release-branch.r100')
2425 tags.sort()
2426 for t in tags:
2427 if t.startswith('release-branch.'):
2428 releaseBranch = t
2429
2430#######################################################################
2431# http://codereview.appspot.com/static/upload.py, heavily edited.
2432
2433#!/usr/bin/env python
2434#
2435# Copyright 2007 Google Inc.
2436#
2437# Licensed under the Apache License, Version 2.0 (the "License");
2438# you may not use this file except in compliance with the License.
2439# You may obtain a copy of the License at
2440#
2441# http://www.apache.org/licenses/LICENSE-2.0
2442#
2443# Unless required by applicable law or agreed to in writing, software
2444# distributed under the License is distributed on an "AS IS" BASIS,
2445# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2446# See the License for the specific language governing permissions and
2447# limitations under the License.
2448
2449"""Tool for uploading diffs from a version control system to the codereview app.
2450
2451Usage summary: upload.py [options] [-- diff_options]
2452
2453Diff options are passed to the diff command of the underlying system.
2454
2455Supported version control systems:
2456 Git
2457 Mercurial
2458 Subversion
2459
2460It is important for Git/Mercurial users to specify a tree/node/branch to diff
2461against by using the '--rev' option.
2462"""
2463# This code is derived from appcfg.py in the App Engine SDK (open source),
2464# and from ASPN recipe #146306.
2465
2466import cookielib
2467import getpass
2468import logging
2469import mimetypes
2470import optparse
2471import os
2472import re
2473import socket
2474import subprocess
2475import sys
2476import urllib
2477import urllib2
2478import urlparse
2479
2480# The md5 module was deprecated in Python 2.5.
2481try:
2482 from hashlib import md5
2483except ImportError:
2484 from md5 import md5
2485
2486try:
2487 import readline
2488except ImportError:
2489 pass
2490
2491# The logging verbosity:
2492# 0: Errors only.
2493# 1: Status messages.
2494# 2: Info logs.
2495# 3: Debug logs.
2496verbosity = 1
2497
2498# Max size of patch or base file.
2499MAX_UPLOAD_SIZE = 900 * 1024
2500
2501# whitelist for non-binary filetypes which do not start with "text/"
2502# .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2503TEXT_MIMETYPES = [
2504 'application/javascript',
2505 'application/x-javascript',
2506 'application/x-freemind'
2507]
2508
2509def GetEmail(prompt):
2510 """Prompts the user for their email address and returns it.
2511
2512 The last used email address is saved to a file and offered up as a suggestion
2513 to the user. If the user presses enter without typing in anything the last
2514 used email address is used. If the user enters a new address, it is saved
2515 for next time we prompt.
2516
2517 """
2518 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2519 last_email = ""
2520 if os.path.exists(last_email_file_name):
2521 try:
2522 last_email_file = open(last_email_file_name, "r")
2523 last_email = last_email_file.readline().strip("\n")
2524 last_email_file.close()
2525 prompt += " [%s]" % last_email
2526 except IOError, e:
2527 pass
2528 email = raw_input(prompt + ": ").strip()
2529 if email:
2530 try:
2531 last_email_file = open(last_email_file_name, "w")
2532 last_email_file.write(email)
2533 last_email_file.close()
2534 except IOError, e:
2535 pass
2536 else:
2537 email = last_email
2538 return email
2539
2540
2541def StatusUpdate(msg):
2542 """Print a status message to stdout.
2543
2544 If 'verbosity' is greater than 0, print the message.
2545
2546 Args:
2547 msg: The string to print.
2548 """
2549 if verbosity > 0:
2550 print msg
2551
2552
2553def ErrorExit(msg):
2554 """Print an error message to stderr and exit."""
2555 print >>sys.stderr, msg
2556 sys.exit(1)
2557
2558
2559class ClientLoginError(urllib2.HTTPError):
2560 """Raised to indicate there was an error authenticating with ClientLogin."""
2561
2562 def __init__(self, url, code, msg, headers, args):
2563 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
2564 self.args = args
2565 self.reason = args["Error"]
2566
2567
2568class AbstractRpcServer(object):
2569 """Provides a common interface for a simple RPC server."""
2570
2571 def __init__(self, host, auth_function, host_override=None, extra_headers={}, save_cookies=False):
2572 """Creates a new HttpRpcServer.
2573
2574 Args:
2575 host: The host to send requests to.
2576 auth_function: A function that takes no arguments and returns an
2577 (email, password) tuple when called. Will be called if authentication
2578 is required.
2579 host_override: The host header to send to the server (defaults to host).
2580 extra_headers: A dict of extra headers to append to every request.
2581 save_cookies: If True, save the authentication cookies to local disk.
2582 If False, use an in-memory cookiejar instead. Subclasses must
2583 implement this functionality. Defaults to False.
2584 """
2585 self.host = host
2586 self.host_override = host_override
2587 self.auth_function = auth_function
2588 self.authenticated = False
2589 self.extra_headers = extra_headers
2590 self.save_cookies = save_cookies
2591 self.opener = self._GetOpener()
2592 if self.host_override:
2593 logging.info("Server: %s; Host: %s", self.host, self.host_override)
2594 else:
2595 logging.info("Server: %s", self.host)
2596
2597 def _GetOpener(self):
2598 """Returns an OpenerDirector for making HTTP requests.
2599
2600 Returns:
2601 A urllib2.OpenerDirector object.
2602 """
2603 raise NotImplementedError()
2604
2605 def _CreateRequest(self, url, data=None):
2606 """Creates a new urllib request."""
2607 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
2608 req = urllib2.Request(url, data=data)
2609 if self.host_override:
2610 req.add_header("Host", self.host_override)
2611 for key, value in self.extra_headers.iteritems():
2612 req.add_header(key, value)
2613 return req
2614
2615 def _GetAuthToken(self, email, password):
2616 """Uses ClientLogin to authenticate the user, returning an auth token.
2617
2618 Args:
2619 email: The user's email address
2620 password: The user's password
2621
2622 Raises:
2623 ClientLoginError: If there was an error authenticating with ClientLogin.
2624 HTTPError: If there was some other form of HTTP error.
2625
2626 Returns:
2627 The authentication token returned by ClientLogin.
2628 """
2629 account_type = "GOOGLE"
2630 if self.host.endswith(".google.com") and not force_google_account:
2631 # Needed for use inside Google.
2632 account_type = "HOSTED"
2633 req = self._CreateRequest(
2634 url="https://www.google.com/accounts/ClientLogin",
2635 data=urllib.urlencode({
2636 "Email": email,
2637 "Passwd": password,
2638 "service": "ah",
2639 "source": "rietveld-codereview-upload",
2640 "accountType": account_type,
2641 }),
2642 )
2643 try:
2644 response = self.opener.open(req)
2645 response_body = response.read()
2646 response_dict = dict(x.split("=") for x in response_body.split("\n") if x)
2647 return response_dict["Auth"]
2648 except urllib2.HTTPError, e:
2649 if e.code == 403:
2650 body = e.read()
2651 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
2652 raise ClientLoginError(req.get_full_url(), e.code, e.msg, e.headers, response_dict)
2653 else:
2654 raise
2655
2656 def _GetAuthCookie(self, auth_token):
2657 """Fetches authentication cookies for an authentication token.
2658
2659 Args:
2660 auth_token: The authentication token returned by ClientLogin.
2661
2662 Raises:
2663 HTTPError: If there was an error fetching the authentication cookies.
2664 """
2665 # This is a dummy value to allow us to identify when we're successful.
2666 continue_location = "http://localhost/"
2667 args = {"continue": continue_location, "auth": auth_token}
2668 req = self._CreateRequest("http://%s/_ah/login?%s" % (self.host, urllib.urlencode(args)))
2669 try:
2670 response = self.opener.open(req)
2671 except urllib2.HTTPError, e:
2672 response = e
2673 if (response.code != 302 or
2674 response.info()["location"] != continue_location):
2675 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, response.headers, response.fp)
2676 self.authenticated = True
2677
2678 def _Authenticate(self):
2679 """Authenticates the user.
2680
2681 The authentication process works as follows:
2682 1) We get a username and password from the user
2683 2) We use ClientLogin to obtain an AUTH token for the user
2684 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2685 3) We pass the auth token to /_ah/login on the server to obtain an
2686 authentication cookie. If login was successful, it tries to redirect
2687 us to the URL we provided.
2688
2689 If we attempt to access the upload API without first obtaining an
2690 authentication cookie, it returns a 401 response (or a 302) and
2691 directs us to authenticate ourselves with ClientLogin.
2692 """
2693 for i in range(3):
2694 credentials = self.auth_function()
2695 try:
2696 auth_token = self._GetAuthToken(credentials[0], credentials[1])
2697 except ClientLoginError, e:
2698 if e.reason == "BadAuthentication":
2699 print >>sys.stderr, "Invalid username or password."
2700 continue
2701 if e.reason == "CaptchaRequired":
2702 print >>sys.stderr, (
2703 "Please go to\n"
2704 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2705 "and verify you are a human. Then try again.")
2706 break
2707 if e.reason == "NotVerified":
2708 print >>sys.stderr, "Account not verified."
2709 break
2710 if e.reason == "TermsNotAgreed":
2711 print >>sys.stderr, "User has not agreed to TOS."
2712 break
2713 if e.reason == "AccountDeleted":
2714 print >>sys.stderr, "The user account has been deleted."
2715 break
2716 if e.reason == "AccountDisabled":
2717 print >>sys.stderr, "The user account has been disabled."
2718 break
2719 if e.reason == "ServiceDisabled":
2720 print >>sys.stderr, "The user's access to the service has been disabled."
2721 break
2722 if e.reason == "ServiceUnavailable":
2723 print >>sys.stderr, "The service is not available; try again later."
2724 break
2725 raise
2726 self._GetAuthCookie(auth_token)
2727 return
2728
2729 def Send(self, request_path, payload=None,
2730 content_type="application/octet-stream",
2731 timeout=None,
2732 **kwargs):
2733 """Sends an RPC and returns the response.
2734
2735 Args:
2736 request_path: The path to send the request to, eg /api/appversion/create.
2737 payload: The body of the request, or None to send an empty request.
2738 content_type: The Content-Type header to use.
2739 timeout: timeout in seconds; default None i.e. no timeout.
2740 (Note: for large requests on OS X, the timeout doesn't work right.)
2741 kwargs: Any keyword arguments are converted into query string parameters.
2742
2743 Returns:
2744 The response body, as a string.
2745 """
2746 # TODO: Don't require authentication. Let the server say
2747 # whether it is necessary.
2748 if not self.authenticated:
2749 self._Authenticate()
2750
2751 old_timeout = socket.getdefaulttimeout()
2752 socket.setdefaulttimeout(timeout)
2753 try:
2754 tries = 0
2755 while True:
2756 tries += 1
2757 args = dict(kwargs)
2758 url = "http://%s%s" % (self.host, request_path)
2759 if args:
2760 url += "?" + urllib.urlencode(args)
2761 req = self._CreateRequest(url=url, data=payload)
2762 req.add_header("Content-Type", content_type)
2763 try:
2764 f = self.opener.open(req)
2765 response = f.read()
2766 f.close()
2767 return response
2768 except urllib2.HTTPError, e:
2769 if tries > 3:
2770 raise
2771 elif e.code == 401 or e.code == 302:
2772 self._Authenticate()
2773 else:
2774 raise
2775 finally:
2776 socket.setdefaulttimeout(old_timeout)
2777
2778
2779class HttpRpcServer(AbstractRpcServer):
2780 """Provides a simplified RPC-style interface for HTTP requests."""
2781
2782 def _Authenticate(self):
2783 """Save the cookie jar after authentication."""
2784 super(HttpRpcServer, self)._Authenticate()
2785 if self.save_cookies:
2786 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
2787 self.cookie_jar.save()
2788
2789 def _GetOpener(self):
2790 """Returns an OpenerDirector that supports cookies and ignores redirects.
2791
2792 Returns:
2793 A urllib2.OpenerDirector object.
2794 """
2795 opener = urllib2.OpenerDirector()
2796 opener.add_handler(urllib2.ProxyHandler())
2797 opener.add_handler(urllib2.UnknownHandler())
2798 opener.add_handler(urllib2.HTTPHandler())
2799 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
2800 opener.add_handler(urllib2.HTTPSHandler())
2801 opener.add_handler(urllib2.HTTPErrorProcessor())
2802 if self.save_cookies:
2803 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies_" + server)
2804 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
2805 if os.path.exists(self.cookie_file):
2806 try:
2807 self.cookie_jar.load()
2808 self.authenticated = True
2809 StatusUpdate("Loaded authentication cookies from %s" % self.cookie_file)
2810 except (cookielib.LoadError, IOError):
2811 # Failed to load cookies - just ignore them.
2812 pass
2813 else:
2814 # Create an empty cookie file with mode 600
2815 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
2816 os.close(fd)
2817 # Always chmod the cookie file
2818 os.chmod(self.cookie_file, 0600)
2819 else:
2820 # Don't save cookies across runs of update.py.
2821 self.cookie_jar = cookielib.CookieJar()
2822 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
2823 return opener
2824
2825
2826def GetRpcServer(options):
2827 """Returns an instance of an AbstractRpcServer.
2828
2829 Returns:
2830 A new AbstractRpcServer, on which RPC calls can be made.
2831 """
2832
2833 rpc_server_class = HttpRpcServer
2834
2835 def GetUserCredentials():
2836 """Prompts the user for a username and password."""
2837 # Disable status prints so they don't obscure the password prompt.
2838 global global_status
2839 st = global_status
2840 global_status = None
2841
2842 email = options.email
2843 if email is None:
2844 email = GetEmail("Email (login for uploading to %s)" % options.server)
2845 password = getpass.getpass("Password for %s: " % email)
2846
2847 # Put status back.
2848 global_status = st
2849 return (email, password)
2850
2851 # If this is the dev_appserver, use fake authentication.
2852 host = (options.host or options.server).lower()
2853 if host == "localhost" or host.startswith("localhost:"):
2854 email = options.email
2855 if email is None:
2856 email = "test@example.com"
2857 logging.info("Using debug user %s. Override with --email" % email)
2858 server = rpc_server_class(
2859 options.server,
2860 lambda: (email, "password"),
2861 host_override=options.host,
2862 extra_headers={"Cookie": 'dev_appserver_login="%s:False"' % email},
2863 save_cookies=options.save_cookies)
2864 # Don't try to talk to ClientLogin.
2865 server.authenticated = True
2866 return server
2867
2868 return rpc_server_class(options.server, GetUserCredentials,
2869 host_override=options.host, save_cookies=options.save_cookies)
2870
2871
2872def EncodeMultipartFormData(fields, files):
2873 """Encode form fields for multipart/form-data.
2874
2875 Args:
2876 fields: A sequence of (name, value) elements for regular form fields.
2877 files: A sequence of (name, filename, value) elements for data to be
2878 uploaded as files.
2879 Returns:
2880 (content_type, body) ready for httplib.HTTP instance.
2881
2882 Source:
2883 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
2884 """
2885 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
2886 CRLF = '\r\n'
2887 lines = []
2888 for (key, value) in fields:
2889 typecheck(key, str)
2890 typecheck(value, str)
2891 lines.append('--' + BOUNDARY)
2892 lines.append('Content-Disposition: form-data; name="%s"' % key)
2893 lines.append('')
2894 lines.append(value)
2895 for (key, filename, value) in files:
2896 typecheck(key, str)
2897 typecheck(filename, str)
2898 typecheck(value, str)
2899 lines.append('--' + BOUNDARY)
2900 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
2901 lines.append('Content-Type: %s' % GetContentType(filename))
2902 lines.append('')
2903 lines.append(value)
2904 lines.append('--' + BOUNDARY + '--')
2905 lines.append('')
2906 body = CRLF.join(lines)
2907 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
2908 return content_type, body
2909
2910
2911def GetContentType(filename):
2912 """Helper to guess the content-type from the filename."""
2913 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
2914
2915
2916# Use a shell for subcommands on Windows to get a PATH search.
2917use_shell = sys.platform.startswith("win")
2918
2919def RunShellWithReturnCode(command, print_output=False,
2920 universal_newlines=True, env=os.environ):
2921 """Executes a command and returns the output from stdout and the return code.
2922
2923 Args:
2924 command: Command to execute.
2925 print_output: If True, the output is printed to stdout.
2926 If False, both stdout and stderr are ignored.
2927 universal_newlines: Use universal_newlines flag (default: True).
2928
2929 Returns:
2930 Tuple (output, return code)
2931 """
2932 logging.info("Running %s", command)
2933 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
2934 shell=use_shell, universal_newlines=universal_newlines, env=env)
2935 if print_output:
2936 output_array = []
2937 while True:
2938 line = p.stdout.readline()
2939 if not line:
2940 break
2941 print line.strip("\n")
2942 output_array.append(line)
2943 output = "".join(output_array)
2944 else:
2945 output = p.stdout.read()
2946 p.wait()
2947 errout = p.stderr.read()
2948 if print_output and errout:
2949 print >>sys.stderr, errout
2950 p.stdout.close()
2951 p.stderr.close()
2952 return output, p.returncode
2953
2954
2955def RunShell(command, silent_ok=False, universal_newlines=True,
2956 print_output=False, env=os.environ):
2957 data, retcode = RunShellWithReturnCode(command, print_output, universal_newlines, env)
2958 if retcode:
2959 ErrorExit("Got error status from %s:\n%s" % (command, data))
2960 if not silent_ok and not data:
2961 ErrorExit("No output from %s" % command)
2962 return data
2963
2964
2965class VersionControlSystem(object):
2966 """Abstract base class providing an interface to the VCS."""
2967
2968 def __init__(self, options):
2969 """Constructor.
2970
2971 Args:
2972 options: Command line options.
2973 """
2974 self.options = options
2975
2976 def GenerateDiff(self, args):
2977 """Return the current diff as a string.
2978
2979 Args:
2980 args: Extra arguments to pass to the diff command.
2981 """
2982 raise NotImplementedError(
2983 "abstract method -- subclass %s must override" % self.__class__)
2984
2985 def GetUnknownFiles(self):
2986 """Return a list of files unknown to the VCS."""
2987 raise NotImplementedError(
2988 "abstract method -- subclass %s must override" % self.__class__)
2989
2990 def CheckForUnknownFiles(self):
2991 """Show an "are you sure?" prompt if there are unknown files."""
2992 unknown_files = self.GetUnknownFiles()
2993 if unknown_files:
2994 print "The following files are not added to version control:"
2995 for line in unknown_files:
2996 print line
2997 prompt = "Are you sure to continue?(y/N) "
2998 answer = raw_input(prompt).strip()
2999 if answer != "y":
3000 ErrorExit("User aborted")
3001
3002 def GetBaseFile(self, filename):
3003 """Get the content of the upstream version of a file.
3004
3005 Returns:
3006 A tuple (base_content, new_content, is_binary, status)
3007 base_content: The contents of the base file.
3008 new_content: For text files, this is empty. For binary files, this is
3009 the contents of the new file, since the diff output won't contain
3010 information to reconstruct the current file.
3011 is_binary: True iff the file is binary.
3012 status: The status of the file.
3013 """
3014
3015 raise NotImplementedError(
3016 "abstract method -- subclass %s must override" % self.__class__)
3017
3018
3019 def GetBaseFiles(self, diff):
3020 """Helper that calls GetBase file for each file in the patch.
3021
3022 Returns:
3023 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
3024 are retrieved based on lines that start with "Index:" or
3025 "Property changes on:".
3026 """
3027 files = {}
3028 for line in diff.splitlines(True):
3029 if line.startswith('Index:') or line.startswith('Property changes on:'):
3030 unused, filename = line.split(':', 1)
3031 # On Windows if a file has property changes its filename uses '\'
3032 # instead of '/'.
3033 filename = filename.strip().replace('\\', '/')
3034 files[filename] = self.GetBaseFile(filename)
3035 return files
3036
3037
3038 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
3039 files):
3040 """Uploads the base files (and if necessary, the current ones as well)."""
3041
3042 def UploadFile(filename, file_id, content, is_binary, status, is_base):
3043 """Uploads a file to the server."""
3044 set_status("uploading " + filename)
3045 file_too_large = False
3046 if is_base:
3047 type = "base"
3048 else:
3049 type = "current"
3050 if len(content) > MAX_UPLOAD_SIZE:
3051 print ("Not uploading the %s file for %s because it's too large." %
3052 (type, filename))
3053 file_too_large = True
3054 content = ""
3055 checksum = md5(content).hexdigest()
3056 if options.verbose > 0 and not file_too_large:
3057 print "Uploading %s file for %s" % (type, filename)
3058 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
3059 form_fields = [
3060 ("filename", filename),
3061 ("status", status),
3062 ("checksum", checksum),
3063 ("is_binary", str(is_binary)),
3064 ("is_current", str(not is_base)),
3065 ]
3066 if file_too_large:
3067 form_fields.append(("file_too_large", "1"))
3068 if options.email:
3069 form_fields.append(("user", options.email))
3070 ctype, body = EncodeMultipartFormData(form_fields, [("data", filename, content)])
3071 response_body = rpc_server.Send(url, body, content_type=ctype)
3072 if not response_body.startswith("OK"):
3073 StatusUpdate(" --> %s" % response_body)
3074 sys.exit(1)
3075
3076 # Don't want to spawn too many threads, nor do we want to
3077 # hit Rietveld too hard, or it will start serving 500 errors.
3078 # When 8 works, it's no better than 4, and sometimes 8 is
3079 # too many for Rietveld to handle.
3080 MAX_PARALLEL_UPLOADS = 4
3081
3082 sema = threading.BoundedSemaphore(MAX_PARALLEL_UPLOADS)
3083 upload_threads = []
3084 finished_upload_threads = []
3085
3086 class UploadFileThread(threading.Thread):
3087 def __init__(self, args):
3088 threading.Thread.__init__(self)
3089 self.args = args
3090 def run(self):
3091 UploadFile(*self.args)
3092 finished_upload_threads.append(self)
3093 sema.release()
3094
3095 def StartUploadFile(*args):
3096 sema.acquire()
3097 while len(finished_upload_threads) > 0:
3098 t = finished_upload_threads.pop()
3099 upload_threads.remove(t)
3100 t.join()
3101 t = UploadFileThread(args)
3102 upload_threads.append(t)
3103 t.start()
3104
3105 def WaitForUploads():
3106 for t in upload_threads:
3107 t.join()
3108
3109 patches = dict()
3110 [patches.setdefault(v, k) for k, v in patch_list]
3111 for filename in patches.keys():
3112 base_content, new_content, is_binary, status = files[filename]
3113 file_id_str = patches.get(filename)
3114 if file_id_str.find("nobase") != -1:
3115 base_content = None
3116 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
3117 file_id = int(file_id_str)
3118 if base_content != None:
3119 StartUploadFile(filename, file_id, base_content, is_binary, status, True)
3120 if new_content != None:
3121 StartUploadFile(filename, file_id, new_content, is_binary, status, False)
3122 WaitForUploads()
3123
3124 def IsImage(self, filename):
3125 """Returns true if the filename has an image extension."""
3126 mimetype = mimetypes.guess_type(filename)[0]
3127 if not mimetype:
3128 return False
3129 return mimetype.startswith("image/")
3130
3131 def IsBinary(self, filename):
3132 """Returns true if the guessed mimetyped isnt't in text group."""
3133 mimetype = mimetypes.guess_type(filename)[0]
3134 if not mimetype:
3135 return False # e.g. README, "real" binaries usually have an extension
3136 # special case for text files which don't start with text/
3137 if mimetype in TEXT_MIMETYPES:
3138 return False
3139 return not mimetype.startswith("text/")
3140
3141
3142class FakeMercurialUI(object):
3143 def __init__(self):
3144 self.quiet = True
3145 self.output = ''
3146
3147 def write(self, *args, **opts):
3148 self.output += ' '.join(args)
3149 def copy(self):
3150 return self
3151 def status(self, *args, **opts):
3152 pass
3153
3154 def readconfig(self, *args, **opts):
3155 pass
3156 def expandpath(self, *args, **opts):
3157 return global_ui.expandpath(*args, **opts)
3158 def configitems(self, *args, **opts):
3159 return global_ui.configitems(*args, **opts)
3160 def config(self, *args, **opts):
3161 return global_ui.config(*args, **opts)
3162
3163use_hg_shell = False # set to True to shell out to hg always; slower
3164
3165class MercurialVCS(VersionControlSystem):
3166 """Implementation of the VersionControlSystem interface for Mercurial."""
3167
3168 def __init__(self, options, ui, repo):
3169 super(MercurialVCS, self).__init__(options)
3170 self.ui = ui
3171 self.repo = repo
3172 self.status = None
3173 # Absolute path to repository (we can be in a subdir)
3174 self.repo_dir = os.path.normpath(repo.root)
3175 # Compute the subdir
3176 cwd = os.path.normpath(os.getcwd())
3177 assert cwd.startswith(self.repo_dir)
3178 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
3179 if self.options.revision:
3180 self.base_rev = self.options.revision
3181 else:
3182 mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3183 if not err and mqparent != "":
3184 self.base_rev = mqparent
3185 else:
3186 self.base_rev = RunShell(["hg", "parents", "-q"]).split(':')[1].strip()
3187 def _GetRelPath(self, filename):
3188 """Get relative path of a file according to the current directory,
3189 given its logical path in the repo."""
3190 assert filename.startswith(self.subdir), (filename, self.subdir)
3191 return filename[len(self.subdir):].lstrip(r"\/")
3192
3193 def GenerateDiff(self, extra_args):
3194 # If no file specified, restrict to the current subdir
3195 extra_args = extra_args or ["."]
3196 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
3197 data = RunShell(cmd, silent_ok=True)
3198 svndiff = []
3199 filecount = 0
3200 for line in data.splitlines():
3201 m = re.match("diff --git a/(\S+) b/(\S+)", line)
3202 if m:
3203 # Modify line to make it look like as it comes from svn diff.
3204 # With this modification no changes on the server side are required
3205 # to make upload.py work with Mercurial repos.
3206 # NOTE: for proper handling of moved/copied files, we have to use
3207 # the second filename.
3208 filename = m.group(2)
3209 svndiff.append("Index: %s" % filename)
3210 svndiff.append("=" * 67)
3211 filecount += 1
3212 logging.info(line)
3213 else:
3214 svndiff.append(line)
3215 if not filecount:
3216 ErrorExit("No valid patches found in output from hg diff")
3217 return "\n".join(svndiff) + "\n"
3218
3219 def GetUnknownFiles(self):
3220 """Return a list of files unknown to the VCS."""
3221 args = []
3222 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3223 silent_ok=True)
3224 unknown_files = []
3225 for line in status.splitlines():
3226 st, fn = line.split(" ", 1)
3227 if st == "?":
3228 unknown_files.append(fn)
3229 return unknown_files
3230
3231 def get_hg_status(self, rev, path):
3232 # We'd like to use 'hg status -C path', but that is buggy
3233 # (see http://mercurial.selenic.com/bts/issue3023).
3234 # Instead, run 'hg status -C' without a path
3235 # and skim the output for the path we want.
3236 if self.status is None:
3237 if use_hg_shell:
3238 out = RunShell(["hg", "status", "-C", "--rev", rev])
3239 else:
3240 fui = FakeMercurialUI()
3241 ret = commands.status(fui, self.repo, *[], **{'rev': [rev], 'copies': True})
3242 if ret:
3243 raise util.Abort(ret)
3244 out = fui.output
3245 self.status = out.splitlines()
3246 for i in range(len(self.status)):
3247 # line is
3248 # A path
3249 # M path
3250 # etc
3251 line = self.status[i].replace('\\', '/')
3252 if line[2:] == path:
3253 if i+1 < len(self.status) and self.status[i+1][:2] == ' ':
3254 return self.status[i:i+2]
3255 return self.status[i:i+1]
3256 raise util.Abort("no status for " + path)
3257
3258 def GetBaseFile(self, filename):
3259 set_status("inspecting " + filename)
3260 # "hg status" and "hg cat" both take a path relative to the current subdir
3261 # rather than to the repo root, but "hg diff" has given us the full path
3262 # to the repo root.
3263 base_content = ""
3264 new_content = None
3265 is_binary = False
3266 oldrelpath = relpath = self._GetRelPath(filename)
3267 out = self.get_hg_status(self.base_rev, relpath)
3268 status, what = out[0].split(' ', 1)
3269 if len(out) > 1 and status == "A" and what == relpath:
3270 oldrelpath = out[1].strip()
3271 status = "M"
3272 if ":" in self.base_rev:
3273 base_rev = self.base_rev.split(":", 1)[0]
3274 else:
3275 base_rev = self.base_rev
3276 if status != "A":
3277 if use_hg_shell:
3278 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3279 else:
3280 base_content = str(self.repo[base_rev][oldrelpath].data())
3281 is_binary = "\0" in base_content # Mercurial's heuristic
3282 if status != "R":
3283 new_content = open(relpath, "rb").read()
3284 is_binary = is_binary or "\0" in new_content
3285 if is_binary and base_content and use_hg_shell:
3286 # Fetch again without converting newlines
3287 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
3288 silent_ok=True, universal_newlines=False)
3289 if not is_binary or not self.IsImage(relpath):
3290 new_content = None
3291 return base_content, new_content, is_binary, status
3292
3293
3294# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3295def SplitPatch(data):
3296 """Splits a patch into separate pieces for each file.
3297
3298 Args:
3299 data: A string containing the output of svn diff.
3300
3301 Returns:
3302 A list of 2-tuple (filename, text) where text is the svn diff output
3303 pertaining to filename.
3304 """
3305 patches = []
3306 filename = None
3307 diff = []
3308 for line in data.splitlines(True):
3309 new_filename = None
3310 if line.startswith('Index:'):
3311 unused, new_filename = line.split(':', 1)
3312 new_filename = new_filename.strip()
3313 elif line.startswith('Property changes on:'):
3314 unused, temp_filename = line.split(':', 1)
3315 # When a file is modified, paths use '/' between directories, however
3316 # when a property is modified '\' is used on Windows. Make them the same
3317 # otherwise the file shows up twice.
3318 temp_filename = temp_filename.strip().replace('\\', '/')
3319 if temp_filename != filename:
3320 # File has property changes but no modifications, create a new diff.
3321 new_filename = temp_filename
3322 if new_filename:
3323 if filename and diff:
3324 patches.append((filename, ''.join(diff)))
3325 filename = new_filename
3326 diff = [line]
3327 continue
3328 if diff is not None:
3329 diff.append(line)
3330 if filename and diff:
3331 patches.append((filename, ''.join(diff)))
3332 return patches
3333
3334
3335def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
3336 """Uploads a separate patch for each file in the diff output.
3337
3338 Returns a list of [patch_key, filename] for each file.
3339 """
3340 patches = SplitPatch(data)
3341 rv = []
3342 for patch in patches:
3343 set_status("uploading patch for " + patch[0])
3344 if len(patch[1]) > MAX_UPLOAD_SIZE:
3345 print ("Not uploading the patch for " + patch[0] +
3346 " because the file is too large.")
3347 continue
3348 form_fields = [("filename", patch[0])]
3349 if not options.download_base:
3350 form_fields.append(("content_upload", "1"))
3351 files = [("data", "data.diff", patch[1])]
3352 ctype, body = EncodeMultipartFormData(form_fields, files)
3353 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
3354 print "Uploading patch for " + patch[0]
3355 response_body = rpc_server.Send(url, body, content_type=ctype)
3356 lines = response_body.splitlines()
3357 if not lines or lines[0] != "OK":
3358 StatusUpdate(" --> %s" % response_body)
3359 sys.exit(1)
3360 rv.append([lines[1], patch[0]])
3361 return rv