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