2 # (The line above is necessary so that I can use 世界 in the
3 # *comment* below without Python getting all bent out of shape.)
5 # Copyright 2007-2009 Google Inc.
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
11 # http://www.apache.org/licenses/LICENSE-2.0
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.
19 '''Mercurial interface to codereview.appspot.com.
21 To configure, set the following options in
22 your repository's .hg/hgrc file.
25 codereview = path/to/codereview.py
28 server = codereview.appspot.com
30 The server should be running Rietveld; see http://code.google.com/p/rietveld/.
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.
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".
41 from mercurial import cmdutil, commands, hg, util, error, match
42 from mercurial.node import nullrev, hex, nullid, short
47 from HTMLParser import HTMLParser
49 # The standard 'json' package is new in Python 2.6.
50 # Before that it was an external package named simplejson.
52 # Standard location in 2.6 and beyond.
56 # Conventional name for earlier package.
57 import simplejson as json
60 # Was also bundled with django, which is commonly installed.
61 from django.utils import simplejson as json
67 hgversion = util.version()
69 from mercurial.version import version as v
70 hgversion = v.get_version()
73 from mercurial.discovery import findcommonincoming
75 def findcommonincoming(repo, remote):
76 return repo.findcommonincoming(remote)
79 The code review extension requires Mercurial 1.3 or newer.
81 To install a new Mercurial,
83 sudo easy_install mercurial
85 works on most systems.
89 You may need to clear your current Mercurial installation by running:
91 sudo apt-get remove mercurial mercurial-common
92 sudo rm -rf /etc/mercurial
97 if os.access("/etc/mercurial", 0):
101 def promptyesno(ui, msg):
102 # Arguments to ui.prompt changed between 1.3 and 1.3.1.
103 # Even so, some 1.3.1 distributions seem to have the old prompt!?!?
104 # What a terrible way to maintain software.
106 return ui.promptchoice(msg, ["&yes", "&no"], 0) == 0
107 except AttributeError:
108 return ui.prompt(msg, ["&yes", "&no"], "y") != "n"
110 # To experiment with Mercurial in the python interpreter:
111 # >>> repo = hg.repository(ui.ui(), path = ".")
113 #######################################################################
114 # Normally I would split this into multiple files, but it simplifies
115 # import path headaches to keep it all in one file. Sorry.
118 if __name__ == "__main__":
119 print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
122 server = "codereview.appspot.com"
123 server_url_base = None
126 missing_codereview = None
130 #######################################################################
131 # RE: UNICODE STRING HANDLING
133 # Python distinguishes between the str (string of bytes)
134 # and unicode (string of code points) types. Most operations
135 # work on either one just fine, but some (like regexp matching)
136 # require unicode, and others (like write) require str.
138 # As befits the language, Python hides the distinction between
139 # unicode and str by converting between them silently, but
140 # *only* if all the bytes/code points involved are 7-bit ASCII.
141 # This means that if you're not careful, your program works
142 # fine on "hello, world" and fails on "hello, 世界". And of course,
143 # the obvious way to be careful - use static types - is unavailable.
144 # So the only way is trial and error to find where to put explicit
147 # Because more functions do implicit conversion to str (string of bytes)
148 # than do implicit conversion to unicode (string of code points),
149 # the convention in this module is to represent all text as str,
150 # converting to unicode only when calling a unicode-only function
151 # and then converting back to str as soon as possible.
155 raise util.Abort("type check failed: %s has type %s != %s" % (repr(s), type(s), t))
157 # If we have to pass unicode instead of str, ustr does that conversion clearly.
160 return s.decode("utf-8")
162 # Even with those, Mercurial still sometimes turns unicode into str
163 # and then tries to use it as ascii. Change Mercurial's default.
164 def set_mercurial_encoding_to_utf8():
165 from mercurial import encoding
166 encoding.encoding = 'utf-8'
168 set_mercurial_encoding_to_utf8()
170 # Even with those we still run into problems.
171 # I tried to do things by the book but could not convince
172 # Mercurial to let me check in a change with UTF-8 in the
173 # CL description or author field, no matter how many conversions
174 # between str and unicode I inserted and despite changing the
175 # default encoding. I'm tired of this game, so set the default
176 # encoding for all of Python to 'utf-8', not 'ascii'.
177 def default_to_utf8():
179 reload(sys) # site.py deleted setdefaultencoding; get it back
180 sys.setdefaultencoding('utf-8')
184 #######################################################################
185 # Change list parsing.
187 # Change lists are stored in .hg/codereview/cl.nnnnnn
188 # where nnnnnn is the number assigned by the code review server.
189 # Most data about a change list is stored on the code review server
190 # too: the description, reviewer, and cc list are all stored there.
191 # The only thing in the cl.nnnnnn file is the list of relevant files.
192 # Also, the existence of the cl.nnnnnn file marks this repository
193 # as the one where the change list lives.
195 emptydiff = """Index: ~rietveld~placeholder~
196 ===================================================================
197 diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
202 def __init__(self, name):
212 self.copied_from = None # None means current user
220 s += "Author: " + cl.copied_from + "\n\n"
222 s += "Private: " + str(self.private) + "\n"
223 s += "Mailed: " + str(self.mailed) + "\n"
224 s += "Description:\n"
225 s += Indent(cl.desc, "\t")
232 def EditorText(self):
237 s += "Author: " + cl.copied_from + "\n"
239 s += 'URL: ' + cl.url + ' # cannot edit\n\n'
241 s += "Private: True\n"
242 s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
243 s += "CC: " + JoinComma(cl.cc) + "\n"
245 s += "Description:\n"
247 s += "\t<enter description here>\n"
249 s += Indent(cl.desc, "\t")
251 if cl.local or cl.name == "new":
259 def PendingText(self):
261 s = cl.name + ":" + "\n"
262 s += Indent(cl.desc, "\t")
265 s += "\tAuthor: " + cl.copied_from + "\n"
266 s += "\tReviewer: " + JoinComma(cl.reviewer) + "\n"
267 s += "\tCC: " + JoinComma(cl.cc) + "\n"
270 s += "\t\t" + f + "\n"
274 def Flush(self, ui, repo):
275 if self.name == "new":
276 self.Upload(ui, repo, gofmt_just_warn=True, creating=True)
277 dir = CodeReviewDir(ui, repo)
278 path = dir + '/cl.' + self.name
279 f = open(path+'!', "w")
280 f.write(self.DiskText())
282 if sys.platform == "win32" and os.path.isfile(path):
284 os.rename(path+'!', path)
285 if self.web and not self.copied_from:
286 EditDesc(self.name, desc=self.desc,
287 reviewers=JoinComma(self.reviewer), cc=JoinComma(self.cc),
288 private=self.private)
290 def Delete(self, ui, repo):
291 dir = CodeReviewDir(ui, repo)
292 os.unlink(dir + "/cl." + self.name)
298 if self.name != "new":
299 s = "code review %s: %s" % (self.name, s)
303 def Upload(self, ui, repo, send_mail=False, gofmt=True, gofmt_just_warn=False, creating=False, quiet=False):
304 if not self.files and not creating:
305 ui.warn("no files in change list\n")
306 if ui.configbool("codereview", "force_gofmt", True) and gofmt:
307 CheckFormat(ui, repo, self.files, just_warn=gofmt_just_warn)
308 set_status("uploading CL metadata + diffs")
311 ("content_upload", "1"),
312 ("reviewers", JoinComma(self.reviewer)),
313 ("cc", JoinComma(self.cc)),
314 ("description", self.desc),
318 if self.name != "new":
319 form_fields.append(("issue", self.name))
321 # We do not include files when creating the issue,
322 # because we want the patch sets to record the repository
323 # and base revision they are diffs against. We use the patch
324 # set message for that purpose, but there is no message with
325 # the first patch set. Instead the message gets used as the
326 # new CL's overall subject. So omit the diffs when creating
327 # and then we'll run an immediate upload.
328 # This has the effect that every CL begins with an empty "Patch set 1".
329 if self.files and not creating:
330 vcs = MercurialVCS(upload_options, ui, repo)
331 data = vcs.GenerateDiff(self.files)
332 files = vcs.GetBaseFiles(data)
333 if len(data) > MAX_UPLOAD_SIZE:
334 uploaded_diff_file = []
335 form_fields.append(("separate_patches", "1"))
337 uploaded_diff_file = [("data", "data.diff", data)]
339 uploaded_diff_file = [("data", "data.diff", emptydiff)]
341 if vcs and self.name != "new":
342 form_fields.append(("subject", "diff -r " + vcs.base_rev + " " + getremote(ui, repo, {}).path))
344 # First upload sets the subject for the CL itself.
345 form_fields.append(("subject", self.Subject()))
346 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
347 response_body = MySend("/upload", body, content_type=ctype)
350 lines = msg.splitlines()
353 patchset = lines[1].strip()
354 patches = [x.split(" ", 1) for x in lines[2:]]
355 if response_body.startswith("Issue updated.") and quiet:
358 ui.status(msg + "\n")
359 set_status("uploaded CL metadata + diffs")
360 if not response_body.startswith("Issue created.") and not response_body.startswith("Issue updated."):
361 raise util.Abort("failed to update issue: " + response_body)
362 issue = msg[msg.rfind("/")+1:]
365 self.url = server_url_base + self.name
366 if not uploaded_diff_file:
367 set_status("uploading patches")
368 patches = UploadSeparatePatches(issue, rpc, patchset, data, upload_options)
370 set_status("uploading base files")
371 vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
373 set_status("sending mail")
374 MySend("/" + issue + "/mail", payload="")
376 set_status("flushing changes to disk")
380 def Mail(self, ui, repo):
381 pmsg = "Hello " + JoinComma(self.reviewer)
383 pmsg += " (cc: %s)" % (', '.join(self.cc),)
386 repourl = getremote(ui, repo, {}).path
388 pmsg += "I'd like you to review this change to\n" + repourl + "\n"
390 pmsg += "Please take another look.\n"
392 PostMessage(ui, self.name, pmsg, subject=self.Subject())
396 def GoodCLName(name):
398 return re.match("^[0-9]+$", name)
400 def ParseCL(text, name):
415 for line in text.split('\n'):
418 if line != '' and line[0] == '#':
420 if line == '' or line[0] == ' ' or line[0] == '\t':
421 if sname == None and line != '':
422 return None, lineno, 'text outside section'
424 sections[sname] += line + '\n'
428 s, val = line[:p].strip(), line[p+1:].strip()
432 sections[sname] += val + '\n'
434 return None, lineno, 'malformed section header'
437 sections[k] = StripCommon(sections[k]).rstrip()
440 if sections['Author']:
441 cl.copied_from = sections['Author']
442 cl.desc = sections['Description']
443 for line in sections['Files'].split('\n'):
446 line = line[0:i].rstrip()
450 cl.files.append(line)
451 cl.reviewer = SplitCommaSpace(sections['Reviewer'])
452 cl.cc = SplitCommaSpace(sections['CC'])
453 cl.url = sections['URL']
454 if sections['Mailed'] != 'False':
455 # Odd default, but avoids spurious mailings when
456 # reading old CLs that do not have a Mailed: line.
457 # CLs created with this update will always have
458 # Mailed: False on disk.
460 if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
462 if cl.desc == '<enter description here>':
466 def SplitCommaSpace(s):
471 return re.split(", *", s)
485 def ExceptionDetail():
486 s = str(sys.exc_info()[0])
487 if s.startswith("<type '") and s.endswith("'>"):
489 elif s.startswith("<class '") and s.endswith("'>"):
491 arg = str(sys.exc_info()[1])
496 def IsLocalCL(ui, repo, name):
497 return GoodCLName(name) and os.access(CodeReviewDir(ui, repo) + "/cl." + name, 0)
499 # Load CL from disk and/or the web.
500 def LoadCL(ui, repo, name, web=True):
502 set_status("loading CL " + name)
503 if not GoodCLName(name):
504 return None, "invalid CL name"
505 dir = CodeReviewDir(ui, repo)
506 path = dir + "cl." + name
507 if os.access(path, 0):
511 cl, lineno, err = ParseCL(text, name)
513 return None, "malformed CL data: "+err
518 set_status("getting issue metadata from web")
519 d = JSONGet(ui, "/api/" + name + "?messages=true")
522 return None, "cannot load CL %s from server" % (name,)
523 if 'owner_email' not in d or 'issue' not in d or str(d['issue']) != name:
524 return None, "malformed response loading CL data from code review server"
526 cl.reviewer = d.get('reviewers', [])
527 cl.cc = d.get('cc', [])
528 if cl.local and cl.copied_from and cl.desc:
529 # local copy of CL written by someone else
530 # and we saved a description. use that one,
531 # so that committers can edit the description
532 # before doing hg submit.
535 cl.desc = d.get('description', "")
536 cl.url = server_url_base + name
538 cl.private = d.get('private', False) != False
539 set_status("loaded CL " + name)
545 # print >>sys.stderr, "\t", time.asctime(), s
549 class StatusThread(threading.Thread):
551 threading.Thread.__init__(self)
553 # pause a reasonable amount of time before
554 # starting to display status messages, so that
555 # most hg commands won't ever see them.
558 # now show status every 15 seconds
560 time.sleep(15 - time.time() % 15)
565 s = "(unknown status)"
566 print >>sys.stderr, time.asctime(), s
568 def start_status_thread():
570 t.setDaemon(True) # allowed to exit if t is still running
573 class LoadCLThread(threading.Thread):
574 def __init__(self, ui, repo, dir, f, web):
575 threading.Thread.__init__(self)
583 cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
585 self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
589 # Load all the CLs from this repository.
590 def LoadAllCL(ui, repo, web=True):
591 dir = CodeReviewDir(ui, repo)
593 files = [f for f in os.listdir(dir) if f.startswith('cl.')]
599 t = LoadCLThread(ui, repo, dir, f, web)
602 # first request: wait in case it needs to authenticate
603 # otherwise we get lots of user/password prompts
604 # running in parallel.
617 # Find repository root. On error, ui.warn and return None
618 def RepoDir(ui, repo):
620 if not url.startswith('file:'):
621 ui.warn("repository %s is not in local file system\n" % (url,))
624 if url.endswith('/'):
629 # Find (or make) code review directory. On error, ui.warn and return None
630 def CodeReviewDir(ui, repo):
631 dir = RepoDir(ui, repo)
634 dir += '/.hg/codereview/'
635 if not os.path.isdir(dir):
639 ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
644 # Turn leading tabs into spaces, so that the common white space
645 # prefix doesn't get confused when people's editors write out
646 # some lines with spaces, some with tabs. Only a heuristic
647 # (some editors don't use 8 spaces either) but a useful one.
648 def TabsToSpaces(line):
650 while i < len(line) and line[i] == '\t':
652 return ' '*(8*i) + line[i:]
654 # Strip maximal common leading white space prefix from text
655 def StripCommon(text):
658 for line in text.split('\n'):
662 line = TabsToSpaces(line)
663 white = line[:len(line)-len(line.lstrip())]
668 for i in range(min(len(white), len(ws))+1):
669 if white[0:i] == ws[0:i]:
677 for line in text.split('\n'):
679 line = TabsToSpaces(line)
680 if line.startswith(ws):
681 line = line[len(ws):]
682 if line == '' and t == '':
685 while len(t) >= 2 and t[-2:] == '\n\n':
690 # Indent text with indent.
691 def Indent(text, indent):
693 typecheck(indent, str)
695 for line in text.split('\n'):
696 t += indent + line + '\n'
700 # Return the first line of l
703 return text.split('\n')[0]
705 _change_prolog = """# Change list.
706 # Lines beginning with # are ignored.
707 # Multi-line values should be indented.
710 #######################################################################
711 # Mercurial helper functions
713 # Get effective change nodes taking into account applied MQ patches
714 def effective_revpair(repo):
716 return cmdutil.revpair(repo, ['qparent'])
718 return cmdutil.revpair(repo, None)
720 # Return list of changed files in repository that match pats.
721 # Warn about patterns that did not match.
722 def matchpats(ui, repo, pats, opts):
723 matcher = cmdutil.match(repo, pats, opts)
724 node1, node2 = effective_revpair(repo)
725 modified, added, removed, deleted, unknown, ignored, clean = repo.status(node1, node2, matcher, ignored=True, clean=True, unknown=True)
726 return (modified, added, removed, deleted, unknown, ignored, clean)
728 # Return list of changed files in repository that match pats.
729 # The patterns came from the command line, so we warn
730 # if they have no effect or cannot be understood.
731 def ChangedFiles(ui, repo, pats, opts, taken=None):
733 # Run each pattern separately so that we can warn about
734 # patterns that didn't do anything useful.
736 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, [p], opts)
739 promptadd(ui, repo, f)
742 promptremove(ui, repo, f)
745 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, [p], opts)
746 for f in modified + added + removed:
748 ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
749 if not modified and not added and not removed:
750 ui.warn("warning: %s did not match any modified files\n" % (p,))
752 # Again, all at once (eliminates duplicates)
753 modified, added, removed = matchpats(ui, repo, pats, opts)[:3]
754 l = modified + added + removed
757 l = Sub(l, taken.keys())
760 # Return list of changed files in repository that match pats and still exist.
761 def ChangedExistingFiles(ui, repo, pats, opts):
762 modified, added = matchpats(ui, repo, pats, opts)[:2]
767 # Return list of files claimed by existing CLs
769 all = LoadAllCL(ui, repo, web=False)
771 for _, cl in all.items():
776 # Return list of changed files that are not claimed by other CLs
777 def DefaultFiles(ui, repo, pats, opts):
778 return ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
781 return [l for l in l1 if l not in l2]
788 def Intersect(l1, l2):
789 return [l for l in l1 if l in l2]
791 def getremote(ui, repo, opts):
792 # save $http_proxy; creating the HTTP repo object will
793 # delete it in an attempt to "help"
794 proxy = os.environ.get('http_proxy')
795 source = hg.parseurl(ui.expandpath("default"), None)[0]
797 remoteui = hg.remoteui # hg 1.6
799 remoteui = cmdutil.remoteui
800 other = hg.repository(remoteui(repo, opts), source)
801 if proxy is not None:
802 os.environ['http_proxy'] = proxy
805 def Incoming(ui, repo, opts):
806 _, incoming, _ = findcommonincoming(repo, getremote(ui, repo, opts))
809 desc_re = '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
811 desc_msg = '''Your CL description appears not to use the standard form.
813 The first line of your change description is conventionally a
814 one-line summary of the change, prefixed by the primary affected package,
815 and is used as the subject for code review mail; the rest of the description
820 encoding/rot13: new package
822 math: add IsInf, IsNaN
824 net: fix cname in LookupHost
826 unicode: update to Unicode 5.0.2
832 def promptremove(ui, repo, f):
833 if promptyesno(ui, "hg remove %s (y/n)?" % (f,)):
834 if commands.remove(ui, repo, 'path:'+f) != 0:
835 ui.warn("error removing %s" % (f,))
837 def promptadd(ui, repo, f):
838 if promptyesno(ui, "hg add %s (y/n)?" % (f,)):
839 if commands.add(ui, repo, 'path:'+f) != 0:
840 ui.warn("error adding %s" % (f,))
842 def EditCL(ui, repo, cl):
843 set_status(None) # do not show status
846 s = ui.edit(s, ui.username())
847 clx, line, err = ParseCL(s, cl.name)
849 if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
850 return "change list not modified"
855 if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
857 elif re.search('<enter reason for undo>', clx.desc):
858 if promptyesno(ui, "change list description omits reason for undo\nre-edit (y/n)?"):
860 elif not re.match(desc_re, clx.desc.split('\n')[0]):
861 if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
864 # Check file list for files that need to be hg added or hg removed
865 # or simply aren't understood.
866 pats = ['path:'+f for f in clx.files]
867 modified, added, removed, deleted, unknown, ignored, clean = matchpats(ui, repo, pats, {})
870 if f in modified or f in added or f in removed:
874 promptremove(ui, repo, f)
878 promptadd(ui, repo, f)
882 ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
885 ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
888 p = repo.root + '/' + f
889 if os.path.isfile(p):
890 ui.warn("warning: %s is a file but not known to hg\n" % (f,))
894 ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
896 ui.warn("error: %s does not exist; omitting\n" % (f,))
900 cl.reviewer = clx.reviewer
903 cl.private = clx.private
907 # For use by submit, etc. (NOT by change)
908 # Get change list number or list of files from command line.
909 # If files are given, make a new change list.
910 def CommandLineCL(ui, repo, pats, opts, defaultcc=None):
911 if len(pats) > 0 and GoodCLName(pats[0]):
913 return None, "cannot specify change number and file names"
914 if opts.get('message'):
915 return None, "cannot use -m with existing CL"
916 cl, err = LoadCL(ui, repo, pats[0], web=True)
922 cl.files = ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
924 return None, "no files changed"
925 if opts.get('reviewer'):
926 cl.reviewer = Add(cl.reviewer, SplitCommaSpace(opts.get('reviewer')))
928 cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
930 cl.cc = Add(cl.cc, defaultcc)
932 if opts.get('message'):
933 cl.desc = opts.get('message')
935 err = EditCL(ui, repo, cl)
940 # reposetup replaces cmdutil.match with this wrapper,
941 # which expands the syntax @clnumber to mean the files
943 original_match = None
944 def ReplacementForCmdutilMatch(repo, pats=None, opts=None, globbed=False, default='relpath'):
950 if p.startswith('@'):
953 if not GoodCLName(clname):
954 raise util.Abort("invalid CL name " + clname)
955 cl, err = LoadCL(repo.ui, repo, clname, web=False)
957 raise util.Abort("loading CL " + clname + ": " + err)
959 raise util.Abort("no files in CL " + clname)
960 files = Add(files, cl.files)
961 pats = Sub(pats, taken) + ['path:'+f for f in files]
962 return original_match(repo, pats=pats, opts=opts, globbed=globbed, default=default)
964 def RelativePath(path, cwd):
966 if path.startswith(cwd) and path[n] == '/':
970 def CheckFormat(ui, repo, files, just_warn=False):
971 set_status("running gofmt")
972 CheckGofmt(ui, repo, files, just_warn)
973 CheckTabfmt(ui, repo, files, just_warn)
975 # Check that gofmt run on the list of files does not change them
976 def CheckGofmt(ui, repo, files, just_warn):
977 files = [f for f in files if (f.startswith('src/') or f.startswith('test/bench/')) and f.endswith('.go')]
981 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
982 files = [f for f in files if os.access(f, 0)]
986 cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
989 raise util.Abort("gofmt: " + ExceptionDetail())
990 data = cmd.stdout.read()
991 errors = cmd.stderr.read()
993 set_status("done with gofmt")
995 ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
998 msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
1000 ui.warn("warning: " + msg + "\n")
1002 raise util.Abort(msg)
1005 # Check that *.[chys] files indent using tabs.
1006 def CheckTabfmt(ui, repo, files, just_warn):
1007 files = [f for f in files if f.startswith('src/') and re.search(r"\.[chys]$", f)]
1011 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1012 files = [f for f in files if os.access(f, 0)]
1016 for line in open(f, 'r'):
1017 # Four leading spaces is enough to complain about,
1018 # except that some Plan 9 code uses four spaces as the label indent,
1020 if line.startswith(' ') and not re.match(' [A-Za-z0-9_]+:', line):
1024 # ignore cannot open file, etc.
1026 if len(badfiles) > 0:
1027 msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
1029 ui.warn("warning: " + msg + "\n")
1031 raise util.Abort(msg)
1034 #######################################################################
1035 # Mercurial commands
1037 # every command must take a ui and and repo as arguments.
1038 # opts is a dict where you can find other command line flags
1040 # Other parameters are taken in order from items on the command line that
1041 # don't start with a dash. If no default value is given in the parameter list,
1042 # they are required.
1045 def change(ui, repo, *pats, **opts):
1046 """create, edit or delete a change list
1048 Create, edit or delete a change list.
1049 A change list is a group of files to be reviewed and submitted together,
1050 plus a textual description of the change.
1051 Change lists are referred to by simple alphanumeric names.
1053 Changes must be reviewed before they can be submitted.
1055 In the absence of options, the change command opens the
1056 change list for editing in the default editor.
1058 Deleting a change with the -d or -D flag does not affect
1059 the contents of the files listed in that change. To revert
1060 the files listed in a change, use
1064 before running hg change -d 123456.
1067 if missing_codereview:
1068 return missing_codereview
1071 if len(pats) > 0 and GoodCLName(pats[0]):
1074 return "cannot specify CL name and file patterns"
1076 cl, err = LoadCL(ui, repo, name, web=True)
1079 if not cl.local and (opts["stdin"] or not opts["stdout"]):
1080 return "cannot change non-local CL " + name
1082 if repo[None].branch() != "default":
1083 return "cannot run hg change outside default branch"
1087 files = ChangedFiles(ui, repo, pats, opts, taken=Taken(ui, repo))
1089 if opts["delete"] or opts["deletelocal"]:
1090 if opts["delete"] and opts["deletelocal"]:
1091 return "cannot use -d and -D together"
1093 if opts["deletelocal"]:
1096 return "cannot use "+flag+" with file patterns"
1097 if opts["stdin"] or opts["stdout"]:
1098 return "cannot use "+flag+" with -i or -o"
1100 return "cannot change non-local CL " + name
1103 return "original author must delete CL; hg change -D will remove locally"
1104 PostMessage(ui, cl.name, "*** Abandoned ***", send_mail=cl.mailed)
1105 EditDesc(cl.name, closed=True, private=cl.private)
1110 s = sys.stdin.read()
1111 clx, line, err = ParseCL(s, name)
1113 return "error parsing change list: line %d: %s" % (line, err)
1114 if clx.desc is not None:
1117 if clx.reviewer is not None:
1118 cl.reviewer = clx.reviewer
1120 if clx.cc is not None:
1123 if clx.files is not None:
1124 cl.files = clx.files
1126 if clx.private != cl.private:
1127 cl.private = clx.private
1130 if not opts["stdin"] and not opts["stdout"]:
1133 err = EditCL(ui, repo, cl)
1138 for d, _ in dirty.items():
1142 d.Upload(ui, repo, quiet=True)
1145 ui.write(cl.EditorText())
1146 elif opts["pending"]:
1147 ui.write(cl.PendingText())
1152 ui.write("CL created: " + cl.url + "\n")
1155 def code_login(ui, repo, **opts):
1156 """log in to code review server
1158 Logs in to the code review server, saving a cookie in
1159 a file in your home directory.
1161 if missing_codereview:
1162 return missing_codereview
1166 def clpatch(ui, repo, clname, **opts):
1167 """import a patch from the code review server
1169 Imports a patch from the code review server into the local client.
1170 If the local client has already modified any of the files that the
1171 patch modifies, this command will refuse to apply the patch.
1173 Submitting an imported patch will keep the original author's
1174 name as the Author: line but add your own name to a Committer: line.
1176 if repo[None].branch() != "default":
1177 return "cannot run hg clpatch outside default branch"
1178 return clpatch_or_undo(ui, repo, clname, opts, mode="clpatch")
1180 def undo(ui, repo, clname, **opts):
1181 """undo the effect of a CL
1183 Creates a new CL that undoes an earlier CL.
1184 After creating the CL, opens the CL text for editing so that
1185 you can add the reason for the undo to the description.
1187 if repo[None].branch() != "default":
1188 return "cannot run hg undo outside default branch"
1189 return clpatch_or_undo(ui, repo, clname, opts, mode="undo")
1191 def release_apply(ui, repo, clname, **opts):
1192 """apply a CL to the release branch
1194 Creates a new CL copying a previously committed change
1195 from the main branch to the release branch.
1196 The current client must either be clean or already be in
1199 The release branch must be created by starting with a
1200 clean client, disabling the code review plugin, and running:
1202 hg update weekly.YYYY-MM-DD
1203 hg branch release-branch.rNN
1204 hg commit -m 'create release-branch.rNN'
1205 hg push --new-branch
1207 Then re-enable the code review plugin.
1209 People can test the release branch by running
1211 hg update release-branch.rNN
1213 in a clean client. To return to the normal tree,
1217 Move changes since the weekly into the release branch
1218 using hg release-apply followed by the usual code review
1219 process and hg submit.
1221 When it comes time to tag the release, record the
1222 final long-form tag of the release-branch.rNN
1223 in the *default* branch's .hgtags file. That is, run
1227 and then edit .hgtags as you would for a weekly.
1231 if not releaseBranch:
1232 return "no active release branches"
1233 if c.branch() != releaseBranch:
1234 if c.modified() or c.added() or c.removed():
1235 raise util.Abort("uncommitted local changes - cannot switch branches")
1236 err = hg.clean(repo, releaseBranch)
1240 err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1242 raise util.Abort(err)
1243 except Exception, e:
1244 hg.clean(repo, "default")
1248 def rev2clname(rev):
1249 # Extract CL name from revision description.
1250 # The last line in the description that is a codereview URL is the real one.
1251 # Earlier lines might be part of the user-written description.
1252 all = re.findall('(?m)^http://codereview.appspot.com/([0-9]+)$', rev.description())
1257 undoHeader = """undo CL %s / %s
1259 <enter reason for undo>
1261 ««« original CL description
1268 backportHeader = """[%s] %s
1273 backportFooter = """
1277 # Implementation of clpatch/undo.
1278 def clpatch_or_undo(ui, repo, clname, opts, mode):
1279 if missing_codereview:
1280 return missing_codereview
1282 if mode == "undo" or mode == "backport":
1283 if hgversion < '1.4':
1284 # Don't have cmdutil.match (see implementation of sync command).
1285 return "hg is too old to run hg %s - update to 1.4 or newer" % mode
1287 # Find revision in Mercurial repository.
1288 # Assume CL number is 7+ decimal digits.
1289 # Otherwise is either change log sequence number (fewer decimal digits),
1290 # hexadecimal hash, or tag name.
1291 # Mercurial will fall over long before the change log
1292 # sequence numbers get to be 7 digits long.
1293 if re.match('^[0-9]{7,}$', clname):
1295 matchfn = cmdutil.match(repo, [], {'rev': None})
1298 for ctx in cmdutil.walkchangerevs(repo, matchfn, {'rev': None}, prep):
1299 rev = repo[ctx.rev()]
1300 # Last line with a code review URL is the actual review URL.
1301 # Earlier ones might be part of the CL description.
1307 return "cannot find CL %s in local repository" % clname
1311 return "unknown revision %s" % clname
1312 clname = rev2clname(rev)
1314 return "cannot find CL name in revision description"
1316 # Create fresh CL and start with patch that would reverse the change.
1317 vers = short(rev.node())
1319 desc = str(rev.description())
1321 cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1323 cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1325 v0 = short(rev.parents()[0].node())
1331 patch = RunShell(["hg", "diff", "--git", "-r", arg])
1334 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1337 if patch == emptydiff:
1338 return "codereview issue %s has no diff" % clname
1340 # find current hg version (hg identify)
1342 parents = ctx.parents()
1343 id = '+'.join([short(p.node()) for p in parents])
1345 # if version does not match the patch version,
1346 # try to update the patch line numbers.
1347 if vers != "" and id != vers:
1348 # "vers in repo" gives the wrong answer
1349 # on some versions of Mercurial. Instead, do the actual
1350 # lookup and catch the exception.
1352 repo[vers].description()
1354 return "local repository is out of date; sync to get %s" % (vers)
1355 patch1, err = portPatch(repo, patch, vers, id)
1357 if not opts["ignore_hgpatch_failure"]:
1358 return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1362 if opts["no_incoming"] or mode == "backport":
1363 argv += ["--checksync=false"]
1365 cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1367 return "hgpatch: " + ExceptionDetail()
1369 out, err = cmd.communicate(patch)
1370 if cmd.returncode != 0 and not opts["ignore_hgpatch_failure"]:
1371 return "hgpatch failed"
1373 cl.files = out.strip().split()
1374 if not cl.files and not opts["ignore_hgpatch_failure"]:
1375 return "codereview issue %s has no changed files" % clname
1376 files = ChangedFiles(ui, repo, [], opts)
1377 extra = Sub(cl.files, files)
1379 ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1382 err = EditCL(ui, repo, cl)
1384 return "CL created, but error editing: " + err
1387 ui.write(cl.PendingText() + "\n")
1389 # portPatch rewrites patch from being a patch against
1390 # oldver to being a patch against newver.
1391 def portPatch(repo, patch, oldver, newver):
1392 lines = patch.splitlines(True) # True = keep \n
1394 for i in range(len(lines)):
1396 if line.startswith('--- a/'):
1398 delta = fileDeltas(repo, file, oldver, newver)
1399 if not delta or not line.startswith('@@ '):
1401 # @@ -x,y +z,w @@ means the patch chunk replaces
1402 # the original file's line numbers x up to x+y with the
1403 # line numbers z up to z+w in the new file.
1404 # Find the delta from x in the original to the same
1405 # line in the current version and add that delta to both
1407 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1409 return None, "error parsing patch line numbers"
1410 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1411 d, err = lineDelta(delta, n1, len1)
1416 lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1418 newpatch = ''.join(lines)
1421 # fileDelta returns the line number deltas for the given file's
1422 # changes from oldver to newver.
1423 # The deltas are a list of (n, len, newdelta) triples that say
1424 # lines [n, n+len) were modified, and after that range the
1425 # line numbers are +newdelta from what they were before.
1426 def fileDeltas(repo, file, oldver, newver):
1427 cmd = ["hg", "diff", "--git", "-r", oldver + ":" + newver, "path:" + file]
1428 data = RunShell(cmd, silent_ok=True)
1430 for line in data.splitlines():
1431 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1434 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1435 deltas.append((n1, len1, n2+len2-(n1+len1)))
1438 # lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1439 # It returns an error if those lines were rewritten by the patch.
1440 def lineDelta(deltas, n, len):
1442 for (old, oldlen, newdelta) in deltas:
1446 return 0, "patch and recent changes conflict"
1450 def download(ui, repo, clname, **opts):
1451 """download a change from the code review server
1453 Download prints a description of the given change list
1454 followed by its diff, downloaded from the code review server.
1456 if missing_codereview:
1457 return missing_codereview
1459 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1462 ui.write(cl.EditorText() + "\n")
1463 ui.write(patch + "\n")
1466 def file(ui, repo, clname, pat, *pats, **opts):
1467 """assign files to or remove files from a change list
1469 Assign files to or (with -d) remove files from a change list.
1471 The -d option only removes files from the change list.
1472 It does not edit them or remove them from the repository.
1474 if missing_codereview:
1475 return missing_codereview
1477 pats = tuple([pat] + list(pats))
1478 if not GoodCLName(clname):
1479 return "invalid CL name " + clname
1482 cl, err = LoadCL(ui, repo, clname, web=False)
1486 return "cannot change non-local CL " + clname
1488 files = ChangedFiles(ui, repo, pats, opts)
1491 oldfiles = Intersect(files, cl.files)
1494 ui.status("# Removing files from CL. To undo:\n")
1495 ui.status("# cd %s\n" % (repo.root))
1497 ui.status("# hg file %s %s\n" % (cl.name, f))
1498 cl.files = Sub(cl.files, oldfiles)
1501 ui.status("no such files in CL")
1505 return "no such modified files"
1507 files = Sub(files, cl.files)
1508 taken = Taken(ui, repo)
1512 if not warned and not ui.quiet:
1513 ui.status("# Taking files from other CLs. To undo:\n")
1514 ui.status("# cd %s\n" % (repo.root))
1518 ui.status("# hg file %s %s\n" % (ocl.name, f))
1519 if ocl not in dirty:
1520 ocl.files = Sub(ocl.files, files)
1522 cl.files = Add(cl.files, files)
1524 for d, _ in dirty.items():
1528 def gofmt(ui, repo, *pats, **opts):
1529 """apply gofmt to modified files
1531 Applies gofmt to the modified files in the repository that match
1534 if missing_codereview:
1535 return missing_codereview
1537 files = ChangedExistingFiles(ui, repo, pats, opts)
1538 files = [f for f in files if f.endswith(".go")]
1540 return "no modified go files"
1542 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1544 cmd = ["gofmt", "-l"]
1545 if not opts["list"]:
1547 if os.spawnvp(os.P_WAIT, "gofmt", cmd + files) != 0:
1548 raise util.Abort("gofmt did not exit cleanly")
1549 except error.Abort, e:
1552 raise util.Abort("gofmt: " + ExceptionDetail())
1555 def mail(ui, repo, *pats, **opts):
1556 """mail a change for review
1558 Uploads a patch to the code review server and then sends mail
1559 to the reviewer and CC list asking for a review.
1561 if missing_codereview:
1562 return missing_codereview
1564 cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1567 cl.Upload(ui, repo, gofmt_just_warn=True)
1569 # If no reviewer is listed, assign the review to defaultcc.
1570 # This makes sure that it appears in the
1571 # codereview.appspot.com/user/defaultcc
1572 # page, so that it doesn't get dropped on the floor.
1574 return "no reviewers listed in CL"
1575 cl.cc = Sub(cl.cc, defaultcc)
1576 cl.reviewer = defaultcc
1580 return "no changed files, not sending mail"
1584 def pending(ui, repo, *pats, **opts):
1585 """show pending changes
1587 Lists pending changes followed by a list of unassigned but modified files.
1589 if missing_codereview:
1590 return missing_codereview
1592 m = LoadAllCL(ui, repo, web=True)
1597 ui.write(cl.PendingText() + "\n")
1599 files = DefaultFiles(ui, repo, [], opts)
1601 s = "Changed files not in any CL:\n"
1603 s += "\t" + f + "\n"
1606 def reposetup(ui, repo):
1607 global original_match
1608 if original_match is None:
1609 start_status_thread()
1610 original_match = cmdutil.match
1611 cmdutil.match = ReplacementForCmdutilMatch
1612 RietveldSetup(ui, repo)
1614 def CheckContributor(ui, repo, user=None):
1615 set_status("checking CONTRIBUTORS file")
1616 user, userline = FindContributor(ui, repo, user, warn=False)
1618 raise util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1621 def FindContributor(ui, repo, user=None, warn=True):
1623 user = ui.config("ui", "username")
1625 raise util.Abort("[ui] username is not configured in .hgrc")
1627 m = re.match(r".*<(.*)>", user)
1631 if user not in contributors:
1633 ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1636 user, email = contributors[user]
1637 return email, "%s <%s>" % (user, email)
1639 def submit(ui, repo, *pats, **opts):
1640 """submit change to remote repository
1642 Submits change to remote repository.
1643 Bails out if the local repository is not in sync with the remote one.
1645 if missing_codereview:
1646 return missing_codereview
1648 # We already called this on startup but sometimes Mercurial forgets.
1649 set_mercurial_encoding_to_utf8()
1651 repo.ui.quiet = True
1652 if not opts["no_incoming"] and Incoming(ui, repo, opts):
1653 return "local repository out of date; must sync before submit"
1655 cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1661 user = cl.copied_from
1662 userline = CheckContributor(ui, repo, user)
1663 typecheck(userline, str)
1667 about += "R=" + JoinComma([CutDomain(s) for s in cl.reviewer]) + "\n"
1669 tbr = SplitCommaSpace(opts.get('tbr'))
1670 cl.reviewer = Add(cl.reviewer, tbr)
1671 about += "TBR=" + JoinComma([CutDomain(s) for s in tbr]) + "\n"
1673 about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1676 return "no reviewers listed in CL"
1679 return "cannot submit non-local CL"
1681 # upload, to sync current patch and also get change number if CL is new.
1682 if not cl.copied_from:
1683 cl.Upload(ui, repo, gofmt_just_warn=True)
1685 # check gofmt for real; allowed upload to warn in order to save CL.
1687 CheckFormat(ui, repo, cl.files)
1689 about += "%s%s\n" % (server_url_base, cl.name)
1692 about += "\nCommitter: " + CheckContributor(ui, repo, None) + "\n"
1693 typecheck(about, str)
1695 if not cl.mailed and not cl.copied_from: # in case this is TBR
1698 # submit changes locally
1699 date = opts.get('date')
1701 opts['date'] = util.parsedate(date)
1702 typecheck(opts['date'], str)
1703 opts['message'] = cl.desc.rstrip() + "\n\n" + about
1704 typecheck(opts['message'], str)
1707 print "NOT SUBMITTING:"
1708 print "User: ", userline
1710 print Indent(opts['message'], "\t")
1712 print Indent('\n'.join(cl.files), "\t")
1713 return "dry run; not submitted"
1715 m = match.exact(repo.root, repo.getcwd(), cl.files)
1716 node = repo.commit(ustr(opts['message']), ustr(userline), opts.get('date'), m)
1718 return "nothing changed"
1720 # push to remote; if it fails for any reason, roll back
1722 log = repo.changelog
1724 parents = log.parentrevs(rev)
1725 if (rev-1 not in parents and
1726 (parents == (nullrev, nullrev) or
1727 len(log.heads(log.node(parents[0]))) > 1 and
1728 (parents[1] == nullrev or len(log.heads(log.node(parents[1]))) > 1))):
1730 raise util.Abort("local repository out of date; must sync before submit")
1732 # push changes to remote.
1733 # if it works, we're committed.
1735 other = getremote(ui, repo, opts)
1736 r = repo.push(other, False, None)
1738 raise util.Abort("local repository out of date; must sync before submit")
1743 # we're committed. upload final patch, close review, add commit message
1744 changeURL = short(node)
1746 m = re.match("^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?", url)
1748 changeURL = "http://code.google.com/p/%s/source/detail?r=%s" % (m.group(2), changeURL)
1750 print >>sys.stderr, "URL: ", url
1751 pmsg = "*** Submitted as " + changeURL + " ***\n\n" + opts['message']
1753 # When posting, move reviewers to CC line,
1754 # so that the issue stops showing up in their "My Issues" page.
1755 PostMessage(ui, cl.name, pmsg, reviewers="", cc=JoinComma(cl.reviewer+cl.cc))
1757 if not cl.copied_from:
1758 EditDesc(cl.name, closed=True, private=cl.private)
1762 if c.branch() == releaseBranch and not c.modified() and not c.added() and not c.removed():
1763 ui.write("switching from %s to default branch.\n" % releaseBranch)
1764 err = hg.clean(repo, "default")
1769 def sync(ui, repo, **opts):
1770 """synchronize with remote repository
1772 Incorporates recent changes from the remote repository
1773 into the local repository.
1775 if missing_codereview:
1776 return missing_codereview
1778 if not opts["local"]:
1779 ui.status = sync_note
1781 other = getremote(ui, repo, opts)
1782 modheads = repo.pull(other)
1783 err = commands.postincoming(ui, repo, modheads, True, "tip")
1786 commands.update(ui, repo, rev="default")
1787 sync_changes(ui, repo)
1790 # we run sync (pull -u) in verbose mode to get the
1791 # list of files being updated, but that drags along
1792 # a bunch of messages we don't care about.
1794 if msg == 'resolving manifests\n':
1796 if msg == 'searching for changes\n':
1798 if msg == "couldn't find merge tool hgmerge\n":
1800 sys.stdout.write(msg)
1802 def sync_changes(ui, repo):
1803 # Look through recent change log descriptions to find
1804 # potential references to http://.*/our-CL-number.
1805 # Double-check them by looking at the Rietveld log.
1807 desc = repo[rev].description().strip()
1808 for clname in re.findall('(?m)^http://(?:[^\n]+)/([0-9]+)$', desc):
1809 if IsLocalCL(ui, repo, clname) and IsRietveldSubmitted(ui, clname, repo[rev].hex()):
1810 ui.warn("CL %s submitted as %s; closing\n" % (clname, repo[rev]))
1811 cl, err = LoadCL(ui, repo, clname, web=False)
1813 ui.warn("loading CL %s: %s\n" % (clname, err))
1815 if not cl.copied_from:
1816 EditDesc(cl.name, closed=True, private=cl.private)
1819 if hgversion < '1.4':
1820 get = util.cachefunc(lambda r: repo[r].changeset())
1821 changeiter, matchfn = cmdutil.walkchangerevs(ui, repo, [], get, {'rev': None})
1823 for st, rev, fns in changeiter:
1831 matchfn = cmdutil.match(repo, [], {'rev': None})
1834 for ctx in cmdutil.walkchangerevs(repo, matchfn, {'rev': None}, prep):
1837 # Remove files that are not modified from the CLs in which they appear.
1838 all = LoadAllCL(ui, repo, web=False)
1839 changed = ChangedFiles(ui, repo, [], {})
1840 for _, cl in all.items():
1841 extra = Sub(cl.files, changed)
1843 ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
1845 ui.warn("\t%s\n" % (f,))
1846 cl.files = Sub(cl.files, extra)
1849 if not cl.copied_from:
1850 ui.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl.name, cl.name))
1852 ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
1855 def upload(ui, repo, name, **opts):
1856 """upload diffs to the code review server
1858 Uploads the current modifications for a given change to the server.
1860 if missing_codereview:
1861 return missing_codereview
1863 repo.ui.quiet = True
1864 cl, err = LoadCL(ui, repo, name, web=True)
1868 return "cannot upload non-local change"
1870 print "%s%s\n" % (server_url_base, cl.name)
1874 ('r', 'reviewer', '', 'add reviewer'),
1875 ('', 'cc', '', 'add cc'),
1876 ('', 'tbr', '', 'add future reviewer'),
1877 ('m', 'message', '', 'change description (for new change)'),
1881 # The ^ means to show this command in the help text that
1882 # is printed when running hg with no arguments.
1886 ('d', 'delete', None, 'delete existing change list'),
1887 ('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
1888 ('i', 'stdin', None, 'read change list from standard input'),
1889 ('o', 'stdout', None, 'print change list to standard output'),
1890 ('p', 'pending', None, 'print pending summary to standard output'),
1892 "[-d | -D] [-i] [-o] change# or FILE ..."
1897 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
1898 ('', 'no_incoming', None, 'disable check for incoming changes'),
1902 # Would prefer to call this codereview-login, but then
1903 # hg help codereview prints the help for this command
1904 # instead of the help for the extension.
1918 ('d', 'delete', None, 'delete files from change list (but not repository)'),
1920 "[-d] change# FILE ..."
1925 ('l', 'list', None, 'list files that would change, but do not edit them'),
1937 ] + commands.walkopts,
1938 "[-r reviewer] [--cc cc] [change# | file ...]"
1943 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
1944 ('', 'no_incoming', None, 'disable check for incoming changes'),
1948 # TODO: release-start, release-tag, weekly-tag
1952 ('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
1953 ('n', 'dryrun', None, 'make change only locally (for testing)'),
1954 ] + commands.walkopts + commands.commitopts + commands.commitopts2,
1955 "[-r reviewer] [--cc cc] [change# | file ...]"
1960 ('', 'local', None, 'do not pull changes from remote repository')
1967 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
1968 ('', 'no_incoming', None, 'disable check for incoming changes'),
1980 #######################################################################
1981 # Wrappers around upload.py for interacting with Rietveld
1984 class FormParser(HTMLParser):
1989 HTMLParser.__init__(self)
1990 def handle_starttag(self, tag, attrs):
2000 self.map[key] = value
2001 if tag == "textarea":
2009 def handle_endtag(self, tag):
2010 if tag == "textarea" and self.curtag is not None:
2011 self.map[self.curtag] = self.curdata
2014 def handle_charref(self, name):
2015 self.handle_data(unichr(int(name)))
2016 def handle_entityref(self, name):
2017 import htmlentitydefs
2018 if name in htmlentitydefs.entitydefs:
2019 self.handle_data(htmlentitydefs.entitydefs[name])
2021 self.handle_data("&" + name + ";")
2022 def handle_data(self, data):
2023 if self.curdata is not None:
2024 self.curdata += data
2026 def JSONGet(ui, path):
2028 data = MySend(path, force_auth=False)
2029 typecheck(data, str)
2030 d = fix_json(json.loads(data))
2032 ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2036 # Clean up json parser output to match our expectations:
2037 # * all strings are UTF-8-encoded str, not unicode.
2038 # * missing fields are missing, not None,
2039 # so that d.get("foo", defaultvalue) works.
2041 if type(x) in [str, int, float, bool, type(None)]:
2043 elif type(x) is unicode:
2044 x = x.encode("utf-8")
2045 elif type(x) is list:
2046 for i in range(len(x)):
2047 x[i] = fix_json(x[i])
2048 elif type(x) is dict:
2054 x[k] = fix_json(x[k])
2058 raise util.Abort("unknown type " + str(type(x)) + " in fix_json")
2060 x = x.replace('\r\n', '\n')
2063 def IsRietveldSubmitted(ui, clname, hex):
2064 dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2067 for msg in dict.get("messages", []):
2068 text = msg.get("text", "")
2069 m = re.match('\*\*\* Submitted as [^*]*?([0-9a-f]+) \*\*\*', text)
2070 if m is not None and len(m.group(1)) >= 8 and hex.startswith(m.group(1)):
2074 def IsRietveldMailed(cl):
2075 for msg in cl.dict.get("messages", []):
2076 if msg.get("text", "").find("I'd like you to review this change") >= 0:
2080 def DownloadCL(ui, repo, clname):
2081 set_status("downloading CL " + clname)
2082 cl, err = LoadCL(ui, repo, clname, web=True)
2084 return None, None, None, "error loading CL %s: %s" % (clname, err)
2086 # Find most recent diff
2087 diffs = cl.dict.get("patchsets", [])
2089 return None, None, None, "CL has no patch sets"
2092 patchset = JSONGet(ui, "/api/" + clname + "/" + str(patchid))
2093 if patchset is None:
2094 return None, None, None, "error loading CL patchset %s/%d" % (clname, patchid)
2095 if patchset.get("patchset", 0) != patchid:
2096 return None, None, None, "malformed patchset information"
2099 msg = patchset.get("message", "").split()
2100 if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2102 diff = "/download/issue" + clname + "_" + str(patchid) + ".diff"
2104 diffdata = MySend(diff, force_auth=False)
2106 # Print warning if email is not in CONTRIBUTORS file.
2107 email = cl.dict.get("owner_email", "")
2109 return None, None, None, "cannot find owner for %s" % (clname)
2110 him = FindContributor(ui, repo, email)
2111 me = FindContributor(ui, repo, None)
2113 cl.mailed = IsRietveldMailed(cl)
2115 cl.copied_from = email
2117 return cl, vers, diffdata, ""
2119 def MySend(request_path, payload=None,
2120 content_type="application/octet-stream",
2121 timeout=None, force_auth=True,
2123 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2125 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2126 except Exception, e:
2127 if type(e) != urllib2.HTTPError or e.code != 500: # only retry on HTTP 500 error
2129 print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2131 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2133 # Like upload.py Send but only authenticates when the
2134 # redirect is to www.google.com/accounts. This keeps
2135 # unnecessary redirects from happening during testing.
2136 def MySend1(request_path, payload=None,
2137 content_type="application/octet-stream",
2138 timeout=None, force_auth=True,
2140 """Sends an RPC and returns the response.
2143 request_path: The path to send the request to, eg /api/appversion/create.
2144 payload: The body of the request, or None to send an empty request.
2145 content_type: The Content-Type header to use.
2146 timeout: timeout in seconds; default None i.e. no timeout.
2147 (Note: for large requests on OS X, the timeout doesn't work right.)
2148 kwargs: Any keyword arguments are converted into query string parameters.
2151 The response body, as a string.
2153 # TODO: Don't require authentication. Let the server say
2154 # whether it is necessary.
2157 rpc = GetRpcServer(upload_options)
2159 if not self.authenticated and force_auth:
2160 self._Authenticate()
2161 if request_path is None:
2164 old_timeout = socket.getdefaulttimeout()
2165 socket.setdefaulttimeout(timeout)
2171 url = "http://%s%s" % (self.host, request_path)
2173 url += "?" + urllib.urlencode(args)
2174 req = self._CreateRequest(url=url, data=payload)
2175 req.add_header("Content-Type", content_type)
2177 f = self.opener.open(req)
2180 # Translate \r\n into \n, because Rietveld doesn't.
2181 response = response.replace('\r\n', '\n')
2182 # who knows what urllib will give us
2183 if type(response) == unicode:
2184 response = response.encode("utf-8")
2185 typecheck(response, str)
2187 except urllib2.HTTPError, e:
2191 self._Authenticate()
2193 loc = e.info()["location"]
2194 if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2196 self._Authenticate()
2200 socket.setdefaulttimeout(old_timeout)
2204 f.feed(ustr(MySend(url))) # f.feed wants unicode
2206 # convert back to utf-8 to restore sanity
2208 for k,v in f.map.items():
2209 m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2212 def EditDesc(issue, subject=None, desc=None, reviewers=None, cc=None, closed=False, private=False):
2213 set_status("uploading change to description")
2214 form_fields = GetForm("/" + issue + "/edit")
2215 if subject is not None:
2216 form_fields['subject'] = subject
2217 if desc is not None:
2218 form_fields['description'] = desc
2219 if reviewers is not None:
2220 form_fields['reviewers'] = reviewers
2222 form_fields['cc'] = cc
2224 form_fields['closed'] = "checked"
2226 form_fields['private'] = "checked"
2227 ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2228 response = MySend("/" + issue + "/edit", body, content_type=ctype)
2230 print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2233 def PostMessage(ui, issue, message, reviewers=None, cc=None, send_mail=True, subject=None):
2234 set_status("uploading message")
2235 form_fields = GetForm("/" + issue + "/publish")
2236 if reviewers is not None:
2237 form_fields['reviewers'] = reviewers
2239 form_fields['cc'] = cc
2241 form_fields['send_mail'] = "checked"
2243 del form_fields['send_mail']
2244 if subject is not None:
2245 form_fields['subject'] = subject
2246 form_fields['message'] = message
2248 form_fields['message_only'] = '1' # Don't include draft comments
2249 if reviewers is not None or cc is not None:
2250 form_fields['message_only'] = '' # Must set '' in order to override cc/reviewer
2251 ctype = "applications/x-www-form-urlencoded"
2252 body = urllib.urlencode(form_fields)
2253 response = MySend("/" + issue + "/publish", body, content_type=ctype)
2261 def nocommit(*pats, **opts):
2262 """(disabled when using this extension)"""
2263 raise util.Abort("codereview extension enabled; use mail, upload, or submit instead of commit")
2265 def nobackout(*pats, **opts):
2266 """(disabled when using this extension)"""
2267 raise util.Abort("codereview extension enabled; use undo instead of backout")
2269 def norollback(*pats, **opts):
2270 """(disabled when using this extension)"""
2271 raise util.Abort("codereview extension enabled; use undo instead of rollback")
2273 def RietveldSetup(ui, repo):
2274 global defaultcc, upload_options, rpc, server, server_url_base, force_google_account, verbosity, contributors
2275 global missing_codereview
2277 repo_config_path = ''
2278 # Read repository-specific options from lib/codereview/codereview.cfg
2280 repo_config_path = repo.root + '/lib/codereview/codereview.cfg'
2281 f = open(repo_config_path)
2283 if line.startswith('defaultcc: '):
2284 defaultcc = SplitCommaSpace(line[10:])
2286 # If there are no options, chances are good this is not
2287 # a code review repository; stop now before we foul
2288 # things up even worse. Might also be that repo doesn't
2289 # even have a root. See issue 959.
2290 if repo_config_path == '':
2291 missing_codereview = 'codereview disabled: repository has no root'
2293 missing_codereview = 'codereview disabled: cannot open ' + repo_config_path
2296 # Should only modify repository with hg submit.
2297 # Disable the built-in Mercurial commands that might
2299 cmdutil.commit = nocommit
2300 global real_rollback
2301 real_rollback = repo.rollback
2302 repo.rollback = norollback
2303 # would install nobackout if we could; oh well
2306 f = open(repo.root + '/CONTRIBUTORS', 'r')
2308 raise util.Abort("cannot open %s: %s" % (repo.root+'/CONTRIBUTORS', ExceptionDetail()))
2310 # CONTRIBUTORS is a list of lines like:
2312 # Person <email> <alt-email>
2313 # The first email address is the one used in commit logs.
2314 if line.startswith('#'):
2316 m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
2319 email = m.group(2)[1:-1]
2320 contributors[email.lower()] = (name, email)
2321 for extra in m.group(3).split():
2322 contributors[extra[1:-1].lower()] = (name, email)
2328 x = ui.config("codereview", "server")
2332 # TODO(rsc): Take from ui.username?
2334 x = ui.config("codereview", "email")
2338 server_url_base = "http://" + server + "/"
2340 testing = ui.config("codereview", "testing")
2341 force_google_account = ui.configbool("codereview", "force_google_account", False)
2343 upload_options = opt()
2344 upload_options.email = email
2345 upload_options.host = None
2346 upload_options.verbose = 0
2347 upload_options.description = None
2348 upload_options.description_file = None
2349 upload_options.reviewers = None
2350 upload_options.cc = None
2351 upload_options.message = None
2352 upload_options.issue = None
2353 upload_options.download_base = False
2354 upload_options.revision = None
2355 upload_options.send_mail = False
2356 upload_options.vcs = None
2357 upload_options.server = server
2358 upload_options.save_cookies = True
2361 upload_options.save_cookies = False
2362 upload_options.email = "test@example.com"
2366 global releaseBranch
2367 tags = repo.branchtags().keys()
2368 if 'release-branch.r100' in tags:
2369 # NOTE(rsc): This tags.sort is going to get the wrong
2370 # answer when comparing release-branch.r99 with
2371 # release-branch.r100. If we do ten releases a year
2372 # that gives us 4 years before we have to worry about this.
2373 raise util.Abort('tags.sort needs to be fixed for release-branch.r100')
2376 if t.startswith('release-branch.'):
2379 #######################################################################
2380 # http://codereview.appspot.com/static/upload.py, heavily edited.
2382 #!/usr/bin/env python
2384 # Copyright 2007 Google Inc.
2386 # Licensed under the Apache License, Version 2.0 (the "License");
2387 # you may not use this file except in compliance with the License.
2388 # You may obtain a copy of the License at
2390 # http://www.apache.org/licenses/LICENSE-2.0
2392 # Unless required by applicable law or agreed to in writing, software
2393 # distributed under the License is distributed on an "AS IS" BASIS,
2394 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2395 # See the License for the specific language governing permissions and
2396 # limitations under the License.
2398 """Tool for uploading diffs from a version control system to the codereview app.
2400 Usage summary: upload.py [options] [-- diff_options]
2402 Diff options are passed to the diff command of the underlying system.
2404 Supported version control systems:
2409 It is important for Git/Mercurial users to specify a tree/node/branch to diff
2410 against by using the '--rev' option.
2412 # This code is derived from appcfg.py in the App Engine SDK (open source),
2413 # and from ASPN recipe #146306.
2429 # The md5 module was deprecated in Python 2.5.
2431 from hashlib import md5
2440 # The logging verbosity:
2442 # 1: Status messages.
2447 # Max size of patch or base file.
2448 MAX_UPLOAD_SIZE = 900 * 1024
2450 # whitelist for non-binary filetypes which do not start with "text/"
2451 # .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2453 'application/javascript',
2454 'application/x-javascript',
2455 'application/x-freemind'
2458 def GetEmail(prompt):
2459 """Prompts the user for their email address and returns it.
2461 The last used email address is saved to a file and offered up as a suggestion
2462 to the user. If the user presses enter without typing in anything the last
2463 used email address is used. If the user enters a new address, it is saved
2464 for next time we prompt.
2467 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2469 if os.path.exists(last_email_file_name):
2471 last_email_file = open(last_email_file_name, "r")
2472 last_email = last_email_file.readline().strip("\n")
2473 last_email_file.close()
2474 prompt += " [%s]" % last_email
2477 email = raw_input(prompt + ": ").strip()
2480 last_email_file = open(last_email_file_name, "w")
2481 last_email_file.write(email)
2482 last_email_file.close()
2490 def StatusUpdate(msg):
2491 """Print a status message to stdout.
2493 If 'verbosity' is greater than 0, print the message.
2496 msg: The string to print.
2503 """Print an error message to stderr and exit."""
2504 print >>sys.stderr, msg
2508 class ClientLoginError(urllib2.HTTPError):
2509 """Raised to indicate there was an error authenticating with ClientLogin."""
2511 def __init__(self, url, code, msg, headers, args):
2512 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
2514 self.reason = args["Error"]
2517 class AbstractRpcServer(object):
2518 """Provides a common interface for a simple RPC server."""
2520 def __init__(self, host, auth_function, host_override=None, extra_headers={}, save_cookies=False):
2521 """Creates a new HttpRpcServer.
2524 host: The host to send requests to.
2525 auth_function: A function that takes no arguments and returns an
2526 (email, password) tuple when called. Will be called if authentication
2528 host_override: The host header to send to the server (defaults to host).
2529 extra_headers: A dict of extra headers to append to every request.
2530 save_cookies: If True, save the authentication cookies to local disk.
2531 If False, use an in-memory cookiejar instead. Subclasses must
2532 implement this functionality. Defaults to False.
2535 self.host_override = host_override
2536 self.auth_function = auth_function
2537 self.authenticated = False
2538 self.extra_headers = extra_headers
2539 self.save_cookies = save_cookies
2540 self.opener = self._GetOpener()
2541 if self.host_override:
2542 logging.info("Server: %s; Host: %s", self.host, self.host_override)
2544 logging.info("Server: %s", self.host)
2546 def _GetOpener(self):
2547 """Returns an OpenerDirector for making HTTP requests.
2550 A urllib2.OpenerDirector object.
2552 raise NotImplementedError()
2554 def _CreateRequest(self, url, data=None):
2555 """Creates a new urllib request."""
2556 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
2557 req = urllib2.Request(url, data=data)
2558 if self.host_override:
2559 req.add_header("Host", self.host_override)
2560 for key, value in self.extra_headers.iteritems():
2561 req.add_header(key, value)
2564 def _GetAuthToken(self, email, password):
2565 """Uses ClientLogin to authenticate the user, returning an auth token.
2568 email: The user's email address
2569 password: The user's password
2572 ClientLoginError: If there was an error authenticating with ClientLogin.
2573 HTTPError: If there was some other form of HTTP error.
2576 The authentication token returned by ClientLogin.
2578 account_type = "GOOGLE"
2579 if self.host.endswith(".google.com") and not force_google_account:
2580 # Needed for use inside Google.
2581 account_type = "HOSTED"
2582 req = self._CreateRequest(
2583 url="https://www.google.com/accounts/ClientLogin",
2584 data=urllib.urlencode({
2588 "source": "rietveld-codereview-upload",
2589 "accountType": account_type,
2593 response = self.opener.open(req)
2594 response_body = response.read()
2595 response_dict = dict(x.split("=") for x in response_body.split("\n") if x)
2596 return response_dict["Auth"]
2597 except urllib2.HTTPError, e:
2600 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
2601 raise ClientLoginError(req.get_full_url(), e.code, e.msg, e.headers, response_dict)
2605 def _GetAuthCookie(self, auth_token):
2606 """Fetches authentication cookies for an authentication token.
2609 auth_token: The authentication token returned by ClientLogin.
2612 HTTPError: If there was an error fetching the authentication cookies.
2614 # This is a dummy value to allow us to identify when we're successful.
2615 continue_location = "http://localhost/"
2616 args = {"continue": continue_location, "auth": auth_token}
2617 req = self._CreateRequest("http://%s/_ah/login?%s" % (self.host, urllib.urlencode(args)))
2619 response = self.opener.open(req)
2620 except urllib2.HTTPError, e:
2622 if (response.code != 302 or
2623 response.info()["location"] != continue_location):
2624 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, response.headers, response.fp)
2625 self.authenticated = True
2627 def _Authenticate(self):
2628 """Authenticates the user.
2630 The authentication process works as follows:
2631 1) We get a username and password from the user
2632 2) We use ClientLogin to obtain an AUTH token for the user
2633 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2634 3) We pass the auth token to /_ah/login on the server to obtain an
2635 authentication cookie. If login was successful, it tries to redirect
2636 us to the URL we provided.
2638 If we attempt to access the upload API without first obtaining an
2639 authentication cookie, it returns a 401 response (or a 302) and
2640 directs us to authenticate ourselves with ClientLogin.
2643 credentials = self.auth_function()
2645 auth_token = self._GetAuthToken(credentials[0], credentials[1])
2646 except ClientLoginError, e:
2647 if e.reason == "BadAuthentication":
2648 print >>sys.stderr, "Invalid username or password."
2650 if e.reason == "CaptchaRequired":
2651 print >>sys.stderr, (
2653 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2654 "and verify you are a human. Then try again.")
2656 if e.reason == "NotVerified":
2657 print >>sys.stderr, "Account not verified."
2659 if e.reason == "TermsNotAgreed":
2660 print >>sys.stderr, "User has not agreed to TOS."
2662 if e.reason == "AccountDeleted":
2663 print >>sys.stderr, "The user account has been deleted."
2665 if e.reason == "AccountDisabled":
2666 print >>sys.stderr, "The user account has been disabled."
2668 if e.reason == "ServiceDisabled":
2669 print >>sys.stderr, "The user's access to the service has been disabled."
2671 if e.reason == "ServiceUnavailable":
2672 print >>sys.stderr, "The service is not available; try again later."
2675 self._GetAuthCookie(auth_token)
2678 def Send(self, request_path, payload=None,
2679 content_type="application/octet-stream",
2682 """Sends an RPC and returns the response.
2685 request_path: The path to send the request to, eg /api/appversion/create.
2686 payload: The body of the request, or None to send an empty request.
2687 content_type: The Content-Type header to use.
2688 timeout: timeout in seconds; default None i.e. no timeout.
2689 (Note: for large requests on OS X, the timeout doesn't work right.)
2690 kwargs: Any keyword arguments are converted into query string parameters.
2693 The response body, as a string.
2695 # TODO: Don't require authentication. Let the server say
2696 # whether it is necessary.
2697 if not self.authenticated:
2698 self._Authenticate()
2700 old_timeout = socket.getdefaulttimeout()
2701 socket.setdefaulttimeout(timeout)
2707 url = "http://%s%s" % (self.host, request_path)
2709 url += "?" + urllib.urlencode(args)
2710 req = self._CreateRequest(url=url, data=payload)
2711 req.add_header("Content-Type", content_type)
2713 f = self.opener.open(req)
2717 except urllib2.HTTPError, e:
2720 elif e.code == 401 or e.code == 302:
2721 self._Authenticate()
2725 socket.setdefaulttimeout(old_timeout)
2728 class HttpRpcServer(AbstractRpcServer):
2729 """Provides a simplified RPC-style interface for HTTP requests."""
2731 def _Authenticate(self):
2732 """Save the cookie jar after authentication."""
2733 super(HttpRpcServer, self)._Authenticate()
2734 if self.save_cookies:
2735 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
2736 self.cookie_jar.save()
2738 def _GetOpener(self):
2739 """Returns an OpenerDirector that supports cookies and ignores redirects.
2742 A urllib2.OpenerDirector object.
2744 opener = urllib2.OpenerDirector()
2745 opener.add_handler(urllib2.ProxyHandler())
2746 opener.add_handler(urllib2.UnknownHandler())
2747 opener.add_handler(urllib2.HTTPHandler())
2748 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
2749 opener.add_handler(urllib2.HTTPSHandler())
2750 opener.add_handler(urllib2.HTTPErrorProcessor())
2751 if self.save_cookies:
2752 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies_" + server)
2753 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
2754 if os.path.exists(self.cookie_file):
2756 self.cookie_jar.load()
2757 self.authenticated = True
2758 StatusUpdate("Loaded authentication cookies from %s" % self.cookie_file)
2759 except (cookielib.LoadError, IOError):
2760 # Failed to load cookies - just ignore them.
2763 # Create an empty cookie file with mode 600
2764 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
2766 # Always chmod the cookie file
2767 os.chmod(self.cookie_file, 0600)
2769 # Don't save cookies across runs of update.py.
2770 self.cookie_jar = cookielib.CookieJar()
2771 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
2775 def GetRpcServer(options):
2776 """Returns an instance of an AbstractRpcServer.
2779 A new AbstractRpcServer, on which RPC calls can be made.
2782 rpc_server_class = HttpRpcServer
2784 def GetUserCredentials():
2785 """Prompts the user for a username and password."""
2786 # Disable status prints so they don't obscure the password prompt.
2787 global global_status
2789 global_status = None
2791 email = options.email
2793 email = GetEmail("Email (login for uploading to %s)" % options.server)
2794 password = getpass.getpass("Password for %s: " % email)
2798 return (email, password)
2800 # If this is the dev_appserver, use fake authentication.
2801 host = (options.host or options.server).lower()
2802 if host == "localhost" or host.startswith("localhost:"):
2803 email = options.email
2805 email = "test@example.com"
2806 logging.info("Using debug user %s. Override with --email" % email)
2807 server = rpc_server_class(
2809 lambda: (email, "password"),
2810 host_override=options.host,
2811 extra_headers={"Cookie": 'dev_appserver_login="%s:False"' % email},
2812 save_cookies=options.save_cookies)
2813 # Don't try to talk to ClientLogin.
2814 server.authenticated = True
2817 return rpc_server_class(options.server, GetUserCredentials,
2818 host_override=options.host, save_cookies=options.save_cookies)
2821 def EncodeMultipartFormData(fields, files):
2822 """Encode form fields for multipart/form-data.
2825 fields: A sequence of (name, value) elements for regular form fields.
2826 files: A sequence of (name, filename, value) elements for data to be
2829 (content_type, body) ready for httplib.HTTP instance.
2832 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
2834 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
2837 for (key, value) in fields:
2839 typecheck(value, str)
2840 lines.append('--' + BOUNDARY)
2841 lines.append('Content-Disposition: form-data; name="%s"' % key)
2844 for (key, filename, value) in files:
2846 typecheck(filename, str)
2847 typecheck(value, str)
2848 lines.append('--' + BOUNDARY)
2849 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
2850 lines.append('Content-Type: %s' % GetContentType(filename))
2853 lines.append('--' + BOUNDARY + '--')
2855 body = CRLF.join(lines)
2856 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
2857 return content_type, body
2860 def GetContentType(filename):
2861 """Helper to guess the content-type from the filename."""
2862 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
2865 # Use a shell for subcommands on Windows to get a PATH search.
2866 use_shell = sys.platform.startswith("win")
2868 def RunShellWithReturnCode(command, print_output=False,
2869 universal_newlines=True, env=os.environ):
2870 """Executes a command and returns the output from stdout and the return code.
2873 command: Command to execute.
2874 print_output: If True, the output is printed to stdout.
2875 If False, both stdout and stderr are ignored.
2876 universal_newlines: Use universal_newlines flag (default: True).
2879 Tuple (output, return code)
2881 logging.info("Running %s", command)
2882 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
2883 shell=use_shell, universal_newlines=universal_newlines, env=env)
2887 line = p.stdout.readline()
2890 print line.strip("\n")
2891 output_array.append(line)
2892 output = "".join(output_array)
2894 output = p.stdout.read()
2896 errout = p.stderr.read()
2897 if print_output and errout:
2898 print >>sys.stderr, errout
2901 return output, p.returncode
2904 def RunShell(command, silent_ok=False, universal_newlines=True,
2905 print_output=False, env=os.environ):
2906 data, retcode = RunShellWithReturnCode(command, print_output, universal_newlines, env)
2908 ErrorExit("Got error status from %s:\n%s" % (command, data))
2909 if not silent_ok and not data:
2910 ErrorExit("No output from %s" % command)
2914 class VersionControlSystem(object):
2915 """Abstract base class providing an interface to the VCS."""
2917 def __init__(self, options):
2921 options: Command line options.
2923 self.options = options
2925 def GenerateDiff(self, args):
2926 """Return the current diff as a string.
2929 args: Extra arguments to pass to the diff command.
2931 raise NotImplementedError(
2932 "abstract method -- subclass %s must override" % self.__class__)
2934 def GetUnknownFiles(self):
2935 """Return a list of files unknown to the VCS."""
2936 raise NotImplementedError(
2937 "abstract method -- subclass %s must override" % self.__class__)
2939 def CheckForUnknownFiles(self):
2940 """Show an "are you sure?" prompt if there are unknown files."""
2941 unknown_files = self.GetUnknownFiles()
2943 print "The following files are not added to version control:"
2944 for line in unknown_files:
2946 prompt = "Are you sure to continue?(y/N) "
2947 answer = raw_input(prompt).strip()
2949 ErrorExit("User aborted")
2951 def GetBaseFile(self, filename):
2952 """Get the content of the upstream version of a file.
2955 A tuple (base_content, new_content, is_binary, status)
2956 base_content: The contents of the base file.
2957 new_content: For text files, this is empty. For binary files, this is
2958 the contents of the new file, since the diff output won't contain
2959 information to reconstruct the current file.
2960 is_binary: True iff the file is binary.
2961 status: The status of the file.
2964 raise NotImplementedError(
2965 "abstract method -- subclass %s must override" % self.__class__)
2968 def GetBaseFiles(self, diff):
2969 """Helper that calls GetBase file for each file in the patch.
2972 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
2973 are retrieved based on lines that start with "Index:" or
2974 "Property changes on:".
2977 for line in diff.splitlines(True):
2978 if line.startswith('Index:') or line.startswith('Property changes on:'):
2979 unused, filename = line.split(':', 1)
2980 # On Windows if a file has property changes its filename uses '\'
2982 filename = filename.strip().replace('\\', '/')
2983 files[filename] = self.GetBaseFile(filename)
2987 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
2989 """Uploads the base files (and if necessary, the current ones as well)."""
2991 def UploadFile(filename, file_id, content, is_binary, status, is_base):
2992 """Uploads a file to the server."""
2993 set_status("uploading " + filename)
2994 file_too_large = False
2999 if len(content) > MAX_UPLOAD_SIZE:
3000 print ("Not uploading the %s file for %s because it's too large." %
3002 file_too_large = True
3004 checksum = md5(content).hexdigest()
3005 if options.verbose > 0 and not file_too_large:
3006 print "Uploading %s file for %s" % (type, filename)
3007 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
3009 ("filename", filename),
3011 ("checksum", checksum),
3012 ("is_binary", str(is_binary)),
3013 ("is_current", str(not is_base)),
3016 form_fields.append(("file_too_large", "1"))
3018 form_fields.append(("user", options.email))
3019 ctype, body = EncodeMultipartFormData(form_fields, [("data", filename, content)])
3020 response_body = rpc_server.Send(url, body, content_type=ctype)
3021 if not response_body.startswith("OK"):
3022 StatusUpdate(" --> %s" % response_body)
3025 # Don't want to spawn too many threads, nor do we want to
3026 # hit Rietveld too hard, or it will start serving 500 errors.
3027 # When 8 works, it's no better than 4, and sometimes 8 is
3028 # too many for Rietveld to handle.
3029 MAX_PARALLEL_UPLOADS = 4
3031 sema = threading.BoundedSemaphore(MAX_PARALLEL_UPLOADS)
3033 finished_upload_threads = []
3035 class UploadFileThread(threading.Thread):
3036 def __init__(self, args):
3037 threading.Thread.__init__(self)
3040 UploadFile(*self.args)
3041 finished_upload_threads.append(self)
3044 def StartUploadFile(*args):
3046 while len(finished_upload_threads) > 0:
3047 t = finished_upload_threads.pop()
3048 upload_threads.remove(t)
3050 t = UploadFileThread(args)
3051 upload_threads.append(t)
3054 def WaitForUploads():
3055 for t in upload_threads:
3059 [patches.setdefault(v, k) for k, v in patch_list]
3060 for filename in patches.keys():
3061 base_content, new_content, is_binary, status = files[filename]
3062 file_id_str = patches.get(filename)
3063 if file_id_str.find("nobase") != -1:
3065 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
3066 file_id = int(file_id_str)
3067 if base_content != None:
3068 StartUploadFile(filename, file_id, base_content, is_binary, status, True)
3069 if new_content != None:
3070 StartUploadFile(filename, file_id, new_content, is_binary, status, False)
3073 def IsImage(self, filename):
3074 """Returns true if the filename has an image extension."""
3075 mimetype = mimetypes.guess_type(filename)[0]
3078 return mimetype.startswith("image/")
3080 def IsBinary(self, filename):
3081 """Returns true if the guessed mimetyped isnt't in text group."""
3082 mimetype = mimetypes.guess_type(filename)[0]
3084 return False # e.g. README, "real" binaries usually have an extension
3085 # special case for text files which don't start with text/
3086 if mimetype in TEXT_MIMETYPES:
3088 return not mimetype.startswith("text/")
3090 class FakeMercurialUI(object):
3095 def write(self, *args, **opts):
3096 self.output += ' '.join(args)
3098 use_hg_shell = False # set to True to shell out to hg always; slower
3100 class MercurialVCS(VersionControlSystem):
3101 """Implementation of the VersionControlSystem interface for Mercurial."""
3103 def __init__(self, options, ui, repo):
3104 super(MercurialVCS, self).__init__(options)
3107 # Absolute path to repository (we can be in a subdir)
3108 self.repo_dir = os.path.normpath(repo.root)
3109 # Compute the subdir
3110 cwd = os.path.normpath(os.getcwd())
3111 assert cwd.startswith(self.repo_dir)
3112 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
3113 if self.options.revision:
3114 self.base_rev = self.options.revision
3116 mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3117 if not err and mqparent != "":
3118 self.base_rev = mqparent
3120 self.base_rev = RunShell(["hg", "parents", "-q"]).split(':')[1].strip()
3121 def _GetRelPath(self, filename):
3122 """Get relative path of a file according to the current directory,
3123 given its logical path in the repo."""
3124 assert filename.startswith(self.subdir), (filename, self.subdir)
3125 return filename[len(self.subdir):].lstrip(r"\/")
3127 def GenerateDiff(self, extra_args):
3128 # If no file specified, restrict to the current subdir
3129 extra_args = extra_args or ["."]
3130 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
3131 data = RunShell(cmd, silent_ok=True)
3134 for line in data.splitlines():
3135 m = re.match("diff --git a/(\S+) b/(\S+)", line)
3137 # Modify line to make it look like as it comes from svn diff.
3138 # With this modification no changes on the server side are required
3139 # to make upload.py work with Mercurial repos.
3140 # NOTE: for proper handling of moved/copied files, we have to use
3141 # the second filename.
3142 filename = m.group(2)
3143 svndiff.append("Index: %s" % filename)
3144 svndiff.append("=" * 67)
3148 svndiff.append(line)
3150 ErrorExit("No valid patches found in output from hg diff")
3151 return "\n".join(svndiff) + "\n"
3153 def GetUnknownFiles(self):
3154 """Return a list of files unknown to the VCS."""
3156 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3159 for line in status.splitlines():
3160 st, fn = line.split(" ", 1)
3162 unknown_files.append(fn)
3163 return unknown_files
3165 def GetBaseFile(self, filename):
3166 set_status("inspecting " + filename)
3167 # "hg status" and "hg cat" both take a path relative to the current subdir
3168 # rather than to the repo root, but "hg diff" has given us the full path
3173 oldrelpath = relpath = self._GetRelPath(filename)
3174 # "hg status -C" returns two lines for moved/copied files, one otherwise
3176 out = RunShell(["hg", "status", "-C", "--rev", self.base_rev, relpath])
3178 fui = FakeMercurialUI()
3179 ret = commands.status(fui, self.repo, *[relpath], **{'rev': [self.base_rev], 'copies': True})
3181 raise util.Abort(ret)
3183 out = out.splitlines()
3184 # HACK: strip error message about missing file/directory if it isn't in
3186 if out[0].startswith('%s: ' % relpath):
3188 status, what = out[0].split(' ', 1)
3189 if len(out) > 1 and status == "A" and what == relpath:
3190 oldrelpath = out[1].strip()
3192 if ":" in self.base_rev:
3193 base_rev = self.base_rev.split(":", 1)[0]
3195 base_rev = self.base_rev
3198 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3200 base_content = str(self.repo[base_rev][oldrelpath].data())
3201 is_binary = "\0" in base_content # Mercurial's heuristic
3203 new_content = open(relpath, "rb").read()
3204 is_binary = is_binary or "\0" in new_content
3205 if is_binary and base_content and use_hg_shell:
3206 # Fetch again without converting newlines
3207 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
3208 silent_ok=True, universal_newlines=False)
3209 if not is_binary or not self.IsImage(relpath):
3211 return base_content, new_content, is_binary, status
3214 # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3215 def SplitPatch(data):
3216 """Splits a patch into separate pieces for each file.
3219 data: A string containing the output of svn diff.
3222 A list of 2-tuple (filename, text) where text is the svn diff output
3223 pertaining to filename.
3228 for line in data.splitlines(True):
3230 if line.startswith('Index:'):
3231 unused, new_filename = line.split(':', 1)
3232 new_filename = new_filename.strip()
3233 elif line.startswith('Property changes on:'):
3234 unused, temp_filename = line.split(':', 1)
3235 # When a file is modified, paths use '/' between directories, however
3236 # when a property is modified '\' is used on Windows. Make them the same
3237 # otherwise the file shows up twice.
3238 temp_filename = temp_filename.strip().replace('\\', '/')
3239 if temp_filename != filename:
3240 # File has property changes but no modifications, create a new diff.
3241 new_filename = temp_filename
3243 if filename and diff:
3244 patches.append((filename, ''.join(diff)))
3245 filename = new_filename
3248 if diff is not None:
3250 if filename and diff:
3251 patches.append((filename, ''.join(diff)))
3255 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
3256 """Uploads a separate patch for each file in the diff output.
3258 Returns a list of [patch_key, filename] for each file.
3260 patches = SplitPatch(data)
3262 for patch in patches:
3263 set_status("uploading patch for " + patch[0])
3264 if len(patch[1]) > MAX_UPLOAD_SIZE:
3265 print ("Not uploading the patch for " + patch[0] +
3266 " because the file is too large.")
3268 form_fields = [("filename", patch[0])]
3269 if not options.download_base:
3270 form_fields.append(("content_upload", "1"))
3271 files = [("data", "data.diff", patch[1])]
3272 ctype, body = EncodeMultipartFormData(form_fields, files)
3273 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
3274 print "Uploading patch for " + patch[0]
3275 response_body = rpc_server.Send(url, body, content_type=ctype)
3276 lines = response_body.splitlines()
3277 if not lines or lines[0] != "OK":
3278 StatusUpdate(" --> %s" % response_body)
3280 rv.append([lines[1], patch[0]])