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".
43 if __name__ == "__main__":
44 print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
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
61 from mercurial import commands as hg_commands
62 from mercurial import util as hg_util
64 # bind Plan 9 preferred dotfile location
65 if os.sys.platform == 'plan9':
68 n = plan9.bind(os.path.expanduser("~/lib"), os.path.expanduser("~"), plan9.MBEFORE|plan9.MCREATE)
73 codereview_disabled = None
76 server = "codereview.appspot.com"
77 server_url_base = None
79 #######################################################################
80 # Normally I would split this into multiple files, but it simplifies
81 # import path headaches to keep it all in one file. Sorry.
82 # The different parts of the file are separated by banners like this one.
84 #######################################################################
87 def RelativePath(path, cwd):
89 if path.startswith(cwd) and path[n] == '/':
94 return [l for l in l1 if l not in l2]
101 def Intersect(l1, l2):
102 return [l for l in l1 if l in l2]
104 #######################################################################
105 # RE: UNICODE STRING HANDLING
107 # Python distinguishes between the str (string of bytes)
108 # and unicode (string of code points) types. Most operations
109 # work on either one just fine, but some (like regexp matching)
110 # require unicode, and others (like write) require str.
112 # As befits the language, Python hides the distinction between
113 # unicode and str by converting between them silently, but
114 # *only* if all the bytes/code points involved are 7-bit ASCII.
115 # This means that if you're not careful, your program works
116 # fine on "hello, world" and fails on "hello, 世界". And of course,
117 # the obvious way to be careful - use static types - is unavailable.
118 # So the only way is trial and error to find where to put explicit
121 # Because more functions do implicit conversion to str (string of bytes)
122 # than do implicit conversion to unicode (string of code points),
123 # the convention in this module is to represent all text as str,
124 # converting to unicode only when calling a unicode-only function
125 # and then converting back to str as soon as possible.
129 raise hg_util.Abort("type check failed: %s has type %s != %s" % (repr(s), type(s), t))
131 # If we have to pass unicode instead of str, ustr does that conversion clearly.
134 return s.decode("utf-8")
136 # Even with those, Mercurial still sometimes turns unicode into str
137 # and then tries to use it as ascii. Change Mercurial's default.
138 def set_mercurial_encoding_to_utf8():
139 from mercurial import encoding
140 encoding.encoding = 'utf-8'
142 set_mercurial_encoding_to_utf8()
144 # Even with those we still run into problems.
145 # I tried to do things by the book but could not convince
146 # Mercurial to let me check in a change with UTF-8 in the
147 # CL description or author field, no matter how many conversions
148 # between str and unicode I inserted and despite changing the
149 # default encoding. I'm tired of this game, so set the default
150 # encoding for all of Python to 'utf-8', not 'ascii'.
151 def default_to_utf8():
153 stdout, __stdout__ = sys.stdout, sys.__stdout__
154 reload(sys) # site.py deleted setdefaultencoding; get it back
155 sys.stdout, sys.__stdout__ = stdout, __stdout__
156 sys.setdefaultencoding('utf-8')
160 #######################################################################
161 # Status printer for long-running commands
167 print >>sys.stderr, time.asctime(), s
171 class StatusThread(threading.Thread):
173 threading.Thread.__init__(self)
175 # pause a reasonable amount of time before
176 # starting to display status messages, so that
177 # most hg commands won't ever see them.
180 # now show status every 15 seconds
182 time.sleep(15 - time.time() % 15)
187 s = "(unknown status)"
188 print >>sys.stderr, time.asctime(), s
190 def start_status_thread():
192 t.setDaemon(True) # allowed to exit if t is still running
195 #######################################################################
196 # Change list parsing.
198 # Change lists are stored in .hg/codereview/cl.nnnnnn
199 # where nnnnnn is the number assigned by the code review server.
200 # Most data about a change list is stored on the code review server
201 # too: the description, reviewer, and cc list are all stored there.
202 # The only thing in the cl.nnnnnn file is the list of relevant files.
203 # Also, the existence of the cl.nnnnnn file marks this repository
204 # as the one where the change list lives.
206 emptydiff = """Index: ~rietveld~placeholder~
207 ===================================================================
208 diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
213 def __init__(self, name):
223 self.copied_from = None # None means current user
232 s += "Author: " + cl.copied_from + "\n\n"
234 s += "Private: " + str(self.private) + "\n"
235 s += "Mailed: " + str(self.mailed) + "\n"
236 s += "Description:\n"
237 s += Indent(cl.desc, "\t")
244 def EditorText(self):
249 s += "Author: " + cl.copied_from + "\n"
251 s += 'URL: ' + cl.url + ' # cannot edit\n\n'
253 s += "Private: True\n"
254 s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
255 s += "CC: " + JoinComma(cl.cc) + "\n"
257 s += "Description:\n"
259 s += "\t<enter description here>\n"
261 s += Indent(cl.desc, "\t")
263 if cl.local or cl.name == "new":
271 def PendingText(self, quick=False):
273 s = cl.name + ":" + "\n"
274 s += Indent(cl.desc, "\t")
277 s += "\tAuthor: " + cl.copied_from + "\n"
279 s += "\tReviewer: " + JoinComma(cl.reviewer) + "\n"
280 for (who, line, _) in cl.lgtm:
281 s += "\t\t" + who + ": " + line + "\n"
282 s += "\tCC: " + JoinComma(cl.cc) + "\n"
285 s += "\t\t" + f + "\n"
289 def Flush(self, ui, repo):
290 if self.name == "new":
291 self.Upload(ui, repo, gofmt_just_warn=True, creating=True)
292 dir = CodeReviewDir(ui, repo)
293 path = dir + '/cl.' + self.name
294 f = open(path+'!', "w")
295 f.write(self.DiskText())
297 if sys.platform == "win32" and os.path.isfile(path):
299 os.rename(path+'!', path)
300 if self.web and not self.copied_from:
301 EditDesc(self.name, desc=self.desc,
302 reviewers=JoinComma(self.reviewer), cc=JoinComma(self.cc),
303 private=self.private)
305 def Delete(self, ui, repo):
306 dir = CodeReviewDir(ui, repo)
307 os.unlink(dir + "/cl." + self.name)
313 if self.name != "new":
314 s = "code review %s: %s" % (self.name, s)
318 def Upload(self, ui, repo, send_mail=False, gofmt=True, gofmt_just_warn=False, creating=False, quiet=False):
319 if not self.files and not creating:
320 ui.warn("no files in change list\n")
321 if ui.configbool("codereview", "force_gofmt", True) and gofmt:
322 CheckFormat(ui, repo, self.files, just_warn=gofmt_just_warn)
323 set_status("uploading CL metadata + diffs")
326 ("content_upload", "1"),
327 ("reviewers", JoinComma(self.reviewer)),
328 ("cc", JoinComma(self.cc)),
329 ("description", self.desc),
333 if self.name != "new":
334 form_fields.append(("issue", self.name))
336 # We do not include files when creating the issue,
337 # because we want the patch sets to record the repository
338 # and base revision they are diffs against. We use the patch
339 # set message for that purpose, but there is no message with
340 # the first patch set. Instead the message gets used as the
341 # new CL's overall subject. So omit the diffs when creating
342 # and then we'll run an immediate upload.
343 # This has the effect that every CL begins with an empty "Patch set 1".
344 if self.files and not creating:
345 vcs = MercurialVCS(upload_options, ui, repo)
346 data = vcs.GenerateDiff(self.files)
347 files = vcs.GetBaseFiles(data)
348 if len(data) > MAX_UPLOAD_SIZE:
349 uploaded_diff_file = []
350 form_fields.append(("separate_patches", "1"))
352 uploaded_diff_file = [("data", "data.diff", data)]
354 uploaded_diff_file = [("data", "data.diff", emptydiff)]
356 if vcs and self.name != "new":
357 form_fields.append(("subject", "diff -r " + vcs.base_rev + " " + ui.expandpath("default")))
359 # First upload sets the subject for the CL itself.
360 form_fields.append(("subject", self.Subject()))
361 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
362 response_body = MySend("/upload", body, content_type=ctype)
365 lines = msg.splitlines()
368 patchset = lines[1].strip()
369 patches = [x.split(" ", 1) for x in lines[2:]]
371 print >>sys.stderr, "Server says there is nothing to upload (probably wrong):\n" + msg
372 if response_body.startswith("Issue updated.") and quiet:
375 ui.status(msg + "\n")
376 set_status("uploaded CL metadata + diffs")
377 if not response_body.startswith("Issue created.") and not response_body.startswith("Issue updated."):
378 raise hg_util.Abort("failed to update issue: " + response_body)
379 issue = msg[msg.rfind("/")+1:]
382 self.url = server_url_base + self.name
383 if not uploaded_diff_file:
384 set_status("uploading patches")
385 patches = UploadSeparatePatches(issue, rpc, patchset, data, upload_options)
387 set_status("uploading base files")
388 vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
390 set_status("sending mail")
391 MySend("/" + issue + "/mail", payload="")
393 set_status("flushing changes to disk")
397 def Mail(self, ui, repo):
398 pmsg = "Hello " + JoinComma(self.reviewer)
400 pmsg += " (cc: %s)" % (', '.join(self.cc),)
403 repourl = ui.expandpath("default")
405 pmsg += "I'd like you to review this change to\n" + repourl + "\n"
407 pmsg += "Please take another look.\n"
409 PostMessage(ui, self.name, pmsg, subject=self.Subject())
413 def GoodCLName(name):
415 return re.match("^[0-9]+$", name)
417 def ParseCL(text, name):
432 for line in text.split('\n'):
435 if line != '' and line[0] == '#':
437 if line == '' or line[0] == ' ' or line[0] == '\t':
438 if sname == None and line != '':
439 return None, lineno, 'text outside section'
441 sections[sname] += line + '\n'
445 s, val = line[:p].strip(), line[p+1:].strip()
449 sections[sname] += val + '\n'
451 return None, lineno, 'malformed section header'
454 sections[k] = StripCommon(sections[k]).rstrip()
457 if sections['Author']:
458 cl.copied_from = sections['Author']
459 cl.desc = sections['Description']
460 for line in sections['Files'].split('\n'):
463 line = line[0:i].rstrip()
467 cl.files.append(line)
468 cl.reviewer = SplitCommaSpace(sections['Reviewer'])
469 cl.cc = SplitCommaSpace(sections['CC'])
470 cl.url = sections['URL']
471 if sections['Mailed'] != 'False':
472 # Odd default, but avoids spurious mailings when
473 # reading old CLs that do not have a Mailed: line.
474 # CLs created with this update will always have
475 # Mailed: False on disk.
477 if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
479 if cl.desc == '<enter description here>':
483 def SplitCommaSpace(s):
488 return re.split(", *", s)
506 return ", ".join(uniq)
508 def ExceptionDetail():
509 s = str(sys.exc_info()[0])
510 if s.startswith("<type '") and s.endswith("'>"):
512 elif s.startswith("<class '") and s.endswith("'>"):
514 arg = str(sys.exc_info()[1])
519 def IsLocalCL(ui, repo, name):
520 return GoodCLName(name) and os.access(CodeReviewDir(ui, repo) + "/cl." + name, 0)
522 # Load CL from disk and/or the web.
523 def LoadCL(ui, repo, name, web=True):
525 set_status("loading CL " + name)
526 if not GoodCLName(name):
527 return None, "invalid CL name"
528 dir = CodeReviewDir(ui, repo)
529 path = dir + "cl." + name
530 if os.access(path, 0):
534 cl, lineno, err = ParseCL(text, name)
536 return None, "malformed CL data: "+err
541 set_status("getting issue metadata from web")
542 d = JSONGet(ui, "/api/" + name + "?messages=true")
545 return None, "cannot load CL %s from server" % (name,)
546 if 'owner_email' not in d or 'issue' not in d or str(d['issue']) != name:
547 return None, "malformed response loading CL data from code review server"
549 cl.reviewer = d.get('reviewers', [])
550 cl.cc = d.get('cc', [])
551 if cl.local and cl.copied_from and cl.desc:
552 # local copy of CL written by someone else
553 # and we saved a description. use that one,
554 # so that committers can edit the description
555 # before doing hg submit.
558 cl.desc = d.get('description', "")
559 cl.url = server_url_base + name
561 cl.private = d.get('private', False) != False
563 for m in d.get('messages', []):
564 if m.get('approval', False) == True or m.get('disapproval', False) == True:
565 who = re.sub('@.*', '', m.get('sender', ''))
566 text = re.sub("\n(.|\n)*", '', m.get('text', ''))
567 cl.lgtm.append((who, text, m.get('approval', False)))
569 set_status("loaded CL " + name)
572 class LoadCLThread(threading.Thread):
573 def __init__(self, ui, repo, dir, f, web):
574 threading.Thread.__init__(self)
582 cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
584 self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
588 # Load all the CLs from this repository.
589 def LoadAllCL(ui, repo, web=True):
590 dir = CodeReviewDir(ui, repo)
592 files = [f for f in os.listdir(dir) if f.startswith('cl.')]
598 t = LoadCLThread(ui, repo, dir, f, web)
601 # first request: wait in case it needs to authenticate
602 # otherwise we get lots of user/password prompts
603 # running in parallel.
616 # Find repository root. On error, ui.warn and return None
617 def RepoDir(ui, repo):
619 if not url.startswith('file:'):
620 ui.warn("repository %s is not in local file system\n" % (url,))
623 if url.endswith('/'):
628 # Find (or make) code review directory. On error, ui.warn and return None
629 def CodeReviewDir(ui, repo):
630 dir = RepoDir(ui, repo)
633 dir += '/.hg/codereview/'
634 if not os.path.isdir(dir):
638 ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
643 # Turn leading tabs into spaces, so that the common white space
644 # prefix doesn't get confused when people's editors write out
645 # some lines with spaces, some with tabs. Only a heuristic
646 # (some editors don't use 8 spaces either) but a useful one.
647 def TabsToSpaces(line):
649 while i < len(line) and line[i] == '\t':
651 return ' '*(8*i) + line[i:]
653 # Strip maximal common leading white space prefix from text
654 def StripCommon(text):
657 for line in text.split('\n'):
661 line = TabsToSpaces(line)
662 white = line[:len(line)-len(line.lstrip())]
667 for i in range(min(len(white), len(ws))+1):
668 if white[0:i] == ws[0:i]:
676 for line in text.split('\n'):
678 line = TabsToSpaces(line)
679 if line.startswith(ws):
680 line = line[len(ws):]
681 if line == '' and t == '':
684 while len(t) >= 2 and t[-2:] == '\n\n':
689 # Indent text with indent.
690 def Indent(text, indent):
692 typecheck(indent, str)
694 for line in text.split('\n'):
695 t += indent + line + '\n'
699 # Return the first line of l
702 return text.split('\n')[0]
704 _change_prolog = """# Change list.
705 # Lines beginning with # are ignored.
706 # Multi-line values should be indented.
709 desc_re = '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
711 desc_msg = '''Your CL description appears not to use the standard form.
713 The first line of your change description is conventionally a
714 one-line summary of the change, prefixed by the primary affected package,
715 and is used as the subject for code review mail; the rest of the description
720 encoding/rot13: new package
722 math: add IsInf, IsNaN
724 net: fix cname in LookupHost
726 unicode: update to Unicode 5.0.2
730 def promptyesno(ui, msg):
731 if hgversion >= "2.7":
732 return ui.promptchoice(msg + " $$ &yes $$ &no", 0) == 0
734 return ui.promptchoice(msg, ["&yes", "&no"], 0) == 0
736 def promptremove(ui, repo, f):
737 if promptyesno(ui, "hg remove %s (y/n)?" % (f,)):
738 if hg_commands.remove(ui, repo, 'path:'+f) != 0:
739 ui.warn("error removing %s" % (f,))
741 def promptadd(ui, repo, f):
742 if promptyesno(ui, "hg add %s (y/n)?" % (f,)):
743 if hg_commands.add(ui, repo, 'path:'+f) != 0:
744 ui.warn("error adding %s" % (f,))
746 def EditCL(ui, repo, cl):
747 set_status(None) # do not show status
750 s = ui.edit(s, ui.username())
752 # We can't trust Mercurial + Python not to die before making the change,
753 # so, by popular demand, just scribble the most recent CL edit into
754 # $(hg root)/last-change so that if Mercurial does die, people
755 # can look there for their work.
757 f = open(repo.root+"/last-change", "w")
763 clx, line, err = ParseCL(s, cl.name)
765 if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
766 return "change list not modified"
771 if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
773 elif re.search('<enter reason for undo>', clx.desc):
774 if promptyesno(ui, "change list description omits reason for undo\nre-edit (y/n)?"):
776 elif not re.match(desc_re, clx.desc.split('\n')[0]):
777 if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
780 # Check file list for files that need to be hg added or hg removed
781 # or simply aren't understood.
782 pats = ['path:'+f for f in clx.files]
783 changed = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
784 deleted = hg_matchPattern(ui, repo, *pats, deleted=True)
785 unknown = hg_matchPattern(ui, repo, *pats, unknown=True)
786 ignored = hg_matchPattern(ui, repo, *pats, ignored=True)
787 clean = hg_matchPattern(ui, repo, *pats, clean=True)
794 promptremove(ui, repo, f)
798 promptadd(ui, repo, f)
802 ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
805 ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
808 p = repo.root + '/' + f
809 if os.path.isfile(p):
810 ui.warn("warning: %s is a file but not known to hg\n" % (f,))
814 ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
816 ui.warn("error: %s does not exist; omitting\n" % (f,))
820 cl.reviewer = clx.reviewer
823 cl.private = clx.private
827 # For use by submit, etc. (NOT by change)
828 # Get change list number or list of files from command line.
829 # If files are given, make a new change list.
830 def CommandLineCL(ui, repo, pats, opts, op="verb", defaultcc=None):
831 if len(pats) > 0 and GoodCLName(pats[0]):
833 return None, "cannot specify change number and file names"
834 if opts.get('message'):
835 return None, "cannot use -m with existing CL"
836 cl, err = LoadCL(ui, repo, pats[0], web=True)
842 cl.files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
844 return None, "no files changed (use hg %s <number> to use existing CL)" % op
845 if opts.get('reviewer'):
846 cl.reviewer = Add(cl.reviewer, SplitCommaSpace(opts.get('reviewer')))
848 cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
850 cl.cc = Add(cl.cc, defaultcc)
852 if opts.get('message'):
853 cl.desc = opts.get('message')
855 err = EditCL(ui, repo, cl)
860 #######################################################################
861 # Change list file management
863 # Return list of changed files in repository that match pats.
864 # The patterns came from the command line, so we warn
865 # if they have no effect or cannot be understood.
866 def ChangedFiles(ui, repo, pats, taken=None):
868 # Run each pattern separately so that we can warn about
869 # patterns that didn't do anything useful.
871 for f in hg_matchPattern(ui, repo, p, unknown=True):
872 promptadd(ui, repo, f)
873 for f in hg_matchPattern(ui, repo, p, removed=True):
874 promptremove(ui, repo, f)
875 files = hg_matchPattern(ui, repo, p, modified=True, added=True, removed=True)
878 ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
880 ui.warn("warning: %s did not match any modified files\n" % (p,))
882 # Again, all at once (eliminates duplicates)
883 l = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
886 l = Sub(l, taken.keys())
889 # Return list of changed files in repository that match pats and still exist.
890 def ChangedExistingFiles(ui, repo, pats, opts):
891 l = hg_matchPattern(ui, repo, *pats, modified=True, added=True)
895 # Return list of files claimed by existing CLs
897 all = LoadAllCL(ui, repo, web=False)
899 for _, cl in all.items():
904 # Return list of changed files that are not claimed by other CLs
905 def DefaultFiles(ui, repo, pats):
906 return ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
908 #######################################################################
909 # File format checking.
911 def CheckFormat(ui, repo, files, just_warn=False):
912 set_status("running gofmt")
913 CheckGofmt(ui, repo, files, just_warn)
914 CheckTabfmt(ui, repo, files, just_warn)
916 # Check that gofmt run on the list of files does not change them
917 def CheckGofmt(ui, repo, files, just_warn):
918 files = gofmt_required(files)
922 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
923 files = [f for f in files if os.access(f, 0)]
927 cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
930 raise hg_util.Abort("gofmt: " + ExceptionDetail())
931 data = cmd.stdout.read()
932 errors = cmd.stderr.read()
934 set_status("done with gofmt")
936 ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
939 msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
941 ui.warn("warning: " + msg + "\n")
943 raise hg_util.Abort(msg)
946 # Check that *.[chys] files indent using tabs.
947 def CheckTabfmt(ui, repo, files, just_warn):
948 files = [f for f in files if f.startswith('src/') and re.search(r"\.[chys]$", f) and not re.search(r"\.tab\.[ch]$", f)]
952 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
953 files = [f for f in files if os.access(f, 0)]
957 for line in open(f, 'r'):
958 # Four leading spaces is enough to complain about,
959 # except that some Plan 9 code uses four spaces as the label indent,
961 if line.startswith(' ') and not re.match(' [A-Za-z0-9_]+:', line):
965 # ignore cannot open file, etc.
967 if len(badfiles) > 0:
968 msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
970 ui.warn("warning: " + msg + "\n")
972 raise hg_util.Abort(msg)
975 #######################################################################
976 # CONTRIBUTORS file parsing
978 contributorsCache = None
979 contributorsURL = None
981 def ReadContributors(ui, repo):
982 global contributorsCache
983 if contributorsCache is not None:
984 return contributorsCache
987 if contributorsURL is not None:
988 opening = contributorsURL
989 f = urllib2.urlopen(contributorsURL)
991 opening = repo.root + '/CONTRIBUTORS'
992 f = open(repo.root + '/CONTRIBUTORS', 'r')
994 ui.write("warning: cannot open %s: %s\n" % (opening, ExceptionDetail()))
999 # CONTRIBUTORS is a list of lines like:
1001 # Person <email> <alt-email>
1002 # The first email address is the one used in commit logs.
1003 if line.startswith('#'):
1005 m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
1008 email = m.group(2)[1:-1]
1009 contributors[email.lower()] = (name, email)
1010 for extra in m.group(3).split():
1011 contributors[extra[1:-1].lower()] = (name, email)
1013 contributorsCache = contributors
1016 def CheckContributor(ui, repo, user=None):
1017 set_status("checking CONTRIBUTORS file")
1018 user, userline = FindContributor(ui, repo, user, warn=False)
1020 raise hg_util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1023 def FindContributor(ui, repo, user=None, warn=True):
1025 user = ui.config("ui", "username")
1027 raise hg_util.Abort("[ui] username is not configured in .hgrc")
1029 m = re.match(r".*<(.*)>", user)
1033 contributors = ReadContributors(ui, repo)
1034 if user not in contributors:
1036 ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1039 user, email = contributors[user]
1040 return email, "%s <%s>" % (user, email)
1042 #######################################################################
1043 # Mercurial helper functions.
1044 # Read http://mercurial.selenic.com/wiki/MercurialApi before writing any of these.
1045 # We use the ui.pushbuffer/ui.popbuffer + hg_commands.xxx tricks for all interaction
1046 # with Mercurial. It has proved the most stable as they make changes.
1048 hgversion = hg_util.version()
1050 # We require Mercurial 1.9 and suggest Mercurial 2.1.
1051 # The details of the scmutil package changed then,
1052 # so allowing earlier versions would require extra band-aids below.
1053 # Ubuntu 11.10 ships with Mercurial 1.9.1 as the default version.
1055 hg_suggested = "2.1"
1059 The code review extension requires Mercurial """+hg_required+""" or newer.
1060 You are using Mercurial """+hgversion+""".
1062 To install a new Mercurial, visit http://mercurial.selenic.com/downloads/.
1066 You may need to clear your current Mercurial installation by running:
1068 sudo apt-get remove mercurial mercurial-common
1069 sudo rm -rf /etc/mercurial
1072 if hgversion < hg_required:
1074 if os.access("/etc/mercurial", 0):
1075 msg += linux_message
1076 raise hg_util.Abort(msg)
1078 from mercurial.hg import clean as hg_clean
1079 from mercurial import cmdutil as hg_cmdutil
1080 from mercurial import error as hg_error
1081 from mercurial import match as hg_match
1082 from mercurial import node as hg_node
1084 class uiwrap(object):
1085 def __init__(self, ui):
1088 self.oldQuiet = ui.quiet
1090 self.oldVerbose = ui.verbose
1094 ui.quiet = self.oldQuiet
1095 ui.verbose = self.oldVerbose
1096 return ui.popbuffer()
1099 if sys.platform == "win32":
1100 return path.replace('\\', '/')
1103 def hg_matchPattern(ui, repo, *pats, **opts):
1105 hg_commands.status(ui, repo, *pats, **opts)
1108 prefix = to_slash(os.path.realpath(repo.root))+'/'
1109 for line in text.split('\n'):
1113 # Given patterns, Mercurial shows relative to cwd
1114 p = to_slash(os.path.realpath(f[1]))
1115 if not p.startswith(prefix):
1116 print >>sys.stderr, "File %s not in repo root %s.\n" % (p, prefix)
1118 ret.append(p[len(prefix):])
1120 # Without patterns, Mercurial shows relative to root (what we want)
1121 ret.append(to_slash(f[1]))
1124 def hg_heads(ui, repo):
1126 hg_commands.heads(ui, repo)
1131 "resolving manifests",
1132 "searching for changes",
1133 "couldn't find merge tool hgmerge",
1134 "adding changesets",
1136 "adding file changes",
1137 "all local heads known remotely",
1147 def hg_incoming(ui, repo):
1149 ret = hg_commands.incoming(ui, repo, force=False, bundle="")
1150 if ret and ret != 1:
1151 raise hg_util.Abort(ret)
1154 def hg_log(ui, repo, **opts):
1155 for k in ['date', 'keyword', 'rev', 'user']:
1156 if not opts.has_key(k):
1159 ret = hg_commands.log(ui, repo, **opts)
1161 raise hg_util.Abort(ret)
1164 def hg_outgoing(ui, repo, **opts):
1166 ret = hg_commands.outgoing(ui, repo, **opts)
1167 if ret and ret != 1:
1168 raise hg_util.Abort(ret)
1171 def hg_pull(ui, repo, **opts):
1174 ui.verbose = True # for file list
1175 err = hg_commands.pull(ui, repo, **opts)
1176 for line in w.output().split('\n'):
1179 if line.startswith('moving '):
1180 line = 'mv ' + line[len('moving '):]
1181 if line.startswith('getting ') and line.find(' to ') >= 0:
1182 line = 'mv ' + line[len('getting '):]
1183 if line.startswith('getting '):
1184 line = '+ ' + line[len('getting '):]
1185 if line.startswith('removing '):
1186 line = '- ' + line[len('removing '):]
1187 ui.write(line + '\n')
1190 def hg_update(ui, repo, **opts):
1193 ui.verbose = True # for file list
1194 err = hg_commands.update(ui, repo, **opts)
1195 for line in w.output().split('\n'):
1198 if line.startswith('moving '):
1199 line = 'mv ' + line[len('moving '):]
1200 if line.startswith('getting ') and line.find(' to ') >= 0:
1201 line = 'mv ' + line[len('getting '):]
1202 if line.startswith('getting '):
1203 line = '+ ' + line[len('getting '):]
1204 if line.startswith('removing '):
1205 line = '- ' + line[len('removing '):]
1206 ui.write(line + '\n')
1209 def hg_push(ui, repo, **opts):
1213 err = hg_commands.push(ui, repo, **opts)
1214 for line in w.output().split('\n'):
1215 if not isNoise(line):
1216 ui.write(line + '\n')
1219 def hg_commit(ui, repo, *pats, **opts):
1220 return hg_commands.commit(ui, repo, *pats, **opts)
1222 #######################################################################
1223 # Mercurial precommit hook to disable commit except through this interface.
1227 def precommithook(ui, repo, **opts):
1228 if hgversion >= "2.1":
1229 from mercurial import phases
1230 if repo.ui.config('phases', 'new-commit') >= phases.secret:
1233 return False # False means okay.
1234 ui.write("\ncodereview extension enabled; use mail, upload, or submit instead of commit\n\n")
1237 #######################################################################
1238 # @clnumber file pattern support
1240 # We replace scmutil.match with the MatchAt wrapper to add the @clnumber pattern.
1246 def InstallMatch(ui, repo):
1254 from mercurial import scmutil
1255 match_orig = scmutil.match
1256 scmutil.match = MatchAt
1258 def MatchAt(ctx, pats=None, opts=None, globbed=False, default='relpath'):
1265 if p.startswith('@'):
1268 if clname == "default":
1269 files = DefaultFiles(match_ui, match_repo, [])
1271 if not GoodCLName(clname):
1272 raise hg_util.Abort("invalid CL name " + clname)
1273 cl, err = LoadCL(match_repo.ui, match_repo, clname, web=False)
1275 raise hg_util.Abort("loading CL " + clname + ": " + err)
1277 raise hg_util.Abort("no files in CL " + clname)
1278 files = Add(files, cl.files)
1279 pats = Sub(pats, taken) + ['path:'+f for f in files]
1281 # work-around for http://selenic.com/hg/rev/785bbc8634f8
1282 if not hasattr(ctx, 'match'):
1284 return match_orig(ctx, pats=pats, opts=opts, globbed=globbed, default=default)
1286 #######################################################################
1287 # Commands added by code review extension.
1292 #######################################################################
1296 def change(ui, repo, *pats, **opts):
1297 """create, edit or delete a change list
1299 Create, edit or delete a change list.
1300 A change list is a group of files to be reviewed and submitted together,
1301 plus a textual description of the change.
1302 Change lists are referred to by simple alphanumeric names.
1304 Changes must be reviewed before they can be submitted.
1306 In the absence of options, the change command opens the
1307 change list for editing in the default editor.
1309 Deleting a change with the -d or -D flag does not affect
1310 the contents of the files listed in that change. To revert
1311 the files listed in a change, use
1315 before running hg change -d 123456.
1318 if codereview_disabled:
1319 raise hg_util.Abort(codereview_disabled)
1322 if len(pats) > 0 and GoodCLName(pats[0]):
1325 raise hg_util.Abort("cannot specify CL name and file patterns")
1327 cl, err = LoadCL(ui, repo, name, web=True)
1329 raise hg_util.Abort(err)
1330 if not cl.local and (opts["stdin"] or not opts["stdout"]):
1331 raise hg_util.Abort("cannot change non-local CL " + name)
1335 if repo[None].branch() != "default":
1336 raise hg_util.Abort("cannot create CL outside default branch; switch with 'hg update default'")
1338 files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
1340 if opts["delete"] or opts["deletelocal"]:
1341 if opts["delete"] and opts["deletelocal"]:
1342 raise hg_util.Abort("cannot use -d and -D together")
1344 if opts["deletelocal"]:
1347 raise hg_util.Abort("cannot use "+flag+" with file patterns")
1348 if opts["stdin"] or opts["stdout"]:
1349 raise hg_util.Abort("cannot use "+flag+" with -i or -o")
1351 raise hg_util.Abort("cannot change non-local CL " + name)
1354 raise hg_util.Abort("original author must delete CL; hg change -D will remove locally")
1355 PostMessage(ui, cl.name, "*** Abandoned ***", send_mail=cl.mailed)
1356 EditDesc(cl.name, closed=True, private=cl.private)
1361 s = sys.stdin.read()
1362 clx, line, err = ParseCL(s, name)
1364 raise hg_util.Abort("error parsing change list: line %d: %s" % (line, err))
1365 if clx.desc is not None:
1368 if clx.reviewer is not None:
1369 cl.reviewer = clx.reviewer
1371 if clx.cc is not None:
1374 if clx.files is not None:
1375 cl.files = clx.files
1377 if clx.private != cl.private:
1378 cl.private = clx.private
1381 if not opts["stdin"] and not opts["stdout"]:
1384 err = EditCL(ui, repo, cl)
1386 raise hg_util.Abort(err)
1389 for d, _ in dirty.items():
1393 d.Upload(ui, repo, quiet=True)
1396 ui.write(cl.EditorText())
1397 elif opts["pending"]:
1398 ui.write(cl.PendingText())
1403 ui.write("CL created: " + cl.url + "\n")
1406 #######################################################################
1407 # hg code-login (broken?)
1410 def code_login(ui, repo, **opts):
1411 """log in to code review server
1413 Logs in to the code review server, saving a cookie in
1414 a file in your home directory.
1416 if codereview_disabled:
1417 raise hg_util.Abort(codereview_disabled)
1421 #######################################################################
1422 # hg clpatch / undo / release-apply / download
1423 # All concerned with applying or unapplying patches to the repository.
1426 def clpatch(ui, repo, clname, **opts):
1427 """import a patch from the code review server
1429 Imports a patch from the code review server into the local client.
1430 If the local client has already modified any of the files that the
1431 patch modifies, this command will refuse to apply the patch.
1433 Submitting an imported patch will keep the original author's
1434 name as the Author: line but add your own name to a Committer: line.
1436 if repo[None].branch() != "default":
1437 raise hg_util.Abort("cannot run hg clpatch outside default branch")
1438 err = clpatch_or_undo(ui, repo, clname, opts, mode="clpatch")
1440 raise hg_util.Abort(err)
1443 def undo(ui, repo, clname, **opts):
1444 """undo the effect of a CL
1446 Creates a new CL that undoes an earlier CL.
1447 After creating the CL, opens the CL text for editing so that
1448 you can add the reason for the undo to the description.
1450 if repo[None].branch() != "default":
1451 raise hg_util.Abort("cannot run hg undo outside default branch")
1452 err = clpatch_or_undo(ui, repo, clname, opts, mode="undo")
1454 raise hg_util.Abort(err)
1457 def release_apply(ui, repo, clname, **opts):
1458 """apply a CL to the release branch
1460 Creates a new CL copying a previously committed change
1461 from the main branch to the release branch.
1462 The current client must either be clean or already be in
1465 The release branch must be created by starting with a
1466 clean client, disabling the code review plugin, and running:
1468 hg update weekly.YYYY-MM-DD
1469 hg branch release-branch.rNN
1470 hg commit -m 'create release-branch.rNN'
1471 hg push --new-branch
1473 Then re-enable the code review plugin.
1475 People can test the release branch by running
1477 hg update release-branch.rNN
1479 in a clean client. To return to the normal tree,
1483 Move changes since the weekly into the release branch
1484 using hg release-apply followed by the usual code review
1485 process and hg submit.
1487 When it comes time to tag the release, record the
1488 final long-form tag of the release-branch.rNN
1489 in the *default* branch's .hgtags file. That is, run
1493 and then edit .hgtags as you would for a weekly.
1497 if not releaseBranch:
1498 raise hg_util.Abort("no active release branches")
1499 if c.branch() != releaseBranch:
1500 if c.modified() or c.added() or c.removed():
1501 raise hg_util.Abort("uncommitted local changes - cannot switch branches")
1502 err = hg_clean(repo, releaseBranch)
1504 raise hg_util.Abort(err)
1506 err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1508 raise hg_util.Abort(err)
1509 except Exception, e:
1510 hg_clean(repo, "default")
1513 def rev2clname(rev):
1514 # Extract CL name from revision description.
1515 # The last line in the description that is a codereview URL is the real one.
1516 # Earlier lines might be part of the user-written description.
1517 all = re.findall('(?m)^https?://codereview.appspot.com/([0-9]+)$', rev.description())
1522 undoHeader = """undo CL %s / %s
1524 <enter reason for undo>
1526 ««« original CL description
1533 backportHeader = """[%s] %s
1538 backportFooter = """
1542 # Implementation of clpatch/undo.
1543 def clpatch_or_undo(ui, repo, clname, opts, mode):
1544 if codereview_disabled:
1545 return codereview_disabled
1547 if mode == "undo" or mode == "backport":
1548 # Find revision in Mercurial repository.
1549 # Assume CL number is 7+ decimal digits.
1550 # Otherwise is either change log sequence number (fewer decimal digits),
1551 # hexadecimal hash, or tag name.
1552 # Mercurial will fall over long before the change log
1553 # sequence numbers get to be 7 digits long.
1554 if re.match('^[0-9]{7,}$', clname):
1556 for r in hg_log(ui, repo, keyword="codereview.appspot.com/"+clname, limit=100, template="{node}\n").split():
1558 # Last line with a code review URL is the actual review URL.
1559 # Earlier ones might be part of the CL description.
1565 return "cannot find CL %s in local repository" % clname
1569 return "unknown revision %s" % clname
1570 clname = rev2clname(rev)
1572 return "cannot find CL name in revision description"
1574 # Create fresh CL and start with patch that would reverse the change.
1575 vers = hg_node.short(rev.node())
1577 desc = str(rev.description())
1579 cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1581 cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1583 v0 = hg_node.short(rev.parents()[0].node())
1589 patch = RunShell(["hg", "diff", "--git", "-r", arg])
1592 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1595 if patch == emptydiff:
1596 return "codereview issue %s has no diff" % clname
1598 # find current hg version (hg identify)
1600 parents = ctx.parents()
1601 id = '+'.join([hg_node.short(p.node()) for p in parents])
1603 # if version does not match the patch version,
1604 # try to update the patch line numbers.
1605 if vers != "" and id != vers:
1606 # "vers in repo" gives the wrong answer
1607 # on some versions of Mercurial. Instead, do the actual
1608 # lookup and catch the exception.
1610 repo[vers].description()
1612 return "local repository is out of date; sync to get %s" % (vers)
1613 patch1, err = portPatch(repo, patch, vers, id)
1615 if not opts["ignore_hgapplydiff_failure"]:
1616 return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1619 argv = ["hgapplydiff"]
1620 if opts["no_incoming"] or mode == "backport":
1621 argv += ["--checksync=false"]
1623 cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1625 return "hgapplydiff: " + ExceptionDetail() + "\nInstall hgapplydiff with:\n$ go get code.google.com/p/go.codereview/cmd/hgapplydiff\n"
1627 out, err = cmd.communicate(patch)
1628 if cmd.returncode != 0 and not opts["ignore_hgapplydiff_failure"]:
1629 return "hgapplydiff failed"
1631 cl.files = out.strip().split()
1632 if not cl.files and not opts["ignore_hgapplydiff_failure"]:
1633 return "codereview issue %s has no changed files" % clname
1634 files = ChangedFiles(ui, repo, [])
1635 extra = Sub(cl.files, files)
1637 ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1640 err = EditCL(ui, repo, cl)
1642 return "CL created, but error editing: " + err
1645 ui.write(cl.PendingText() + "\n")
1647 # warn if clpatch will modify file already in another CL (it's unsafe to submit them)
1648 if mode == "clpatch":
1650 cls = LoadAllCL(ui, repo, web=False)
1651 for k, v in cls.iteritems():
1652 isec = Intersect(v.files, cl.files)
1653 if isec and k != clname:
1654 msgs.append("CL " + k + ", because it also modifies " + ", ".join(isec) + ".")
1656 ui.warn("warning: please double check before submitting this CL and:\n\t" + "\n\t".join(msgs) + "\n")
1658 # portPatch rewrites patch from being a patch against
1659 # oldver to being a patch against newver.
1660 def portPatch(repo, patch, oldver, newver):
1661 lines = patch.splitlines(True) # True = keep \n
1663 for i in range(len(lines)):
1665 if line.startswith('--- a/'):
1667 delta = fileDeltas(repo, file, oldver, newver)
1668 if not delta or not line.startswith('@@ '):
1670 # @@ -x,y +z,w @@ means the patch chunk replaces
1671 # the original file's line numbers x up to x+y with the
1672 # line numbers z up to z+w in the new file.
1673 # Find the delta from x in the original to the same
1674 # line in the current version and add that delta to both
1676 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1678 return None, "error parsing patch line numbers"
1679 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1680 d, err = lineDelta(delta, n1, len1)
1685 lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1687 newpatch = ''.join(lines)
1690 # fileDelta returns the line number deltas for the given file's
1691 # changes from oldver to newver.
1692 # The deltas are a list of (n, len, newdelta) triples that say
1693 # lines [n, n+len) were modified, and after that range the
1694 # line numbers are +newdelta from what they were before.
1695 def fileDeltas(repo, file, oldver, newver):
1696 cmd = ["hg", "diff", "--git", "-r", oldver + ":" + newver, "path:" + file]
1697 data = RunShell(cmd, silent_ok=True)
1699 for line in data.splitlines():
1700 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1703 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1704 deltas.append((n1, len1, n2+len2-(n1+len1)))
1707 # lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1708 # It returns an error if those lines were rewritten by the patch.
1709 def lineDelta(deltas, n, len):
1711 for (old, oldlen, newdelta) in deltas:
1715 return 0, "patch and recent changes conflict"
1720 def download(ui, repo, clname, **opts):
1721 """download a change from the code review server
1723 Download prints a description of the given change list
1724 followed by its diff, downloaded from the code review server.
1726 if codereview_disabled:
1727 raise hg_util.Abort(codereview_disabled)
1729 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1732 ui.write(cl.EditorText() + "\n")
1733 ui.write(patch + "\n")
1736 #######################################################################
1740 def file(ui, repo, clname, pat, *pats, **opts):
1741 """assign files to or remove files from a change list
1743 Assign files to or (with -d) remove files from a change list.
1745 The -d option only removes files from the change list.
1746 It does not edit them or remove them from the repository.
1748 if codereview_disabled:
1749 raise hg_util.Abort(codereview_disabled)
1751 pats = tuple([pat] + list(pats))
1752 if not GoodCLName(clname):
1753 return "invalid CL name " + clname
1756 cl, err = LoadCL(ui, repo, clname, web=False)
1760 return "cannot change non-local CL " + clname
1762 files = ChangedFiles(ui, repo, pats)
1765 oldfiles = Intersect(files, cl.files)
1768 ui.status("# Removing files from CL. To undo:\n")
1769 ui.status("# cd %s\n" % (repo.root))
1771 ui.status("# hg file %s %s\n" % (cl.name, f))
1772 cl.files = Sub(cl.files, oldfiles)
1775 ui.status("no such files in CL")
1779 return "no such modified files"
1781 files = Sub(files, cl.files)
1782 taken = Taken(ui, repo)
1786 if not warned and not ui.quiet:
1787 ui.status("# Taking files from other CLs. To undo:\n")
1788 ui.status("# cd %s\n" % (repo.root))
1792 ui.status("# hg file %s %s\n" % (ocl.name, f))
1793 if ocl not in dirty:
1794 ocl.files = Sub(ocl.files, files)
1796 cl.files = Add(cl.files, files)
1798 for d, _ in dirty.items():
1802 #######################################################################
1806 def gofmt(ui, repo, *pats, **opts):
1807 """apply gofmt to modified files
1809 Applies gofmt to the modified files in the repository that match
1812 if codereview_disabled:
1813 raise hg_util.Abort(codereview_disabled)
1815 files = ChangedExistingFiles(ui, repo, pats, opts)
1816 files = gofmt_required(files)
1818 ui.status("no modified go files\n")
1821 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1823 cmd = ["gofmt", "-l"]
1824 if not opts["list"]:
1826 if subprocess.call(cmd + files) != 0:
1827 raise hg_util.Abort("gofmt did not exit cleanly")
1828 except hg_error.Abort, e:
1831 raise hg_util.Abort("gofmt: " + ExceptionDetail())
1834 def gofmt_required(files):
1835 return [f for f in files if (not f.startswith('test/') or f.startswith('test/bench/')) and f.endswith('.go')]
1837 #######################################################################
1841 def mail(ui, repo, *pats, **opts):
1842 """mail a change for review
1844 Uploads a patch to the code review server and then sends mail
1845 to the reviewer and CC list asking for a review.
1847 if codereview_disabled:
1848 raise hg_util.Abort(codereview_disabled)
1850 cl, err = CommandLineCL(ui, repo, pats, opts, op="mail", defaultcc=defaultcc)
1852 raise hg_util.Abort(err)
1853 cl.Upload(ui, repo, gofmt_just_warn=True)
1855 # If no reviewer is listed, assign the review to defaultcc.
1856 # This makes sure that it appears in the
1857 # codereview.appspot.com/user/defaultcc
1858 # page, so that it doesn't get dropped on the floor.
1860 raise hg_util.Abort("no reviewers listed in CL")
1861 cl.cc = Sub(cl.cc, defaultcc)
1862 cl.reviewer = defaultcc
1866 raise hg_util.Abort("no changed files, not sending mail")
1870 #######################################################################
1871 # hg p / hg pq / hg ps / hg pending
1874 def ps(ui, repo, *pats, **opts):
1875 """alias for hg p --short
1877 opts['short'] = True
1878 return pending(ui, repo, *pats, **opts)
1881 def pq(ui, repo, *pats, **opts):
1882 """alias for hg p --quick
1884 opts['quick'] = True
1885 return pending(ui, repo, *pats, **opts)
1888 def pending(ui, repo, *pats, **opts):
1889 """show pending changes
1891 Lists pending changes followed by a list of unassigned but modified files.
1893 if codereview_disabled:
1894 raise hg_util.Abort(codereview_disabled)
1896 quick = opts.get('quick', False)
1897 short = opts.get('short', False)
1898 m = LoadAllCL(ui, repo, web=not quick and not short)
1904 ui.write(name + "\t" + line1(cl.desc) + "\n")
1906 ui.write(cl.PendingText(quick=quick) + "\n")
1910 files = DefaultFiles(ui, repo, [])
1912 s = "Changed files not in any CL:\n"
1914 s += "\t" + f + "\n"
1917 #######################################################################
1921 raise hg_util.Abort("local repository out of date; must sync before submit")
1924 def submit(ui, repo, *pats, **opts):
1925 """submit change to remote repository
1927 Submits change to remote repository.
1928 Bails out if the local repository is not in sync with the remote one.
1930 if codereview_disabled:
1931 raise hg_util.Abort(codereview_disabled)
1933 # We already called this on startup but sometimes Mercurial forgets.
1934 set_mercurial_encoding_to_utf8()
1936 if not opts["no_incoming"] and hg_incoming(ui, repo):
1939 cl, err = CommandLineCL(ui, repo, pats, opts, op="submit", defaultcc=defaultcc)
1941 raise hg_util.Abort(err)
1945 user = cl.copied_from
1946 userline = CheckContributor(ui, repo, user)
1947 typecheck(userline, str)
1951 if not cl.lgtm and not opts.get('tbr') and not isAddca(cl):
1952 raise hg_util.Abort("this CL has not been LGTM'ed")
1954 about += "LGTM=" + JoinComma([CutDomain(who) for (who, line, approval) in cl.lgtm if approval]) + "\n"
1955 reviewer = cl.reviewer
1957 tbr = SplitCommaSpace(opts.get('tbr'))
1959 if name.startswith('golang-'):
1960 raise hg_util.Abort("--tbr requires a person, not a mailing list")
1961 cl.reviewer = Add(cl.reviewer, tbr)
1962 about += "TBR=" + JoinComma([CutDomain(s) for s in tbr]) + "\n"
1964 about += "R=" + JoinComma([CutDomain(s) for s in reviewer]) + "\n"
1966 about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1969 raise hg_util.Abort("no reviewers listed in CL")
1972 raise hg_util.Abort("cannot submit non-local CL")
1974 # upload, to sync current patch and also get change number if CL is new.
1975 if not cl.copied_from:
1976 cl.Upload(ui, repo, gofmt_just_warn=True)
1978 # check gofmt for real; allowed upload to warn in order to save CL.
1980 CheckFormat(ui, repo, cl.files)
1982 about += "%s%s\n" % (server_url_base, cl.name)
1985 about += "\nCommitter: " + CheckContributor(ui, repo, None) + "\n"
1986 typecheck(about, str)
1988 if not cl.mailed and not cl.copied_from: # in case this is TBR
1991 # submit changes locally
1992 message = cl.desc.rstrip() + "\n\n" + about
1993 typecheck(message, str)
1995 set_status("pushing " + cl.name + " to remote server")
1997 if hg_outgoing(ui, repo):
1998 raise hg_util.Abort("local repository corrupt or out-of-phase with remote: found outgoing changes")
2000 old_heads = len(hg_heads(ui, repo).split())
2004 ret = hg_commit(ui, repo, *['path:'+f for f in cl.files], message=message, user=userline)
2007 raise hg_util.Abort("nothing changed")
2008 node = repo["-1"].node()
2009 # push to remote; if it fails for any reason, roll back
2011 new_heads = len(hg_heads(ui, repo).split())
2012 if old_heads != new_heads and not (old_heads == 0 and new_heads == 1):
2013 # Created new head, so we weren't up to date.
2016 # Push changes to remote. If it works, we're committed. If not, roll back.
2018 if hg_push(ui, repo):
2019 raise hg_util.Abort("push error")
2020 except hg_error.Abort, e:
2021 if e.message.find("push creates new heads") >= 0:
2022 # Remote repository had changes we missed.
2025 except urllib2.HTTPError, e:
2026 print >>sys.stderr, "pushing to remote server failed; do you have commit permissions?"
2032 # We're committed. Upload final patch, close review, add commit message.
2033 changeURL = hg_node.short(node)
2034 url = ui.expandpath("default")
2035 m = re.match("(^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?)" + "|" +
2036 "(^https?://([^@/]+@)?code\.google\.com/p/([^/.]+)(\.[^./]+)?/?)", url)
2038 if m.group(1): # prj.googlecode.com/hg/ case
2039 changeURL = "https://code.google.com/p/%s/source/detail?r=%s" % (m.group(3), changeURL)
2040 elif m.group(4) and m.group(7): # code.google.com/p/prj.subrepo/ case
2041 changeURL = "https://code.google.com/p/%s/source/detail?r=%s&repo=%s" % (m.group(6), changeURL, m.group(7)[1:])
2042 elif m.group(4): # code.google.com/p/prj/ case
2043 changeURL = "https://code.google.com/p/%s/source/detail?r=%s" % (m.group(6), changeURL)
2045 print >>sys.stderr, "URL: ", url
2047 print >>sys.stderr, "URL: ", url
2048 pmsg = "*** Submitted as " + changeURL + " ***\n\n" + message
2050 # When posting, move reviewers to CC line,
2051 # so that the issue stops showing up in their "My Issues" page.
2052 PostMessage(ui, cl.name, pmsg, reviewers="", cc=JoinComma(cl.reviewer+cl.cc))
2054 if not cl.copied_from:
2055 EditDesc(cl.name, closed=True, private=cl.private)
2059 if c.branch() == releaseBranch and not c.modified() and not c.added() and not c.removed():
2060 ui.write("switching from %s to default branch.\n" % releaseBranch)
2061 err = hg_clean(repo, "default")
2068 isGobot = 'gobot' in rev or 'gobot@swtch.com' in rev or 'gobot@golang.org' in rev
2069 return cl.desc.startswith('A+C:') and 'Generated by addca.' in cl.desc and isGobot
2071 #######################################################################
2075 def sync(ui, repo, **opts):
2076 """synchronize with remote repository
2078 Incorporates recent changes from the remote repository
2079 into the local repository.
2081 if codereview_disabled:
2082 raise hg_util.Abort(codereview_disabled)
2084 if not opts["local"]:
2085 # If there are incoming CLs, pull -u will do the update.
2086 # If there are no incoming CLs, do hg update to make sure
2087 # that an update always happens regardless. This is less
2088 # surprising than update depending on incoming CLs.
2089 # It is important not to do both hg pull -u and hg update
2090 # in the same command, because the hg update will end
2091 # up marking resolve conflicts from the hg pull -u as resolved,
2092 # causing files with <<< >>> markers to not show up in
2093 # hg resolve -l. Yay Mercurial.
2094 if hg_incoming(ui, repo):
2095 err = hg_pull(ui, repo, update=True)
2097 err = hg_update(ui, repo)
2100 sync_changes(ui, repo)
2102 def sync_changes(ui, repo):
2103 # Look through recent change log descriptions to find
2104 # potential references to http://.*/our-CL-number.
2105 # Double-check them by looking at the Rietveld log.
2106 for rev in hg_log(ui, repo, limit=100, template="{node}\n").split():
2107 desc = repo[rev].description().strip()
2108 for clname in re.findall('(?m)^https?://(?:[^\n]+)/([0-9]+)$', desc):
2109 if IsLocalCL(ui, repo, clname) and IsRietveldSubmitted(ui, clname, repo[rev].hex()):
2110 ui.warn("CL %s submitted as %s; closing\n" % (clname, repo[rev]))
2111 cl, err = LoadCL(ui, repo, clname, web=False)
2113 ui.warn("loading CL %s: %s\n" % (clname, err))
2115 if not cl.copied_from:
2116 EditDesc(cl.name, closed=True, private=cl.private)
2119 # Remove files that are not modified from the CLs in which they appear.
2120 all = LoadAllCL(ui, repo, web=False)
2121 changed = ChangedFiles(ui, repo, [])
2122 for cl in all.values():
2123 extra = Sub(cl.files, changed)
2125 ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
2127 ui.warn("\t%s\n" % (f,))
2128 cl.files = Sub(cl.files, extra)
2131 if not cl.copied_from:
2132 ui.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl.name, cl.name))
2134 ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
2137 #######################################################################
2141 def upload(ui, repo, name, **opts):
2142 """upload diffs to the code review server
2144 Uploads the current modifications for a given change to the server.
2146 if codereview_disabled:
2147 raise hg_util.Abort(codereview_disabled)
2149 repo.ui.quiet = True
2150 cl, err = LoadCL(ui, repo, name, web=True)
2152 raise hg_util.Abort(err)
2154 raise hg_util.Abort("cannot upload non-local change")
2156 print "%s%s\n" % (server_url_base, cl.name)
2159 #######################################################################
2160 # Table of commands, supplied to Mercurial for installation.
2163 ('r', 'reviewer', '', 'add reviewer'),
2164 ('', 'cc', '', 'add cc'),
2165 ('', 'tbr', '', 'add future reviewer'),
2166 ('m', 'message', '', 'change description (for new change)'),
2170 # The ^ means to show this command in the help text that
2171 # is printed when running hg with no arguments.
2175 ('d', 'delete', None, 'delete existing change list'),
2176 ('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
2177 ('i', 'stdin', None, 'read change list from standard input'),
2178 ('o', 'stdout', None, 'print change list to standard output'),
2179 ('p', 'pending', None, 'print pending summary to standard output'),
2181 "[-d | -D] [-i] [-o] change# or FILE ..."
2186 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2187 ('', 'no_incoming', None, 'disable check for incoming changes'),
2191 # Would prefer to call this codereview-login, but then
2192 # hg help codereview prints the help for this command
2193 # instead of the help for the extension.
2207 ('d', 'delete', None, 'delete files from change list (but not repository)'),
2209 "[-d] change# FILE ..."
2214 ('l', 'list', None, 'list files that would change, but do not edit them'),
2221 ('s', 'short', False, 'show short result form'),
2222 ('', 'quick', False, 'do not consult codereview server'),
2239 ] + hg_commands.walkopts,
2240 "[-r reviewer] [--cc cc] [change# | file ...]"
2245 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2246 ('', 'no_incoming', None, 'disable check for incoming changes'),
2250 # TODO: release-start, release-tag, weekly-tag
2254 ('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
2255 ] + hg_commands.walkopts + hg_commands.commitopts + hg_commands.commitopts2,
2256 "[-r reviewer] [--cc cc] [change# | file ...]"
2261 ('', 'local', None, 'do not pull changes from remote repository')
2268 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2269 ('', 'no_incoming', None, 'disable check for incoming changes'),
2280 #######################################################################
2281 # Mercurial extension initialization
2283 def norollback(*pats, **opts):
2284 """(disabled when using this extension)"""
2285 raise hg_util.Abort("codereview extension enabled; use undo instead of rollback")
2287 codereview_init = False
2289 def reposetup(ui, repo):
2290 global codereview_disabled
2293 # reposetup gets called both for the local repository
2294 # and also for any repository we are pulling or pushing to.
2295 # Only initialize the first time.
2296 global codereview_init
2299 codereview_init = True
2300 start_status_thread()
2302 # Read repository-specific options from lib/codereview/codereview.cfg or codereview.cfg.
2307 # Yes, repo might not have root; see issue 959.
2308 codereview_disabled = 'codereview disabled: repository has no root'
2311 repo_config_path = ''
2312 p1 = root + '/lib/codereview/codereview.cfg'
2313 p2 = root + '/codereview.cfg'
2314 if os.access(p1, os.F_OK):
2315 repo_config_path = p1
2317 repo_config_path = p2
2319 f = open(repo_config_path)
2321 if line.startswith('defaultcc:'):
2322 defaultcc = SplitCommaSpace(line[len('defaultcc:'):])
2323 if line.startswith('contributors:'):
2324 global contributorsURL
2325 contributorsURL = line[len('contributors:'):].strip()
2327 codereview_disabled = 'codereview disabled: cannot open ' + repo_config_path
2330 remote = ui.config("paths", "default", "")
2331 if remote.find("://") < 0:
2332 raise hg_util.Abort("codereview: default path '%s' is not a URL" % (remote,))
2334 InstallMatch(ui, repo)
2335 RietveldSetup(ui, repo)
2337 # Disable the Mercurial commands that might change the repository.
2338 # Only commands in this extension are supposed to do that.
2339 ui.setconfig("hooks", "precommit.codereview", precommithook)
2341 # Rollback removes an existing commit. Don't do that either.
2342 global real_rollback
2343 real_rollback = repo.rollback
2344 repo.rollback = norollback
2347 #######################################################################
2348 # Wrappers around upload.py for interacting with Rietveld
2350 from HTMLParser import HTMLParser
2353 class FormParser(HTMLParser):
2358 HTMLParser.__init__(self)
2359 def handle_starttag(self, tag, attrs):
2369 self.map[key] = value
2370 if tag == "textarea":
2378 def handle_endtag(self, tag):
2379 if tag == "textarea" and self.curtag is not None:
2380 self.map[self.curtag] = self.curdata
2383 def handle_charref(self, name):
2384 self.handle_data(unichr(int(name)))
2385 def handle_entityref(self, name):
2386 import htmlentitydefs
2387 if name in htmlentitydefs.entitydefs:
2388 self.handle_data(htmlentitydefs.entitydefs[name])
2390 self.handle_data("&" + name + ";")
2391 def handle_data(self, data):
2392 if self.curdata is not None:
2393 self.curdata += data
2395 def JSONGet(ui, path):
2397 data = MySend(path, force_auth=False)
2398 typecheck(data, str)
2399 d = fix_json(json.loads(data))
2401 ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2405 # Clean up json parser output to match our expectations:
2406 # * all strings are UTF-8-encoded str, not unicode.
2407 # * missing fields are missing, not None,
2408 # so that d.get("foo", defaultvalue) works.
2410 if type(x) in [str, int, float, bool, type(None)]:
2412 elif type(x) is unicode:
2413 x = x.encode("utf-8")
2414 elif type(x) is list:
2415 for i in range(len(x)):
2416 x[i] = fix_json(x[i])
2417 elif type(x) is dict:
2423 x[k] = fix_json(x[k])
2427 raise hg_util.Abort("unknown type " + str(type(x)) + " in fix_json")
2429 x = x.replace('\r\n', '\n')
2432 def IsRietveldSubmitted(ui, clname, hex):
2433 dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2436 for msg in dict.get("messages", []):
2437 text = msg.get("text", "")
2438 m = re.match('\*\*\* Submitted as [^*]*?r=([0-9a-f]+)[^ ]* \*\*\*', text)
2439 if m is not None and len(m.group(1)) >= 8 and hex.startswith(m.group(1)):
2443 def IsRietveldMailed(cl):
2444 for msg in cl.dict.get("messages", []):
2445 if msg.get("text", "").find("I'd like you to review this change") >= 0:
2449 def DownloadCL(ui, repo, clname):
2450 set_status("downloading CL " + clname)
2451 cl, err = LoadCL(ui, repo, clname, web=True)
2453 return None, None, None, "error loading CL %s: %s" % (clname, err)
2455 # Find most recent diff
2456 diffs = cl.dict.get("patchsets", [])
2458 return None, None, None, "CL has no patch sets"
2461 patchset = JSONGet(ui, "/api/" + clname + "/" + str(patchid))
2462 if patchset is None:
2463 return None, None, None, "error loading CL patchset %s/%d" % (clname, patchid)
2464 if patchset.get("patchset", 0) != patchid:
2465 return None, None, None, "malformed patchset information"
2468 msg = patchset.get("message", "").split()
2469 if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2471 diff = "/download/issue" + clname + "_" + str(patchid) + ".diff"
2473 diffdata = MySend(diff, force_auth=False)
2475 # Print warning if email is not in CONTRIBUTORS file.
2476 email = cl.dict.get("owner_email", "")
2478 return None, None, None, "cannot find owner for %s" % (clname)
2479 him = FindContributor(ui, repo, email)
2480 me = FindContributor(ui, repo, None)
2482 cl.mailed = IsRietveldMailed(cl)
2484 cl.copied_from = email
2486 return cl, vers, diffdata, ""
2488 def MySend(request_path, payload=None,
2489 content_type="application/octet-stream",
2490 timeout=None, force_auth=True,
2492 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2494 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2495 except Exception, e:
2496 if type(e) != urllib2.HTTPError or e.code != 500: # only retry on HTTP 500 error
2498 print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2500 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2502 # Like upload.py Send but only authenticates when the
2503 # redirect is to www.google.com/accounts. This keeps
2504 # unnecessary redirects from happening during testing.
2505 def MySend1(request_path, payload=None,
2506 content_type="application/octet-stream",
2507 timeout=None, force_auth=True,
2509 """Sends an RPC and returns the response.
2512 request_path: The path to send the request to, eg /api/appversion/create.
2513 payload: The body of the request, or None to send an empty request.
2514 content_type: The Content-Type header to use.
2515 timeout: timeout in seconds; default None i.e. no timeout.
2516 (Note: for large requests on OS X, the timeout doesn't work right.)
2517 kwargs: Any keyword arguments are converted into query string parameters.
2520 The response body, as a string.
2522 # TODO: Don't require authentication. Let the server say
2523 # whether it is necessary.
2526 rpc = GetRpcServer(upload_options)
2528 if not self.authenticated and force_auth:
2529 self._Authenticate()
2530 if request_path is None:
2533 timeout = 30 # seconds
2535 old_timeout = socket.getdefaulttimeout()
2536 socket.setdefaulttimeout(timeout)
2542 url = "https://%s%s" % (self.host, request_path)
2544 url += "?" + urllib.urlencode(args)
2545 req = self._CreateRequest(url=url, data=payload)
2546 req.add_header("Content-Type", content_type)
2548 f = self.opener.open(req)
2551 # Translate \r\n into \n, because Rietveld doesn't.
2552 response = response.replace('\r\n', '\n')
2553 # who knows what urllib will give us
2554 if type(response) == unicode:
2555 response = response.encode("utf-8")
2556 typecheck(response, str)
2558 except urllib2.HTTPError, e:
2562 self._Authenticate()
2564 loc = e.info()["location"]
2565 if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2567 self._Authenticate()
2571 socket.setdefaulttimeout(old_timeout)
2575 f.feed(ustr(MySend(url))) # f.feed wants unicode
2577 # convert back to utf-8 to restore sanity
2579 for k,v in f.map.items():
2580 m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2583 def EditDesc(issue, subject=None, desc=None, reviewers=None, cc=None, closed=False, private=False):
2584 set_status("uploading change to description")
2585 form_fields = GetForm("/" + issue + "/edit")
2586 if subject is not None:
2587 form_fields['subject'] = subject
2588 if desc is not None:
2589 form_fields['description'] = desc
2590 if reviewers is not None:
2591 form_fields['reviewers'] = reviewers
2593 form_fields['cc'] = cc
2595 form_fields['closed'] = "checked"
2597 form_fields['private'] = "checked"
2598 ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2599 response = MySend("/" + issue + "/edit", body, content_type=ctype)
2601 print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2604 def PostMessage(ui, issue, message, reviewers=None, cc=None, send_mail=True, subject=None):
2605 set_status("uploading message")
2606 form_fields = GetForm("/" + issue + "/publish")
2607 if reviewers is not None:
2608 form_fields['reviewers'] = reviewers
2610 form_fields['cc'] = cc
2612 form_fields['send_mail'] = "checked"
2614 del form_fields['send_mail']
2615 if subject is not None:
2616 form_fields['subject'] = subject
2617 form_fields['message'] = message
2619 form_fields['message_only'] = '1' # Don't include draft comments
2620 if reviewers is not None or cc is not None:
2621 form_fields['message_only'] = '' # Must set '' in order to override cc/reviewer
2622 ctype = "applications/x-www-form-urlencoded"
2623 body = urllib.urlencode(form_fields)
2624 response = MySend("/" + issue + "/publish", body, content_type=ctype)
2632 def RietveldSetup(ui, repo):
2633 global force_google_account
2636 global server_url_base
2637 global upload_options
2644 x = ui.config("codereview", "server")
2648 # TODO(rsc): Take from ui.username?
2650 x = ui.config("codereview", "email")
2654 server_url_base = "https://" + server + "/"
2656 testing = ui.config("codereview", "testing")
2657 force_google_account = ui.configbool("codereview", "force_google_account", False)
2659 upload_options = opt()
2660 upload_options.email = email
2661 upload_options.host = None
2662 upload_options.verbose = 0
2663 upload_options.description = None
2664 upload_options.description_file = None
2665 upload_options.reviewers = None
2666 upload_options.cc = None
2667 upload_options.message = None
2668 upload_options.issue = None
2669 upload_options.download_base = False
2670 upload_options.revision = None
2671 upload_options.send_mail = False
2672 upload_options.vcs = None
2673 upload_options.server = server
2674 upload_options.save_cookies = True
2677 upload_options.save_cookies = False
2678 upload_options.email = "test@example.com"
2682 global releaseBranch
2683 tags = repo.branchmap().keys()
2684 if 'release-branch.go10' in tags:
2685 # NOTE(rsc): This tags.sort is going to get the wrong
2686 # answer when comparing release-branch.go9 with
2687 # release-branch.go10. It will be a while before we care.
2688 raise hg_util.Abort('tags.sort needs to be fixed for release-branch.go10')
2691 if t.startswith('release-branch.go'):
2694 #######################################################################
2695 # http://codereview.appspot.com/static/upload.py, heavily edited.
2697 #!/usr/bin/env python
2699 # Copyright 2007 Google Inc.
2701 # Licensed under the Apache License, Version 2.0 (the "License");
2702 # you may not use this file except in compliance with the License.
2703 # You may obtain a copy of the License at
2705 # http://www.apache.org/licenses/LICENSE-2.0
2707 # Unless required by applicable law or agreed to in writing, software
2708 # distributed under the License is distributed on an "AS IS" BASIS,
2709 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2710 # See the License for the specific language governing permissions and
2711 # limitations under the License.
2713 """Tool for uploading diffs from a version control system to the codereview app.
2715 Usage summary: upload.py [options] [-- diff_options]
2717 Diff options are passed to the diff command of the underlying system.
2719 Supported version control systems:
2724 It is important for Git/Mercurial users to specify a tree/node/branch to diff
2725 against by using the '--rev' option.
2727 # This code is derived from appcfg.py in the App Engine SDK (open source),
2728 # and from ASPN recipe #146306.
2744 # The md5 module was deprecated in Python 2.5.
2746 from hashlib import md5
2755 # The logging verbosity:
2757 # 1: Status messages.
2762 # Max size of patch or base file.
2763 MAX_UPLOAD_SIZE = 900 * 1024
2765 # whitelist for non-binary filetypes which do not start with "text/"
2766 # .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2768 'application/javascript',
2769 'application/x-javascript',
2770 'application/x-freemind'
2773 def GetEmail(prompt):
2774 """Prompts the user for their email address and returns it.
2776 The last used email address is saved to a file and offered up as a suggestion
2777 to the user. If the user presses enter without typing in anything the last
2778 used email address is used. If the user enters a new address, it is saved
2779 for next time we prompt.
2782 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2784 if os.path.exists(last_email_file_name):
2786 last_email_file = open(last_email_file_name, "r")
2787 last_email = last_email_file.readline().strip("\n")
2788 last_email_file.close()
2789 prompt += " [%s]" % last_email
2792 email = raw_input(prompt + ": ").strip()
2795 last_email_file = open(last_email_file_name, "w")
2796 last_email_file.write(email)
2797 last_email_file.close()
2805 def StatusUpdate(msg):
2806 """Print a status message to stdout.
2808 If 'verbosity' is greater than 0, print the message.
2811 msg: The string to print.
2818 """Print an error message to stderr and exit."""
2819 print >>sys.stderr, msg
2823 class ClientLoginError(urllib2.HTTPError):
2824 """Raised to indicate there was an error authenticating with ClientLogin."""
2826 def __init__(self, url, code, msg, headers, args):
2827 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
2829 # .reason is now a read-only property based on .msg
2830 # this means we ignore 'msg', but that seems to work fine.
2831 self.msg = args["Error"]
2834 class AbstractRpcServer(object):
2835 """Provides a common interface for a simple RPC server."""
2837 def __init__(self, host, auth_function, host_override=None, extra_headers={}, save_cookies=False):
2838 """Creates a new HttpRpcServer.
2841 host: The host to send requests to.
2842 auth_function: A function that takes no arguments and returns an
2843 (email, password) tuple when called. Will be called if authentication
2845 host_override: The host header to send to the server (defaults to host).
2846 extra_headers: A dict of extra headers to append to every request.
2847 save_cookies: If True, save the authentication cookies to local disk.
2848 If False, use an in-memory cookiejar instead. Subclasses must
2849 implement this functionality. Defaults to False.
2852 self.host_override = host_override
2853 self.auth_function = auth_function
2854 self.authenticated = False
2855 self.extra_headers = extra_headers
2856 self.save_cookies = save_cookies
2857 self.opener = self._GetOpener()
2858 if self.host_override:
2859 logging.info("Server: %s; Host: %s", self.host, self.host_override)
2861 logging.info("Server: %s", self.host)
2863 def _GetOpener(self):
2864 """Returns an OpenerDirector for making HTTP requests.
2867 A urllib2.OpenerDirector object.
2869 raise NotImplementedError()
2871 def _CreateRequest(self, url, data=None):
2872 """Creates a new urllib request."""
2873 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
2874 req = urllib2.Request(url, data=data)
2875 if self.host_override:
2876 req.add_header("Host", self.host_override)
2877 for key, value in self.extra_headers.iteritems():
2878 req.add_header(key, value)
2881 def _GetAuthToken(self, email, password):
2882 """Uses ClientLogin to authenticate the user, returning an auth token.
2885 email: The user's email address
2886 password: The user's password
2889 ClientLoginError: If there was an error authenticating with ClientLogin.
2890 HTTPError: If there was some other form of HTTP error.
2893 The authentication token returned by ClientLogin.
2895 account_type = "GOOGLE"
2896 if self.host.endswith(".google.com") and not force_google_account:
2897 # Needed for use inside Google.
2898 account_type = "HOSTED"
2899 req = self._CreateRequest(
2900 url="https://www.google.com/accounts/ClientLogin",
2901 data=urllib.urlencode({
2905 "source": "rietveld-codereview-upload",
2906 "accountType": account_type,
2910 response = self.opener.open(req)
2911 response_body = response.read()
2912 response_dict = dict(x.split("=") for x in response_body.split("\n") if x)
2913 return response_dict["Auth"]
2914 except urllib2.HTTPError, e:
2917 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
2918 raise ClientLoginError(req.get_full_url(), e.code, e.msg, e.headers, response_dict)
2922 def _GetAuthCookie(self, auth_token):
2923 """Fetches authentication cookies for an authentication token.
2926 auth_token: The authentication token returned by ClientLogin.
2929 HTTPError: If there was an error fetching the authentication cookies.
2931 # This is a dummy value to allow us to identify when we're successful.
2932 continue_location = "http://localhost/"
2933 args = {"continue": continue_location, "auth": auth_token}
2934 req = self._CreateRequest("https://%s/_ah/login?%s" % (self.host, urllib.urlencode(args)))
2936 response = self.opener.open(req)
2937 except urllib2.HTTPError, e:
2939 if (response.code != 302 or
2940 response.info()["location"] != continue_location):
2941 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, response.headers, response.fp)
2942 self.authenticated = True
2944 def _Authenticate(self):
2945 """Authenticates the user.
2947 The authentication process works as follows:
2948 1) We get a username and password from the user
2949 2) We use ClientLogin to obtain an AUTH token for the user
2950 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2951 3) We pass the auth token to /_ah/login on the server to obtain an
2952 authentication cookie. If login was successful, it tries to redirect
2953 us to the URL we provided.
2955 If we attempt to access the upload API without first obtaining an
2956 authentication cookie, it returns a 401 response (or a 302) and
2957 directs us to authenticate ourselves with ClientLogin.
2960 credentials = self.auth_function()
2962 auth_token = self._GetAuthToken(credentials[0], credentials[1])
2963 except ClientLoginError, e:
2964 if e.msg == "BadAuthentication":
2965 print >>sys.stderr, "Invalid username or password."
2967 if e.msg == "CaptchaRequired":
2968 print >>sys.stderr, (
2970 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2971 "and verify you are a human. Then try again.")
2973 if e.msg == "NotVerified":
2974 print >>sys.stderr, "Account not verified."
2976 if e.msg == "TermsNotAgreed":
2977 print >>sys.stderr, "User has not agreed to TOS."
2979 if e.msg == "AccountDeleted":
2980 print >>sys.stderr, "The user account has been deleted."
2982 if e.msg == "AccountDisabled":
2983 print >>sys.stderr, "The user account has been disabled."
2985 if e.msg == "ServiceDisabled":
2986 print >>sys.stderr, "The user's access to the service has been disabled."
2988 if e.msg == "ServiceUnavailable":
2989 print >>sys.stderr, "The service is not available; try again later."
2992 self._GetAuthCookie(auth_token)
2995 def Send(self, request_path, payload=None,
2996 content_type="application/octet-stream",
2999 """Sends an RPC and returns the response.
3002 request_path: The path to send the request to, eg /api/appversion/create.
3003 payload: The body of the request, or None to send an empty request.
3004 content_type: The Content-Type header to use.
3005 timeout: timeout in seconds; default None i.e. no timeout.
3006 (Note: for large requests on OS X, the timeout doesn't work right.)
3007 kwargs: Any keyword arguments are converted into query string parameters.
3010 The response body, as a string.
3012 # TODO: Don't require authentication. Let the server say
3013 # whether it is necessary.
3014 if not self.authenticated:
3015 self._Authenticate()
3017 old_timeout = socket.getdefaulttimeout()
3018 socket.setdefaulttimeout(timeout)
3024 url = "https://%s%s" % (self.host, request_path)
3026 url += "?" + urllib.urlencode(args)
3027 req = self._CreateRequest(url=url, data=payload)
3028 req.add_header("Content-Type", content_type)
3030 f = self.opener.open(req)
3034 except urllib2.HTTPError, e:
3037 elif e.code == 401 or e.code == 302:
3038 self._Authenticate()
3042 socket.setdefaulttimeout(old_timeout)
3045 class HttpRpcServer(AbstractRpcServer):
3046 """Provides a simplified RPC-style interface for HTTP requests."""
3048 def _Authenticate(self):
3049 """Save the cookie jar after authentication."""
3050 super(HttpRpcServer, self)._Authenticate()
3051 if self.save_cookies:
3052 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
3053 self.cookie_jar.save()
3055 def _GetOpener(self):
3056 """Returns an OpenerDirector that supports cookies and ignores redirects.
3059 A urllib2.OpenerDirector object.
3061 opener = urllib2.OpenerDirector()
3062 opener.add_handler(urllib2.ProxyHandler())
3063 opener.add_handler(urllib2.UnknownHandler())
3064 opener.add_handler(urllib2.HTTPHandler())
3065 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
3066 opener.add_handler(urllib2.HTTPSHandler())
3067 opener.add_handler(urllib2.HTTPErrorProcessor())
3068 if self.save_cookies:
3069 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies_" + server)
3070 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
3071 if os.path.exists(self.cookie_file):
3073 self.cookie_jar.load()
3074 self.authenticated = True
3075 StatusUpdate("Loaded authentication cookies from %s" % self.cookie_file)
3076 except (cookielib.LoadError, IOError):
3077 # Failed to load cookies - just ignore them.
3080 # Create an empty cookie file with mode 600
3081 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
3083 # Always chmod the cookie file
3084 os.chmod(self.cookie_file, 0600)
3086 # Don't save cookies across runs of update.py.
3087 self.cookie_jar = cookielib.CookieJar()
3088 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
3092 def GetRpcServer(options):
3093 """Returns an instance of an AbstractRpcServer.
3096 A new AbstractRpcServer, on which RPC calls can be made.
3099 rpc_server_class = HttpRpcServer
3101 def GetUserCredentials():
3102 """Prompts the user for a username and password."""
3103 # Disable status prints so they don't obscure the password prompt.
3104 global global_status
3106 global_status = None
3108 email = options.email
3110 email = GetEmail("Email (login for uploading to %s)" % options.server)
3111 password = getpass.getpass("Password for %s: " % email)
3115 return (email, password)
3117 # If this is the dev_appserver, use fake authentication.
3118 host = (options.host or options.server).lower()
3119 if host == "localhost" or host.startswith("localhost:"):
3120 email = options.email
3122 email = "test@example.com"
3123 logging.info("Using debug user %s. Override with --email" % email)
3124 server = rpc_server_class(
3126 lambda: (email, "password"),
3127 host_override=options.host,
3128 extra_headers={"Cookie": 'dev_appserver_login="%s:False"' % email},
3129 save_cookies=options.save_cookies)
3130 # Don't try to talk to ClientLogin.
3131 server.authenticated = True
3134 return rpc_server_class(options.server, GetUserCredentials,
3135 host_override=options.host, save_cookies=options.save_cookies)
3138 def EncodeMultipartFormData(fields, files):
3139 """Encode form fields for multipart/form-data.
3142 fields: A sequence of (name, value) elements for regular form fields.
3143 files: A sequence of (name, filename, value) elements for data to be
3146 (content_type, body) ready for httplib.HTTP instance.
3149 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
3151 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
3154 for (key, value) in fields:
3156 typecheck(value, str)
3157 lines.append('--' + BOUNDARY)
3158 lines.append('Content-Disposition: form-data; name="%s"' % key)
3161 for (key, filename, value) in files:
3163 typecheck(filename, str)
3164 typecheck(value, str)
3165 lines.append('--' + BOUNDARY)
3166 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
3167 lines.append('Content-Type: %s' % GetContentType(filename))
3170 lines.append('--' + BOUNDARY + '--')
3172 body = CRLF.join(lines)
3173 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
3174 return content_type, body
3177 def GetContentType(filename):
3178 """Helper to guess the content-type from the filename."""
3179 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
3182 # Use a shell for subcommands on Windows to get a PATH search.
3183 use_shell = sys.platform.startswith("win")
3185 def RunShellWithReturnCode(command, print_output=False,
3186 universal_newlines=True, env=os.environ):
3187 """Executes a command and returns the output from stdout and the return code.
3190 command: Command to execute.
3191 print_output: If True, the output is printed to stdout.
3192 If False, both stdout and stderr are ignored.
3193 universal_newlines: Use universal_newlines flag (default: True).
3196 Tuple (output, return code)
3198 logging.info("Running %s", command)
3199 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
3200 shell=use_shell, universal_newlines=universal_newlines, env=env)
3204 line = p.stdout.readline()
3207 print line.strip("\n")
3208 output_array.append(line)
3209 output = "".join(output_array)
3211 output = p.stdout.read()
3213 errout = p.stderr.read()
3214 if print_output and errout:
3215 print >>sys.stderr, errout
3218 return output, p.returncode
3221 def RunShell(command, silent_ok=False, universal_newlines=True,
3222 print_output=False, env=os.environ):
3223 data, retcode = RunShellWithReturnCode(command, print_output, universal_newlines, env)
3225 ErrorExit("Got error status from %s:\n%s" % (command, data))
3226 if not silent_ok and not data:
3227 ErrorExit("No output from %s" % command)
3231 class VersionControlSystem(object):
3232 """Abstract base class providing an interface to the VCS."""
3234 def __init__(self, options):
3238 options: Command line options.
3240 self.options = options
3242 def GenerateDiff(self, args):
3243 """Return the current diff as a string.
3246 args: Extra arguments to pass to the diff command.
3248 raise NotImplementedError(
3249 "abstract method -- subclass %s must override" % self.__class__)
3251 def GetUnknownFiles(self):
3252 """Return a list of files unknown to the VCS."""
3253 raise NotImplementedError(
3254 "abstract method -- subclass %s must override" % self.__class__)
3256 def CheckForUnknownFiles(self):
3257 """Show an "are you sure?" prompt if there are unknown files."""
3258 unknown_files = self.GetUnknownFiles()
3260 print "The following files are not added to version control:"
3261 for line in unknown_files:
3263 prompt = "Are you sure to continue?(y/N) "
3264 answer = raw_input(prompt).strip()
3266 ErrorExit("User aborted")
3268 def GetBaseFile(self, filename):
3269 """Get the content of the upstream version of a file.
3272 A tuple (base_content, new_content, is_binary, status)
3273 base_content: The contents of the base file.
3274 new_content: For text files, this is empty. For binary files, this is
3275 the contents of the new file, since the diff output won't contain
3276 information to reconstruct the current file.
3277 is_binary: True iff the file is binary.
3278 status: The status of the file.
3281 raise NotImplementedError(
3282 "abstract method -- subclass %s must override" % self.__class__)
3285 def GetBaseFiles(self, diff):
3286 """Helper that calls GetBase file for each file in the patch.
3289 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
3290 are retrieved based on lines that start with "Index:" or
3291 "Property changes on:".
3294 for line in diff.splitlines(True):
3295 if line.startswith('Index:') or line.startswith('Property changes on:'):
3296 unused, filename = line.split(':', 1)
3297 # On Windows if a file has property changes its filename uses '\'
3299 filename = to_slash(filename.strip())
3300 files[filename] = self.GetBaseFile(filename)
3304 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
3306 """Uploads the base files (and if necessary, the current ones as well)."""
3308 def UploadFile(filename, file_id, content, is_binary, status, is_base):
3309 """Uploads a file to the server."""
3310 set_status("uploading " + filename)
3311 file_too_large = False
3316 if len(content) > MAX_UPLOAD_SIZE:
3317 print ("Not uploading the %s file for %s because it's too large." %
3319 file_too_large = True
3321 checksum = md5(content).hexdigest()
3322 if options.verbose > 0 and not file_too_large:
3323 print "Uploading %s file for %s" % (type, filename)
3324 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
3326 ("filename", filename),
3328 ("checksum", checksum),
3329 ("is_binary", str(is_binary)),
3330 ("is_current", str(not is_base)),
3333 form_fields.append(("file_too_large", "1"))
3335 form_fields.append(("user", options.email))
3336 ctype, body = EncodeMultipartFormData(form_fields, [("data", filename, content)])
3337 response_body = rpc_server.Send(url, body, content_type=ctype)
3338 if not response_body.startswith("OK"):
3339 StatusUpdate(" --> %s" % response_body)
3342 # Don't want to spawn too many threads, nor do we want to
3343 # hit Rietveld too hard, or it will start serving 500 errors.
3344 # When 8 works, it's no better than 4, and sometimes 8 is
3345 # too many for Rietveld to handle.
3346 MAX_PARALLEL_UPLOADS = 4
3348 sema = threading.BoundedSemaphore(MAX_PARALLEL_UPLOADS)
3350 finished_upload_threads = []
3352 class UploadFileThread(threading.Thread):
3353 def __init__(self, args):
3354 threading.Thread.__init__(self)
3357 UploadFile(*self.args)
3358 finished_upload_threads.append(self)
3361 def StartUploadFile(*args):
3363 while len(finished_upload_threads) > 0:
3364 t = finished_upload_threads.pop()
3365 upload_threads.remove(t)
3367 t = UploadFileThread(args)
3368 upload_threads.append(t)
3371 def WaitForUploads():
3372 for t in upload_threads:
3376 [patches.setdefault(v, k) for k, v in patch_list]
3377 for filename in patches.keys():
3378 base_content, new_content, is_binary, status = files[filename]
3379 file_id_str = patches.get(filename)
3380 if file_id_str.find("nobase") != -1:
3382 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
3383 file_id = int(file_id_str)
3384 if base_content != None:
3385 StartUploadFile(filename, file_id, base_content, is_binary, status, True)
3386 if new_content != None:
3387 StartUploadFile(filename, file_id, new_content, is_binary, status, False)
3390 def IsImage(self, filename):
3391 """Returns true if the filename has an image extension."""
3392 mimetype = mimetypes.guess_type(filename)[0]
3395 return mimetype.startswith("image/")
3397 def IsBinary(self, filename):
3398 """Returns true if the guessed mimetyped isnt't in text group."""
3399 mimetype = mimetypes.guess_type(filename)[0]
3401 return False # e.g. README, "real" binaries usually have an extension
3402 # special case for text files which don't start with text/
3403 if mimetype in TEXT_MIMETYPES:
3405 return not mimetype.startswith("text/")
3408 class FakeMercurialUI(object):
3413 def write(self, *args, **opts):
3414 self.output += ' '.join(args)
3417 def status(self, *args, **opts):
3420 def formatter(self, topic, opts):
3421 from mercurial.formatter import plainformatter
3422 return plainformatter(self, topic, opts)
3424 def readconfig(self, *args, **opts):
3426 def expandpath(self, *args, **opts):
3427 return global_ui.expandpath(*args, **opts)
3428 def configitems(self, *args, **opts):
3429 return global_ui.configitems(*args, **opts)
3430 def config(self, *args, **opts):
3431 return global_ui.config(*args, **opts)
3433 use_hg_shell = False # set to True to shell out to hg always; slower
3435 class MercurialVCS(VersionControlSystem):
3436 """Implementation of the VersionControlSystem interface for Mercurial."""
3438 def __init__(self, options, ui, repo):
3439 super(MercurialVCS, self).__init__(options)
3443 # Absolute path to repository (we can be in a subdir)
3444 self.repo_dir = os.path.normpath(repo.root)
3445 # Compute the subdir
3446 cwd = os.path.normpath(os.getcwd())
3447 assert cwd.startswith(self.repo_dir)
3448 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
3449 if self.options.revision:
3450 self.base_rev = self.options.revision
3452 mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3453 if not err and mqparent != "":
3454 self.base_rev = mqparent
3456 out = RunShell(["hg", "parents", "-q"], silent_ok=True).strip()
3458 # No revisions; use 0 to mean a repository with nothing.
3460 self.base_rev = out.split(':')[1].strip()
3461 def _GetRelPath(self, filename):
3462 """Get relative path of a file according to the current directory,
3463 given its logical path in the repo."""
3464 assert filename.startswith(self.subdir), (filename, self.subdir)
3465 return filename[len(self.subdir):].lstrip(r"\/")
3467 def GenerateDiff(self, extra_args):
3468 # If no file specified, restrict to the current subdir
3469 extra_args = extra_args or ["."]
3470 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
3471 data = RunShell(cmd, silent_ok=True)
3474 for line in data.splitlines():
3475 m = re.match("diff --git a/(\S+) b/(\S+)", line)
3477 # Modify line to make it look like as it comes from svn diff.
3478 # With this modification no changes on the server side are required
3479 # to make upload.py work with Mercurial repos.
3480 # NOTE: for proper handling of moved/copied files, we have to use
3481 # the second filename.
3482 filename = m.group(2)
3483 svndiff.append("Index: %s" % filename)
3484 svndiff.append("=" * 67)
3488 svndiff.append(line)
3490 ErrorExit("No valid patches found in output from hg diff")
3491 return "\n".join(svndiff) + "\n"
3493 def GetUnknownFiles(self):
3494 """Return a list of files unknown to the VCS."""
3496 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3499 for line in status.splitlines():
3500 st, fn = line.split(" ", 1)
3502 unknown_files.append(fn)
3503 return unknown_files
3505 def get_hg_status(self, rev, path):
3506 # We'd like to use 'hg status -C path', but that is buggy
3507 # (see http://mercurial.selenic.com/bts/issue3023).
3508 # Instead, run 'hg status -C' without a path
3509 # and skim the output for the path we want.
3510 if self.status is None:
3512 out = RunShell(["hg", "status", "-C", "--rev", rev])
3514 fui = FakeMercurialUI()
3515 ret = hg_commands.status(fui, self.repo, *[], **{'rev': [rev], 'copies': True})
3517 raise hg_util.Abort(ret)
3519 self.status = out.splitlines()
3520 for i in range(len(self.status)):
3525 line = to_slash(self.status[i])
3526 if line[2:] == path:
3527 if i+1 < len(self.status) and self.status[i+1][:2] == ' ':
3528 return self.status[i:i+2]
3529 return self.status[i:i+1]
3530 raise hg_util.Abort("no status for " + path)
3532 def GetBaseFile(self, filename):
3533 set_status("inspecting " + filename)
3534 # "hg status" and "hg cat" both take a path relative to the current subdir
3535 # rather than to the repo root, but "hg diff" has given us the full path
3540 oldrelpath = relpath = self._GetRelPath(filename)
3541 out = self.get_hg_status(self.base_rev, relpath)
3542 status, what = out[0].split(' ', 1)
3543 if len(out) > 1 and status == "A" and what == relpath:
3544 oldrelpath = out[1].strip()
3546 if ":" in self.base_rev:
3547 base_rev = self.base_rev.split(":", 1)[0]
3549 base_rev = self.base_rev
3552 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3554 base_content = str(self.repo[base_rev][oldrelpath].data())
3555 is_binary = "\0" in base_content # Mercurial's heuristic
3557 new_content = open(relpath, "rb").read()
3558 is_binary = is_binary or "\0" in new_content
3559 if is_binary and base_content and use_hg_shell:
3560 # Fetch again without converting newlines
3561 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
3562 silent_ok=True, universal_newlines=False)
3563 if not is_binary or not self.IsImage(relpath):
3565 return base_content, new_content, is_binary, status
3568 # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3569 def SplitPatch(data):
3570 """Splits a patch into separate pieces for each file.
3573 data: A string containing the output of svn diff.
3576 A list of 2-tuple (filename, text) where text is the svn diff output
3577 pertaining to filename.
3582 for line in data.splitlines(True):
3584 if line.startswith('Index:'):
3585 unused, new_filename = line.split(':', 1)
3586 new_filename = new_filename.strip()
3587 elif line.startswith('Property changes on:'):
3588 unused, temp_filename = line.split(':', 1)
3589 # When a file is modified, paths use '/' between directories, however
3590 # when a property is modified '\' is used on Windows. Make them the same
3591 # otherwise the file shows up twice.
3592 temp_filename = to_slash(temp_filename.strip())
3593 if temp_filename != filename:
3594 # File has property changes but no modifications, create a new diff.
3595 new_filename = temp_filename
3597 if filename and diff:
3598 patches.append((filename, ''.join(diff)))
3599 filename = new_filename
3602 if diff is not None:
3604 if filename and diff:
3605 patches.append((filename, ''.join(diff)))
3609 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
3610 """Uploads a separate patch for each file in the diff output.
3612 Returns a list of [patch_key, filename] for each file.
3614 patches = SplitPatch(data)
3616 for patch in patches:
3617 set_status("uploading patch for " + patch[0])
3618 if len(patch[1]) > MAX_UPLOAD_SIZE:
3619 print ("Not uploading the patch for " + patch[0] +
3620 " because the file is too large.")
3622 form_fields = [("filename", patch[0])]
3623 if not options.download_base:
3624 form_fields.append(("content_upload", "1"))
3625 files = [("data", "data.diff", patch[1])]
3626 ctype, body = EncodeMultipartFormData(form_fields, files)
3627 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
3628 print "Uploading patch for " + patch[0]
3629 response_body = rpc_server.Send(url, body, content_type=ctype)
3630 lines = response_body.splitlines()
3631 if not lines or lines[0] != "OK":
3632 StatusUpdate(" --> %s" % response_body)
3634 rv.append([lines[1], patch[0]])