github.com/varialus/godfly@v0.0.0-20130904042352-1934f9f095ab/lib/codereview/codereview.py (about)

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