Blob


1 # coding=utf-8
2 # (The line above is necessary so that I can use 世界 in the
3 # *comment* below without Python getting all bent out of shape.)
5 # Copyright 2007-2009 Google Inc.
6 #
7 # Licensed under the Apache License, Version 2.0 (the "License");
8 # you may not use this file except in compliance with the License.
9 # You may obtain a copy of the License at
10 #
11 # http://www.apache.org/licenses/LICENSE-2.0
12 #
13 # Unless required by applicable law or agreed to in writing, software
14 # distributed under the License is distributed on an "AS IS" BASIS,
15 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 # See the License for the specific language governing permissions and
17 # limitations under the License.
19 '''Mercurial interface to codereview.appspot.com.
21 To configure, set the following options in
22 your repository's .hg/hgrc file.
24 [extensions]
25 codereview = /path/to/codereview.py
27 [codereview]
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".
39 '''
41 import sys
43 if __name__ == "__main__":
44 print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
45 sys.exit(2)
47 # We require Python 2.6 for the json package.
48 if sys.version < '2.6':
49 print >>sys.stderr, "The codereview extension requires Python 2.6 or newer."
50 print >>sys.stderr, "You are running Python " + sys.version
51 sys.exit(2)
53 import json
54 import os
55 import re
56 import stat
57 import subprocess
58 import threading
59 import time
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':
66 try:
67 import plan9
68 n = plan9.bind(os.path.expanduser("~/lib"), os.path.expanduser("~"), plan9.MBEFORE|plan9.MCREATE)
69 except ImportError:
70 pass
72 defaultcc = None
73 codereview_disabled = None
74 real_rollback = None
75 releaseBranch = 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 #######################################################################
85 # Helpers
87 def RelativePath(path, cwd):
88 n = len(cwd)
89 if path.startswith(cwd) and path[n] == '/':
90 return path[n+1:]
91 return path
93 def Sub(l1, l2):
94 return [l for l in l1 if l not in l2]
96 def Add(l1, l2):
97 l = l1 + Sub(l2, l1)
98 l.sort()
99 return l
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
119 # conversions.
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.
127 def typecheck(s, t):
128 if type(s) != t:
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.
132 def ustr(s):
133 typecheck(s, str)
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():
152 import sys
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')
158 default_to_utf8()
160 #######################################################################
161 # Status printer for long-running commands
163 global_status = None
165 def set_status(s):
166 if verbosity > 0:
167 print >>sys.stderr, time.asctime(), s
168 global global_status
169 global_status = s
171 class StatusThread(threading.Thread):
172 def __init__(self):
173 threading.Thread.__init__(self)
174 def run(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.
178 time.sleep(30)
180 # now show status every 15 seconds
181 while True:
182 time.sleep(15 - time.time() % 15)
183 s = global_status
184 if s is None:
185 continue
186 if s == "":
187 s = "(unknown status)"
188 print >>sys.stderr, time.asctime(), s
190 def start_status_thread():
191 t = StatusThread()
192 t.setDaemon(True) # allowed to exit if t is still running
193 t.start()
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~
209 new file mode 100644
210 """
212 class CL(object):
213 def __init__(self, name):
214 typecheck(name, str)
215 self.name = name
216 self.desc = ''
217 self.files = []
218 self.reviewer = []
219 self.cc = []
220 self.url = ''
221 self.local = False
222 self.web = False
223 self.copied_from = None # None means current user
224 self.mailed = False
225 self.private = False
226 self.lgtm = []
228 def DiskText(self):
229 cl = self
230 s = ""
231 if cl.copied_from:
232 s += "Author: " + cl.copied_from + "\n\n"
233 if cl.private:
234 s += "Private: " + str(self.private) + "\n"
235 s += "Mailed: " + str(self.mailed) + "\n"
236 s += "Description:\n"
237 s += Indent(cl.desc, "\t")
238 s += "Files:\n"
239 for f in cl.files:
240 s += "\t" + f + "\n"
241 typecheck(s, str)
242 return s
244 def EditorText(self):
245 cl = self
246 s = _change_prolog
247 s += "\n"
248 if cl.copied_from:
249 s += "Author: " + cl.copied_from + "\n"
250 if cl.url != '':
251 s += 'URL: ' + cl.url + ' # cannot edit\n\n'
252 if cl.private:
253 s += "Private: True\n"
254 s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
255 s += "CC: " + JoinComma(cl.cc) + "\n"
256 s += "\n"
257 s += "Description:\n"
258 if cl.desc == '':
259 s += "\t<enter description here>\n"
260 else:
261 s += Indent(cl.desc, "\t")
262 s += "\n"
263 if cl.local or cl.name == "new":
264 s += "Files:\n"
265 for f in cl.files:
266 s += "\t" + f + "\n"
267 s += "\n"
268 typecheck(s, str)
269 return s
271 def PendingText(self, quick=False):
272 cl = self
273 s = cl.name + ":" + "\n"
274 s += Indent(cl.desc, "\t")
275 s += "\n"
276 if cl.copied_from:
277 s += "\tAuthor: " + cl.copied_from + "\n"
278 if not quick:
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"
283 s += "\tFiles:\n"
284 for f in cl.files:
285 s += "\t\t" + f + "\n"
286 typecheck(s, str)
287 return s
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())
296 f.close()
297 if sys.platform == "win32" and os.path.isfile(path):
298 os.remove(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)
309 def Subject(self):
310 s = line1(self.desc)
311 if len(s) > 60:
312 s = s[0:55] + "..."
313 if self.name != "new":
314 s = "code review %s: %s" % (self.name, s)
315 typecheck(s, str)
316 return 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")
324 os.chdir(repo.root)
325 form_fields = [
326 ("content_upload", "1"),
327 ("reviewers", JoinComma(self.reviewer)),
328 ("cc", JoinComma(self.cc)),
329 ("description", self.desc),
330 ("base_hashes", ""),
333 if self.name != "new":
334 form_fields.append(("issue", self.name))
335 vcs = None
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"))
351 else:
352 uploaded_diff_file = [("data", "data.diff", data)]
353 else:
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")))
358 else:
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)
363 patchset = None
364 msg = response_body
365 lines = msg.splitlines()
366 if len(lines) >= 2:
367 msg = lines[0]
368 patchset = lines[1].strip()
369 patches = [x.split(" ", 1) for x in lines[2:]]
370 else:
371 print >>sys.stderr, "Server says there is nothing to upload (probably wrong):\n" + msg
372 if response_body.startswith("Issue updated.") and quiet:
373 pass
374 else:
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:]
380 self.name = issue
381 if not self.url:
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)
386 if vcs:
387 set_status("uploading base files")
388 vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
389 if send_mail:
390 set_status("sending mail")
391 MySend("/" + issue + "/mail", payload="")
392 self.web = True
393 set_status("flushing changes to disk")
394 self.Flush(ui, repo)
395 return
397 def Mail(self, ui, repo):
398 pmsg = "Hello " + JoinComma(self.reviewer)
399 if self.cc:
400 pmsg += " (cc: %s)" % (', '.join(self.cc),)
401 pmsg += ",\n"
402 pmsg += "\n"
403 repourl = ui.expandpath("default")
404 if not self.mailed:
405 pmsg += "I'd like you to review this change to\n" + repourl + "\n"
406 else:
407 pmsg += "Please take another look.\n"
408 typecheck(pmsg, str)
409 PostMessage(ui, self.name, pmsg, subject=self.Subject())
410 self.mailed = True
411 self.Flush(ui, repo)
413 def GoodCLName(name):
414 typecheck(name, str)
415 return re.match("^[0-9]+$", name)
417 def ParseCL(text, name):
418 typecheck(text, str)
419 typecheck(name, str)
420 sname = None
421 lineno = 0
422 sections = {
423 'Author': '',
424 'Description': '',
425 'Files': '',
426 'URL': '',
427 'Reviewer': '',
428 'CC': '',
429 'Mailed': '',
430 'Private': '',
432 for line in text.split('\n'):
433 lineno += 1
434 line = line.rstrip()
435 if line != '' and line[0] == '#':
436 continue
437 if line == '' or line[0] == ' ' or line[0] == '\t':
438 if sname == None and line != '':
439 return None, lineno, 'text outside section'
440 if sname != None:
441 sections[sname] += line + '\n'
442 continue
443 p = line.find(':')
444 if p >= 0:
445 s, val = line[:p].strip(), line[p+1:].strip()
446 if s in sections:
447 sname = s
448 if val != '':
449 sections[sname] += val + '\n'
450 continue
451 return None, lineno, 'malformed section header'
453 for k in sections:
454 sections[k] = StripCommon(sections[k]).rstrip()
456 cl = CL(name)
457 if sections['Author']:
458 cl.copied_from = sections['Author']
459 cl.desc = sections['Description']
460 for line in sections['Files'].split('\n'):
461 i = line.find('#')
462 if i >= 0:
463 line = line[0:i].rstrip()
464 line = line.strip()
465 if line == '':
466 continue
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.
476 cl.mailed = True
477 if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
478 cl.private = True
479 if cl.desc == '<enter description here>':
480 cl.desc = ''
481 return cl, 0, ''
483 def SplitCommaSpace(s):
484 typecheck(s, str)
485 s = s.strip()
486 if s == "":
487 return []
488 return re.split(", *", s)
490 def CutDomain(s):
491 typecheck(s, str)
492 i = s.find('@')
493 if i >= 0:
494 s = s[0:i]
495 return s
497 def JoinComma(l):
498 seen = {}
499 uniq = []
500 for s in l:
501 typecheck(s, str)
502 if s not in seen:
503 seen[s] = True
504 uniq.append(s)
506 return ", ".join(uniq)
508 def ExceptionDetail():
509 s = str(sys.exc_info()[0])
510 if s.startswith("<type '") and s.endswith("'>"):
511 s = s[7:-2]
512 elif s.startswith("<class '") and s.endswith("'>"):
513 s = s[8:-2]
514 arg = str(sys.exc_info()[1])
515 if len(arg) > 0:
516 s += ": " + arg
517 return s
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):
524 typecheck(name, str)
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):
531 ff = open(path)
532 text = ff.read()
533 ff.close()
534 cl, lineno, err = ParseCL(text, name)
535 if err != "":
536 return None, "malformed CL data: "+err
537 cl.local = True
538 else:
539 cl = CL(name)
540 if web:
541 set_status("getting issue metadata from web")
542 d = JSONGet(ui, "/api/" + name + "?messages=true")
543 set_status(None)
544 if d is None:
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"
548 cl.dict = d
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.
556 pass
557 else:
558 cl.desc = d.get('description', "")
559 cl.url = server_url_base + name
560 cl.web = True
561 cl.private = d.get('private', False) != False
562 cl.lgtm = []
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)
570 return cl, ''
572 class LoadCLThread(threading.Thread):
573 def __init__(self, ui, repo, dir, f, web):
574 threading.Thread.__init__(self)
575 self.ui = ui
576 self.repo = repo
577 self.dir = dir
578 self.f = f
579 self.web = web
580 self.cl = None
581 def run(self):
582 cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
583 if err != '':
584 self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
585 return
586 self.cl = cl
588 # Load all the CLs from this repository.
589 def LoadAllCL(ui, repo, web=True):
590 dir = CodeReviewDir(ui, repo)
591 m = {}
592 files = [f for f in os.listdir(dir) if f.startswith('cl.')]
593 if not files:
594 return m
595 active = []
596 first = True
597 for f in files:
598 t = LoadCLThread(ui, repo, dir, f, web)
599 t.start()
600 if web and first:
601 # first request: wait in case it needs to authenticate
602 # otherwise we get lots of user/password prompts
603 # running in parallel.
604 t.join()
605 if t.cl:
606 m[t.cl.name] = t.cl
607 first = False
608 else:
609 active.append(t)
610 for t in active:
611 t.join()
612 if t.cl:
613 m[t.cl.name] = t.cl
614 return m
616 # Find repository root. On error, ui.warn and return None
617 def RepoDir(ui, repo):
618 url = repo.url();
619 if not url.startswith('file:'):
620 ui.warn("repository %s is not in local file system\n" % (url,))
621 return None
622 url = url[5:]
623 if url.endswith('/'):
624 url = url[:-1]
625 typecheck(url, str)
626 return url
628 # Find (or make) code review directory. On error, ui.warn and return None
629 def CodeReviewDir(ui, repo):
630 dir = RepoDir(ui, repo)
631 if dir == None:
632 return None
633 dir += '/.hg/codereview/'
634 if not os.path.isdir(dir):
635 try:
636 os.mkdir(dir, 0700)
637 except:
638 ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
639 return None
640 typecheck(dir, str)
641 return dir
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):
648 i = 0
649 while i < len(line) and line[i] == '\t':
650 i += 1
651 return ' '*(8*i) + line[i:]
653 # Strip maximal common leading white space prefix from text
654 def StripCommon(text):
655 typecheck(text, str)
656 ws = None
657 for line in text.split('\n'):
658 line = line.rstrip()
659 if line == '':
660 continue
661 line = TabsToSpaces(line)
662 white = line[:len(line)-len(line.lstrip())]
663 if ws == None:
664 ws = white
665 else:
666 common = ''
667 for i in range(min(len(white), len(ws))+1):
668 if white[0:i] == ws[0:i]:
669 common = white[0:i]
670 ws = common
671 if ws == '':
672 break
673 if ws == None:
674 return text
675 t = ''
676 for line in text.split('\n'):
677 line = line.rstrip()
678 line = TabsToSpaces(line)
679 if line.startswith(ws):
680 line = line[len(ws):]
681 if line == '' and t == '':
682 continue
683 t += line + '\n'
684 while len(t) >= 2 and t[-2:] == '\n\n':
685 t = t[:-1]
686 typecheck(t, str)
687 return t
689 # Indent text with indent.
690 def Indent(text, indent):
691 typecheck(text, str)
692 typecheck(indent, str)
693 t = ''
694 for line in text.split('\n'):
695 t += indent + line + '\n'
696 typecheck(t, str)
697 return t
699 # Return the first line of l
700 def line1(text):
701 typecheck(text, str)
702 return text.split('\n')[0]
704 _change_prolog = """# Change list.
705 # Lines beginning with # are ignored.
706 # Multi-line values should be indented.
707 """
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
716 elaborates.
718 Examples:
720 encoding/rot13: new package
722 math: add IsInf, IsNaN
724 net: fix cname in LookupHost
726 unicode: update to Unicode 5.0.2
728 '''
730 def promptyesno(ui, msg):
731 if hgversion >= "2.7":
732 return ui.promptchoice(msg + " $$ &yes $$ &no", 0) == 0
733 else:
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
748 s = cl.EditorText()
749 while True:
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.
756 try:
757 f = open(repo.root+"/last-change", "w")
758 f.write(s)
759 f.close()
760 except:
761 pass
763 clx, line, err = ParseCL(s, cl.name)
764 if err != '':
765 if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
766 return "change list not modified"
767 continue
769 # Check description.
770 if clx.desc == '':
771 if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
772 continue
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)?"):
775 continue
776 elif not re.match(desc_re, clx.desc.split('\n')[0]):
777 if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
778 continue
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)
788 files = []
789 for f in clx.files:
790 if f in changed:
791 files.append(f)
792 continue
793 if f in deleted:
794 promptremove(ui, repo, f)
795 files.append(f)
796 continue
797 if f in unknown:
798 promptadd(ui, repo, f)
799 files.append(f)
800 continue
801 if f in ignored:
802 ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
803 continue
804 if f in clean:
805 ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
806 files.append(f)
807 continue
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,))
811 files.append(f)
812 continue
813 if os.path.isdir(p):
814 ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
815 continue
816 ui.warn("error: %s does not exist; omitting\n" % (f,))
817 clx.files = files
819 cl.desc = clx.desc
820 cl.reviewer = clx.reviewer
821 cl.cc = clx.cc
822 cl.files = clx.files
823 cl.private = clx.private
824 break
825 return ""
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]):
832 if len(pats) != 1:
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)
837 if err != "":
838 return None, err
839 else:
840 cl = CL("new")
841 cl.local = True
842 cl.files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
843 if not cl.files:
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')))
847 if opts.get('cc'):
848 cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
849 if defaultcc:
850 cl.cc = Add(cl.cc, defaultcc)
851 if cl.name == "new":
852 if opts.get('message'):
853 cl.desc = opts.get('message')
854 else:
855 err = EditCL(ui, repo, cl)
856 if err != '':
857 return None, err
858 return 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):
867 taken = taken or {}
868 # Run each pattern separately so that we can warn about
869 # patterns that didn't do anything useful.
870 for p in pats:
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)
876 for f in files:
877 if f in taken:
878 ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
879 if not files:
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)
884 l.sort()
885 if taken:
886 l = Sub(l, taken.keys())
887 return l
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)
892 l.sort()
893 return l
895 # Return list of files claimed by existing CLs
896 def Taken(ui, repo):
897 all = LoadAllCL(ui, repo, web=False)
898 taken = {}
899 for _, cl in all.items():
900 for f in cl.files:
901 taken[f] = cl
902 return taken
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)
919 if not files:
920 return
921 cwd = os.getcwd()
922 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
923 files = [f for f in files if os.access(f, 0)]
924 if not files:
925 return
926 try:
927 cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
928 cmd.stdin.close()
929 except:
930 raise hg_util.Abort("gofmt: " + ExceptionDetail())
931 data = cmd.stdout.read()
932 errors = cmd.stderr.read()
933 cmd.wait()
934 set_status("done with gofmt")
935 if len(errors) > 0:
936 ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
937 return
938 if len(data) > 0:
939 msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
940 if just_warn:
941 ui.warn("warning: " + msg + "\n")
942 else:
943 raise hg_util.Abort(msg)
944 return
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)]
949 if not files:
950 return
951 cwd = os.getcwd()
952 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
953 files = [f for f in files if os.access(f, 0)]
954 badfiles = []
955 for f in files:
956 try:
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,
960 # so allow that.
961 if line.startswith(' ') and not re.match(' [A-Za-z0-9_]+:', line):
962 badfiles.append(f)
963 break
964 except:
965 # ignore cannot open file, etc.
966 pass
967 if len(badfiles) > 0:
968 msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
969 if just_warn:
970 ui.warn("warning: " + msg + "\n")
971 else:
972 raise hg_util.Abort(msg)
973 return
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
986 try:
987 if contributorsURL is not None:
988 opening = contributorsURL
989 f = urllib2.urlopen(contributorsURL)
990 else:
991 opening = repo.root + '/CONTRIBUTORS'
992 f = open(repo.root + '/CONTRIBUTORS', 'r')
993 except:
994 ui.write("warning: cannot open %s: %s\n" % (opening, ExceptionDetail()))
995 return {}
997 contributors = {}
998 for line in f:
999 # CONTRIBUTORS is a list of lines like:
1000 # Person <email>
1001 # Person <email> <alt-email>
1002 # The first email address is the one used in commit logs.
1003 if line.startswith('#'):
1004 continue
1005 m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
1006 if m:
1007 name = m.group(1)
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
1014 return contributors
1016 def CheckContributor(ui, repo, user=None):
1017 set_status("checking CONTRIBUTORS file")
1018 user, userline = FindContributor(ui, repo, user, warn=False)
1019 if not userline:
1020 raise hg_util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1021 return userline
1023 def FindContributor(ui, repo, user=None, warn=True):
1024 if not user:
1025 user = ui.config("ui", "username")
1026 if not user:
1027 raise hg_util.Abort("[ui] username is not configured in .hgrc")
1028 user = user.lower()
1029 m = re.match(r".*<(.*)>", user)
1030 if m:
1031 user = m.group(1)
1033 contributors = ReadContributors(ui, repo)
1034 if user not in contributors:
1035 if warn:
1036 ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1037 return user, None
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.
1054 hg_required = "1.9"
1055 hg_suggested = "2.1"
1057 old_message = """
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/.
1063 """
1065 linux_message = """
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
1070 """
1072 if hgversion < hg_required:
1073 msg = old_message
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):
1086 self.ui = ui
1087 ui.pushbuffer()
1088 self.oldQuiet = ui.quiet
1089 ui.quiet = True
1090 self.oldVerbose = ui.verbose
1091 ui.verbose = False
1092 def output(self):
1093 ui = self.ui
1094 ui.quiet = self.oldQuiet
1095 ui.verbose = self.oldVerbose
1096 return ui.popbuffer()
1098 def to_slash(path):
1099 if sys.platform == "win32":
1100 return path.replace('\\', '/')
1101 return path
1103 def hg_matchPattern(ui, repo, *pats, **opts):
1104 w = uiwrap(ui)
1105 hg_commands.status(ui, repo, *pats, **opts)
1106 text = w.output()
1107 ret = []
1108 prefix = to_slash(os.path.realpath(repo.root))+'/'
1109 for line in text.split('\n'):
1110 f = line.split()
1111 if len(f) > 1:
1112 if len(pats) > 0:
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)
1117 else:
1118 ret.append(p[len(prefix):])
1119 else:
1120 # Without patterns, Mercurial shows relative to root (what we want)
1121 ret.append(to_slash(f[1]))
1122 return ret
1124 def hg_heads(ui, repo):
1125 w = uiwrap(ui)
1126 hg_commands.heads(ui, repo)
1127 return w.output()
1129 noise = [
1130 "",
1131 "resolving manifests",
1132 "searching for changes",
1133 "couldn't find merge tool hgmerge",
1134 "adding changesets",
1135 "adding manifests",
1136 "adding file changes",
1137 "all local heads known remotely",
1140 def isNoise(line):
1141 line = str(line)
1142 for x in noise:
1143 if line == x:
1144 return True
1145 return False
1147 def hg_incoming(ui, repo):
1148 w = uiwrap(ui)
1149 ret = hg_commands.incoming(ui, repo, force=False, bundle="")
1150 if ret and ret != 1:
1151 raise hg_util.Abort(ret)
1152 return w.output()
1154 def hg_log(ui, repo, **opts):
1155 for k in ['date', 'keyword', 'rev', 'user']:
1156 if not opts.has_key(k):
1157 opts[k] = ""
1158 w = uiwrap(ui)
1159 ret = hg_commands.log(ui, repo, **opts)
1160 if ret:
1161 raise hg_util.Abort(ret)
1162 return w.output()
1164 def hg_outgoing(ui, repo, **opts):
1165 w = uiwrap(ui)
1166 ret = hg_commands.outgoing(ui, repo, **opts)
1167 if ret and ret != 1:
1168 raise hg_util.Abort(ret)
1169 return w.output()
1171 def hg_pull(ui, repo, **opts):
1172 w = uiwrap(ui)
1173 ui.quiet = False
1174 ui.verbose = True # for file list
1175 err = hg_commands.pull(ui, repo, **opts)
1176 for line in w.output().split('\n'):
1177 if isNoise(line):
1178 continue
1179 if line.startswith('moving '):
1180 line = 'mv ' + line[len('moving '):]
1181 if line.startswith('getting ') and line.find(' to ') >= 0:
1182 line = 'mv ' + line[len('getting '):]
1183 if line.startswith('getting '):
1184 line = '+ ' + line[len('getting '):]
1185 if line.startswith('removing '):
1186 line = '- ' + line[len('removing '):]
1187 ui.write(line + '\n')
1188 return err
1190 def hg_update(ui, repo, **opts):
1191 w = uiwrap(ui)
1192 ui.quiet = False
1193 ui.verbose = True # for file list
1194 err = hg_commands.update(ui, repo, **opts)
1195 for line in w.output().split('\n'):
1196 if isNoise(line):
1197 continue
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')
1207 return err
1209 def hg_push(ui, repo, **opts):
1210 w = uiwrap(ui)
1211 ui.quiet = False
1212 ui.verbose = True
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')
1217 return err
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.
1225 commit_okay = False
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:
1231 return False
1232 if commit_okay:
1233 return False # False means okay.
1234 ui.write("\ncodereview extension enabled; use mail, upload, or submit instead of commit\n\n")
1235 return True
1237 #######################################################################
1238 # @clnumber file pattern support
1240 # We replace scmutil.match with the MatchAt wrapper to add the @clnumber pattern.
1242 match_repo = None
1243 match_ui = None
1244 match_orig = None
1246 def InstallMatch(ui, repo):
1247 global match_repo
1248 global match_ui
1249 global match_orig
1251 match_ui = ui
1252 match_repo = 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'):
1259 taken = []
1260 files = []
1261 pats = pats or []
1262 opts = opts or {}
1264 for p in pats:
1265 if p.startswith('@'):
1266 taken.append(p)
1267 clname = p[1:]
1268 if clname == "default":
1269 files = DefaultFiles(match_ui, match_repo, [])
1270 else:
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)
1274 if err != '':
1275 raise hg_util.Abort("loading CL " + clname + ": " + err)
1276 if not cl.files:
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'):
1283 ctx = ctx[None]
1284 return match_orig(ctx, pats=pats, opts=opts, globbed=globbed, default=default)
1286 #######################################################################
1287 # Commands added by code review extension.
1289 def hgcommand(f):
1290 return f
1292 #######################################################################
1293 # hg change
1295 @hgcommand
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
1313 hg revert @123456
1315 before running hg change -d 123456.
1316 """
1318 if codereview_disabled:
1319 raise hg_util.Abort(codereview_disabled)
1321 dirty = {}
1322 if len(pats) > 0 and GoodCLName(pats[0]):
1323 name = pats[0]
1324 if len(pats) != 1:
1325 raise hg_util.Abort("cannot specify CL name and file patterns")
1326 pats = pats[1:]
1327 cl, err = LoadCL(ui, repo, name, web=True)
1328 if err != '':
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)
1332 else:
1333 name = "new"
1334 cl = CL("new")
1335 if repo[None].branch() != "default":
1336 raise hg_util.Abort("cannot create CL outside default branch; switch with 'hg update default'")
1337 dirty[cl] = True
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")
1343 flag = "-d"
1344 if opts["deletelocal"]:
1345 flag = "-D"
1346 if name == "new":
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")
1350 if not cl.local:
1351 raise hg_util.Abort("cannot change non-local CL " + name)
1352 if opts["delete"]:
1353 if cl.copied_from:
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)
1357 cl.Delete(ui, repo)
1358 return
1360 if opts["stdin"]:
1361 s = sys.stdin.read()
1362 clx, line, err = ParseCL(s, name)
1363 if err != '':
1364 raise hg_util.Abort("error parsing change list: line %d: %s" % (line, err))
1365 if clx.desc is not None:
1366 cl.desc = clx.desc;
1367 dirty[cl] = True
1368 if clx.reviewer is not None:
1369 cl.reviewer = clx.reviewer
1370 dirty[cl] = True
1371 if clx.cc is not None:
1372 cl.cc = clx.cc
1373 dirty[cl] = True
1374 if clx.files is not None:
1375 cl.files = clx.files
1376 dirty[cl] = True
1377 if clx.private != cl.private:
1378 cl.private = clx.private
1379 dirty[cl] = True
1381 if not opts["stdin"] and not opts["stdout"]:
1382 if name == "new":
1383 cl.files = files
1384 err = EditCL(ui, repo, cl)
1385 if err != "":
1386 raise hg_util.Abort(err)
1387 dirty[cl] = True
1389 for d, _ in dirty.items():
1390 name = d.name
1391 d.Flush(ui, repo)
1392 if name == "new":
1393 d.Upload(ui, repo, quiet=True)
1395 if opts["stdout"]:
1396 ui.write(cl.EditorText())
1397 elif opts["pending"]:
1398 ui.write(cl.PendingText())
1399 elif name == "new":
1400 if ui.quiet:
1401 ui.write(cl.name)
1402 else:
1403 ui.write("CL created: " + cl.url + "\n")
1404 return
1406 #######################################################################
1407 # hg code-login (broken?)
1409 @hgcommand
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.
1415 """
1416 if codereview_disabled:
1417 raise hg_util.Abort(codereview_disabled)
1419 MySend(None)
1421 #######################################################################
1422 # hg clpatch / undo / release-apply / download
1423 # All concerned with applying or unapplying patches to the repository.
1425 @hgcommand
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.
1435 """
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")
1439 if err:
1440 raise hg_util.Abort(err)
1442 @hgcommand
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.
1449 """
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")
1453 if err:
1454 raise hg_util.Abort(err)
1456 @hgcommand
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
1463 the release branch.
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,
1481 hg update default
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
1491 hg update default
1493 and then edit .hgtags as you would for a weekly.
1495 """
1496 c = repo[None]
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)
1503 if err:
1504 raise hg_util.Abort(err)
1505 try:
1506 err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1507 if err:
1508 raise hg_util.Abort(err)
1509 except Exception, e:
1510 hg_clean(repo, "default")
1511 raise e
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())
1518 if len(all) > 0:
1519 return all[-1]
1520 return ""
1522 undoHeader = """undo CL %s / %s
1524 <enter reason for undo>
1526 ««« original CL description
1527 """
1529 undoFooter = """
1530 »»»
1531 """
1533 backportHeader = """[%s] %s
1535 ««« CL %s / %s
1536 """
1538 backportFooter = """
1539 »»»
1540 """
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):
1555 found = False
1556 for r in hg_log(ui, repo, keyword="codereview.appspot.com/"+clname, limit=100, template="{node}\n").split():
1557 rev = repo[r]
1558 # Last line with a code review URL is the actual review URL.
1559 # Earlier ones might be part of the CL description.
1560 n = rev2clname(rev)
1561 if n == clname:
1562 found = True
1563 break
1564 if not found:
1565 return "cannot find CL %s in local repository" % clname
1566 else:
1567 rev = repo[clname]
1568 if not rev:
1569 return "unknown revision %s" % clname
1570 clname = rev2clname(rev)
1571 if clname == "":
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())
1576 cl = CL("new")
1577 desc = str(rev.description())
1578 if mode == "undo":
1579 cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1580 else:
1581 cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1582 v1 = vers
1583 v0 = hg_node.short(rev.parents()[0].node())
1584 if mode == "undo":
1585 arg = v1 + ":" + v0
1586 else:
1587 vers = v0
1588 arg = v0 + ":" + v1
1589 patch = RunShell(["hg", "diff", "--git", "-r", arg])
1591 else: # clpatch
1592 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1593 if err != "":
1594 return err
1595 if patch == emptydiff:
1596 return "codereview issue %s has no diff" % clname
1598 # find current hg version (hg identify)
1599 ctx = repo[None]
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.
1609 try:
1610 repo[vers].description()
1611 except:
1612 return "local repository is out of date; sync to get %s" % (vers)
1613 patch1, err = portPatch(repo, patch, vers, id)
1614 if err != "":
1615 if not opts["ignore_hgapplydiff_failure"]:
1616 return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1617 else:
1618 patch = patch1
1619 argv = ["hgapplydiff"]
1620 if opts["no_incoming"] or mode == "backport":
1621 argv += ["--checksync=false"]
1622 try:
1623 cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1624 except:
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"
1630 cl.local = True
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)
1636 if extra:
1637 ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1638 cl.Flush(ui, repo)
1639 if mode == "undo":
1640 err = EditCL(ui, repo, cl)
1641 if err != "":
1642 return "CL created, but error editing: " + err
1643 cl.Flush(ui, repo)
1644 else:
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":
1649 msgs = []
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) + ".")
1655 if msgs:
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
1662 delta = None
1663 for i in range(len(lines)):
1664 line = lines[i]
1665 if line.startswith('--- a/'):
1666 file = line[6:-1]
1667 delta = fileDeltas(repo, file, oldver, newver)
1668 if not delta or not line.startswith('@@ '):
1669 continue
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
1675 # x and z.
1676 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1677 if not m:
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)
1681 if err != "":
1682 return "", err
1683 n1 += d
1684 n2 += d
1685 lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1687 newpatch = ''.join(lines)
1688 return newpatch, ""
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)
1698 deltas = []
1699 for line in data.splitlines():
1700 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1701 if not m:
1702 continue
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)))
1705 return deltas
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):
1710 d = 0
1711 for (old, oldlen, newdelta) in deltas:
1712 if old >= n+len:
1713 break
1714 if old+len > n:
1715 return 0, "patch and recent changes conflict"
1716 d = newdelta
1717 return d, ""
1719 @hgcommand
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.
1725 """
1726 if codereview_disabled:
1727 raise hg_util.Abort(codereview_disabled)
1729 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1730 if err != "":
1731 return err
1732 ui.write(cl.EditorText() + "\n")
1733 ui.write(patch + "\n")
1734 return
1736 #######################################################################
1737 # hg file
1739 @hgcommand
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.
1747 """
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
1755 dirty = {}
1756 cl, err = LoadCL(ui, repo, clname, web=False)
1757 if err != '':
1758 return err
1759 if not cl.local:
1760 return "cannot change non-local CL " + clname
1762 files = ChangedFiles(ui, repo, pats)
1764 if opts["delete"]:
1765 oldfiles = Intersect(files, cl.files)
1766 if oldfiles:
1767 if not ui.quiet:
1768 ui.status("# Removing files from CL. To undo:\n")
1769 ui.status("# cd %s\n" % (repo.root))
1770 for f in oldfiles:
1771 ui.status("# hg file %s %s\n" % (cl.name, f))
1772 cl.files = Sub(cl.files, oldfiles)
1773 cl.Flush(ui, repo)
1774 else:
1775 ui.status("no such files in CL")
1776 return
1778 if not files:
1779 return "no such modified files"
1781 files = Sub(files, cl.files)
1782 taken = Taken(ui, repo)
1783 warned = False
1784 for f in files:
1785 if f in taken:
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))
1789 warned = True
1790 ocl = taken[f]
1791 if not ui.quiet:
1792 ui.status("# hg file %s %s\n" % (ocl.name, f))
1793 if ocl not in dirty:
1794 ocl.files = Sub(ocl.files, files)
1795 dirty[ocl] = True
1796 cl.files = Add(cl.files, files)
1797 dirty[cl] = True
1798 for d, _ in dirty.items():
1799 d.Flush(ui, repo)
1800 return
1802 #######################################################################
1803 # hg gofmt
1805 @hgcommand
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
1810 the given patterns.
1811 """
1812 if codereview_disabled:
1813 raise hg_util.Abort(codereview_disabled)
1815 files = ChangedExistingFiles(ui, repo, pats, opts)
1816 files = gofmt_required(files)
1817 if not files:
1818 ui.status("no modified go files\n")
1819 return
1820 cwd = os.getcwd()
1821 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1822 try:
1823 cmd = ["gofmt", "-l"]
1824 if not opts["list"]:
1825 cmd += ["-w"]
1826 if subprocess.call(cmd + files) != 0:
1827 raise hg_util.Abort("gofmt did not exit cleanly")
1828 except hg_error.Abort, e:
1829 raise
1830 except:
1831 raise hg_util.Abort("gofmt: " + ExceptionDetail())
1832 return
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 #######################################################################
1838 # hg mail
1840 @hgcommand
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.
1846 """
1847 if codereview_disabled:
1848 raise hg_util.Abort(codereview_disabled)
1850 cl, err = CommandLineCL(ui, repo, pats, opts, op="mail", defaultcc=defaultcc)
1851 if err != "":
1852 raise hg_util.Abort(err)
1853 cl.Upload(ui, repo, gofmt_just_warn=True)
1854 if not cl.reviewer:
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.
1859 if not defaultcc:
1860 raise hg_util.Abort("no reviewers listed in CL")
1861 cl.cc = Sub(cl.cc, defaultcc)
1862 cl.reviewer = defaultcc
1863 cl.Flush(ui, repo)
1865 if cl.files == []:
1866 raise hg_util.Abort("no changed files, not sending mail")
1868 cl.Mail(ui, repo)
1870 #######################################################################
1871 # hg p / hg pq / hg ps / hg pending
1873 @hgcommand
1874 def ps(ui, repo, *pats, **opts):
1875 """alias for hg p --short
1876 """
1877 opts['short'] = True
1878 return pending(ui, repo, *pats, **opts)
1880 @hgcommand
1881 def pq(ui, repo, *pats, **opts):
1882 """alias for hg p --quick
1883 """
1884 opts['quick'] = True
1885 return pending(ui, repo, *pats, **opts)
1887 @hgcommand
1888 def pending(ui, repo, *pats, **opts):
1889 """show pending changes
1891 Lists pending changes followed by a list of unassigned but modified files.
1892 """
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)
1899 names = m.keys()
1900 names.sort()
1901 for name in names:
1902 cl = m[name]
1903 if short:
1904 ui.write(name + "\t" + line1(cl.desc) + "\n")
1905 else:
1906 ui.write(cl.PendingText(quick=quick) + "\n")
1908 if short:
1909 return 0
1910 files = DefaultFiles(ui, repo, [])
1911 if len(files) > 0:
1912 s = "Changed files not in any CL:\n"
1913 for f in files:
1914 s += "\t" + f + "\n"
1915 ui.write(s)
1917 #######################################################################
1918 # hg submit
1920 def need_sync():
1921 raise hg_util.Abort("local repository out of date; must sync before submit")
1923 @hgcommand
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.
1929 """
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):
1937 need_sync()
1939 cl, err = CommandLineCL(ui, repo, pats, opts, op="submit", defaultcc=defaultcc)
1940 if err != "":
1941 raise hg_util.Abort(err)
1943 user = None
1944 if cl.copied_from:
1945 user = cl.copied_from
1946 userline = CheckContributor(ui, repo, user)
1947 typecheck(userline, str)
1949 about = ""
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")
1953 if cl.lgtm:
1954 about += "LGTM=" + JoinComma([CutDomain(who) for (who, line, approval) in cl.lgtm if approval]) + "\n"
1955 reviewer = cl.reviewer
1956 if opts.get('tbr'):
1957 tbr = SplitCommaSpace(opts.get('tbr'))
1958 for name in 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"
1963 if reviewer:
1964 about += "R=" + JoinComma([CutDomain(s) for s in reviewer]) + "\n"
1965 if cl.cc:
1966 about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1968 if not cl.reviewer:
1969 raise hg_util.Abort("no reviewers listed in CL")
1971 if not cl.local:
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.
1979 cl.Flush(ui, repo)
1980 CheckFormat(ui, repo, cl.files)
1982 about += "%s%s\n" % (server_url_base, cl.name)
1984 if cl.copied_from:
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
1989 cl.Mail(ui, repo)
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())
2002 global commit_okay
2003 commit_okay = True
2004 ret = hg_commit(ui, repo, *['path:'+f for f in cl.files], message=message, user=userline)
2005 commit_okay = False
2006 if ret:
2007 raise hg_util.Abort("nothing changed")
2008 node = repo["-1"].node()
2009 # push to remote; if it fails for any reason, roll back
2010 try:
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.
2014 need_sync()
2016 # Push changes to remote. If it works, we're committed. If not, roll back.
2017 try:
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.
2023 need_sync()
2024 raise
2025 except urllib2.HTTPError, e:
2026 print >>sys.stderr, "pushing to remote server failed; do you have commit permissions?"
2027 raise
2028 except:
2029 real_rollback()
2030 raise
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)
2037 if m:
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)
2044 else:
2045 print >>sys.stderr, "URL: ", url
2046 else:
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)
2056 cl.Delete(ui, repo)
2058 c = repo[None]
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")
2062 if err:
2063 return err
2064 return 0
2066 def isAddca(cl):
2067 rev = cl.reviewer
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 #######################################################################
2072 # hg sync
2074 @hgcommand
2075 def sync(ui, repo, **opts):
2076 """synchronize with remote repository
2078 Incorporates recent changes from the remote repository
2079 into the local repository.
2080 """
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)
2096 else:
2097 err = hg_update(ui, repo)
2098 if err:
2099 return err
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)
2112 if err != "":
2113 ui.warn("loading CL %s: %s\n" % (clname, err))
2114 continue
2115 if not cl.copied_from:
2116 EditDesc(cl.name, closed=True, private=cl.private)
2117 cl.Delete(ui, repo)
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)
2124 if extra:
2125 ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
2126 for f in extra:
2127 ui.warn("\t%s\n" % (f,))
2128 cl.files = Sub(cl.files, extra)
2129 cl.Flush(ui, repo)
2130 if not cl.files:
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))
2133 else:
2134 ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
2135 return 0
2137 #######################################################################
2138 # hg upload
2140 @hgcommand
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.
2145 """
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)
2151 if err != "":
2152 raise hg_util.Abort(err)
2153 if not cl.local:
2154 raise hg_util.Abort("cannot upload non-local change")
2155 cl.Upload(ui, repo)
2156 print "%s%s\n" % (server_url_base, cl.name)
2157 return 0
2159 #######################################################################
2160 # Table of commands, supplied to Mercurial for installation.
2162 review_opts = [
2163 ('r', 'reviewer', '', 'add reviewer'),
2164 ('', 'cc', '', 'add cc'),
2165 ('', 'tbr', '', 'add future reviewer'),
2166 ('m', 'message', '', 'change description (for new change)'),
2169 cmdtable = {
2170 # The ^ means to show this command in the help text that
2171 # is printed when running hg with no arguments.
2172 "^change": (
2173 change,
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 ..."
2183 "^clpatch": (
2184 clpatch,
2186 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2187 ('', 'no_incoming', None, 'disable check for incoming changes'),
2189 "change#"
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.
2194 "code-login": (
2195 code_login,
2196 [],
2197 "",
2199 "^download": (
2200 download,
2201 [],
2202 "change#"
2204 "^file": (
2205 file,
2207 ('d', 'delete', None, 'delete files from change list (but not repository)'),
2209 "[-d] change# FILE ..."
2211 "^gofmt": (
2212 gofmt,
2214 ('l', 'list', None, 'list files that would change, but do not edit them'),
2216 "FILE ..."
2218 "^pending|p": (
2219 pending,
2221 ('s', 'short', False, 'show short result form'),
2222 ('', 'quick', False, 'do not consult codereview server'),
2224 "[FILE ...]"
2226 "^ps": (
2227 ps,
2228 [],
2229 "[FILE ...]"
2231 "^pq": (
2232 pq,
2233 [],
2234 "[FILE ...]"
2236 "^mail": (
2237 mail,
2238 review_opts + [
2239 ] + hg_commands.walkopts,
2240 "[-r reviewer] [--cc cc] [change# | file ...]"
2242 "^release-apply": (
2243 release_apply,
2245 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2246 ('', 'no_incoming', None, 'disable check for incoming changes'),
2248 "change#"
2250 # TODO: release-start, release-tag, weekly-tag
2251 "^submit": (
2252 submit,
2253 review_opts + [
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 ...]"
2258 "^sync": (
2259 sync,
2261 ('', 'local', None, 'do not pull changes from remote repository')
2263 "[--local]",
2265 "^undo": (
2266 undo,
2268 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2269 ('', 'no_incoming', None, 'disable check for incoming changes'),
2271 "change#"
2273 "^upload": (
2274 upload,
2275 [],
2276 "change#"
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
2291 global defaultcc
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
2297 if codereview_init:
2298 return
2299 codereview_init = True
2300 start_status_thread()
2302 # Read repository-specific options from lib/codereview/codereview.cfg or codereview.cfg.
2303 root = ''
2304 try:
2305 root = repo.root
2306 except:
2307 # Yes, repo might not have root; see issue 959.
2308 codereview_disabled = 'codereview disabled: repository has no root'
2309 return
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
2316 else:
2317 repo_config_path = p2
2318 try:
2319 f = open(repo_config_path)
2320 for line in f:
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()
2326 except:
2327 codereview_disabled = 'codereview disabled: cannot open ' + repo_config_path
2328 return
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
2352 # HTML form parser
2353 class FormParser(HTMLParser):
2354 def __init__(self):
2355 self.map = {}
2356 self.curtag = None
2357 self.curdata = None
2358 HTMLParser.__init__(self)
2359 def handle_starttag(self, tag, attrs):
2360 if tag == "input":
2361 key = None
2362 value = ''
2363 for a in attrs:
2364 if a[0] == 'name':
2365 key = a[1]
2366 if a[0] == 'value':
2367 value = a[1]
2368 if key is not None:
2369 self.map[key] = value
2370 if tag == "textarea":
2371 key = None
2372 for a in attrs:
2373 if a[0] == 'name':
2374 key = a[1]
2375 if key is not None:
2376 self.curtag = key
2377 self.curdata = ''
2378 def handle_endtag(self, tag):
2379 if tag == "textarea" and self.curtag is not None:
2380 self.map[self.curtag] = self.curdata
2381 self.curtag = None
2382 self.curdata = None
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])
2389 else:
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):
2396 try:
2397 data = MySend(path, force_auth=False)
2398 typecheck(data, str)
2399 d = fix_json(json.loads(data))
2400 except:
2401 ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2402 return None
2403 return d
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.
2409 def fix_json(x):
2410 if type(x) in [str, int, float, bool, type(None)]:
2411 pass
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:
2418 todel = []
2419 for k in x:
2420 if x[k] is None:
2421 todel.append(k)
2422 else:
2423 x[k] = fix_json(x[k])
2424 for k in todel:
2425 del x[k]
2426 else:
2427 raise hg_util.Abort("unknown type " + str(type(x)) + " in fix_json")
2428 if type(x) is str:
2429 x = x.replace('\r\n', '\n')
2430 return x
2432 def IsRietveldSubmitted(ui, clname, hex):
2433 dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2434 if dict is None:
2435 return False
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)):
2440 return True
2441 return False
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:
2446 return True
2447 return False
2449 def DownloadCL(ui, repo, clname):
2450 set_status("downloading CL " + clname)
2451 cl, err = LoadCL(ui, repo, clname, web=True)
2452 if err != "":
2453 return None, None, None, "error loading CL %s: %s" % (clname, err)
2455 # Find most recent diff
2456 diffs = cl.dict.get("patchsets", [])
2457 if not diffs:
2458 return None, None, None, "CL has no patch sets"
2459 patchid = diffs[-1]
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"
2467 vers = ""
2468 msg = patchset.get("message", "").split()
2469 if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2470 vers = msg[2]
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", "")
2477 if not email:
2478 return None, None, None, "cannot find owner for %s" % (clname)
2479 him = FindContributor(ui, repo, email)
2480 me = FindContributor(ui, repo, None)
2481 if him == me:
2482 cl.mailed = IsRietveldMailed(cl)
2483 else:
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,
2491 **kwargs):
2492 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2493 try:
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
2497 raise
2498 print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2499 time.sleep(2)
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,
2508 **kwargs):
2509 """Sends an RPC and returns the response.
2511 Args:
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.
2519 Returns:
2520 The response body, as a string.
2521 """
2522 # TODO: Don't require authentication. Let the server say
2523 # whether it is necessary.
2524 global rpc
2525 if rpc == None:
2526 rpc = GetRpcServer(upload_options)
2527 self = rpc
2528 if not self.authenticated and force_auth:
2529 self._Authenticate()
2530 if request_path is None:
2531 return
2532 if timeout is None:
2533 timeout = 30 # seconds
2535 old_timeout = socket.getdefaulttimeout()
2536 socket.setdefaulttimeout(timeout)
2537 try:
2538 tries = 0
2539 while True:
2540 tries += 1
2541 args = dict(kwargs)
2542 url = "https://%s%s" % (self.host, request_path)
2543 if args:
2544 url += "?" + urllib.urlencode(args)
2545 req = self._CreateRequest(url=url, data=payload)
2546 req.add_header("Content-Type", content_type)
2547 try:
2548 f = self.opener.open(req)
2549 response = f.read()
2550 f.close()
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)
2557 return response
2558 except urllib2.HTTPError, e:
2559 if tries > 3:
2560 raise
2561 elif e.code == 401:
2562 self._Authenticate()
2563 elif e.code == 302:
2564 loc = e.info()["location"]
2565 if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2566 return ''
2567 self._Authenticate()
2568 else:
2569 raise
2570 finally:
2571 socket.setdefaulttimeout(old_timeout)
2573 def GetForm(url):
2574 f = FormParser()
2575 f.feed(ustr(MySend(url))) # f.feed wants unicode
2576 f.close()
2577 # convert back to utf-8 to restore sanity
2578 m = {}
2579 for k,v in f.map.items():
2580 m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2581 return m
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
2592 if cc is not None:
2593 form_fields['cc'] = cc
2594 if closed:
2595 form_fields['closed'] = "checked"
2596 if private:
2597 form_fields['private'] = "checked"
2598 ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2599 response = MySend("/" + issue + "/edit", body, content_type=ctype)
2600 if response != "":
2601 print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2602 sys.exit(2)
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
2609 if cc is not None:
2610 form_fields['cc'] = cc
2611 if send_mail:
2612 form_fields['send_mail'] = "checked"
2613 else:
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)
2625 if response != "":
2626 print response
2627 sys.exit(2)
2629 class opt(object):
2630 pass
2632 def RietveldSetup(ui, repo):
2633 global force_google_account
2634 global rpc
2635 global server
2636 global server_url_base
2637 global upload_options
2638 global verbosity
2640 if not ui.verbose:
2641 verbosity = 0
2643 # Config options.
2644 x = ui.config("codereview", "server")
2645 if x is not None:
2646 server = x
2648 # TODO(rsc): Take from ui.username?
2649 email = None
2650 x = ui.config("codereview", "email")
2651 if x is not None:
2652 email = x
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
2676 if testing:
2677 upload_options.save_cookies = False
2678 upload_options.email = "test@example.com"
2680 rpc = None
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')
2689 tags.sort()
2690 for t in tags:
2691 if t.startswith('release-branch.go'):
2692 releaseBranch = t
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:
2720 Git
2721 Mercurial
2722 Subversion
2724 It is important for Git/Mercurial users to specify a tree/node/branch to diff
2725 against by using the '--rev' option.
2726 """
2727 # This code is derived from appcfg.py in the App Engine SDK (open source),
2728 # and from ASPN recipe #146306.
2730 import cookielib
2731 import getpass
2732 import logging
2733 import mimetypes
2734 import optparse
2735 import os
2736 import re
2737 import socket
2738 import subprocess
2739 import sys
2740 import urllib
2741 import urllib2
2742 import urlparse
2744 # The md5 module was deprecated in Python 2.5.
2745 try:
2746 from hashlib import md5
2747 except ImportError:
2748 from md5 import md5
2750 try:
2751 import readline
2752 except ImportError:
2753 pass
2755 # The logging verbosity:
2756 # 0: Errors only.
2757 # 1: Status messages.
2758 # 2: Info logs.
2759 # 3: Debug logs.
2760 verbosity = 1
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.
2767 TEXT_MIMETYPES = [
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.
2781 """
2782 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2783 last_email = ""
2784 if os.path.exists(last_email_file_name):
2785 try:
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
2790 except IOError, e:
2791 pass
2792 email = raw_input(prompt + ": ").strip()
2793 if email:
2794 try:
2795 last_email_file = open(last_email_file_name, "w")
2796 last_email_file.write(email)
2797 last_email_file.close()
2798 except IOError, e:
2799 pass
2800 else:
2801 email = last_email
2802 return email
2805 def StatusUpdate(msg):
2806 """Print a status message to stdout.
2808 If 'verbosity' is greater than 0, print the message.
2810 Args:
2811 msg: The string to print.
2812 """
2813 if verbosity > 0:
2814 print msg
2817 def ErrorExit(msg):
2818 """Print an error message to stderr and exit."""
2819 print >>sys.stderr, msg
2820 sys.exit(1)
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)
2828 self.args = args
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.
2840 Args:
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
2844 is required.
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.
2850 """
2851 self.host = host
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)
2860 else:
2861 logging.info("Server: %s", self.host)
2863 def _GetOpener(self):
2864 """Returns an OpenerDirector for making HTTP requests.
2866 Returns:
2867 A urllib2.OpenerDirector object.
2868 """
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)
2879 return req
2881 def _GetAuthToken(self, email, password):
2882 """Uses ClientLogin to authenticate the user, returning an auth token.
2884 Args:
2885 email: The user's email address
2886 password: The user's password
2888 Raises:
2889 ClientLoginError: If there was an error authenticating with ClientLogin.
2890 HTTPError: If there was some other form of HTTP error.
2892 Returns:
2893 The authentication token returned by ClientLogin.
2894 """
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({
2902 "Email": email,
2903 "Passwd": password,
2904 "service": "ah",
2905 "source": "rietveld-codereview-upload",
2906 "accountType": account_type,
2907 }),
2909 try:
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:
2915 if e.code == 403:
2916 body = e.read()
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)
2919 else:
2920 raise
2922 def _GetAuthCookie(self, auth_token):
2923 """Fetches authentication cookies for an authentication token.
2925 Args:
2926 auth_token: The authentication token returned by ClientLogin.
2928 Raises:
2929 HTTPError: If there was an error fetching the authentication cookies.
2930 """
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)))
2935 try:
2936 response = self.opener.open(req)
2937 except urllib2.HTTPError, e:
2938 response = 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.
2958 """
2959 for i in range(3):
2960 credentials = self.auth_function()
2961 try:
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."
2966 continue
2967 if e.msg == "CaptchaRequired":
2968 print >>sys.stderr, (
2969 "Please go to\n"
2970 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2971 "and verify you are a human. Then try again.")
2972 break
2973 if e.msg == "NotVerified":
2974 print >>sys.stderr, "Account not verified."
2975 break
2976 if e.msg == "TermsNotAgreed":
2977 print >>sys.stderr, "User has not agreed to TOS."
2978 break
2979 if e.msg == "AccountDeleted":
2980 print >>sys.stderr, "The user account has been deleted."
2981 break
2982 if e.msg == "AccountDisabled":
2983 print >>sys.stderr, "The user account has been disabled."
2984 break
2985 if e.msg == "ServiceDisabled":
2986 print >>sys.stderr, "The user's access to the service has been disabled."
2987 break
2988 if e.msg == "ServiceUnavailable":
2989 print >>sys.stderr, "The service is not available; try again later."
2990 break
2991 raise
2992 self._GetAuthCookie(auth_token)
2993 return
2995 def Send(self, request_path, payload=None,
2996 content_type="application/octet-stream",
2997 timeout=None,
2998 **kwargs):
2999 """Sends an RPC and returns the response.
3001 Args:
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.
3009 Returns:
3010 The response body, as a string.
3011 """
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)
3019 try:
3020 tries = 0
3021 while True:
3022 tries += 1
3023 args = dict(kwargs)
3024 url = "https://%s%s" % (self.host, request_path)
3025 if args:
3026 url += "?" + urllib.urlencode(args)
3027 req = self._CreateRequest(url=url, data=payload)
3028 req.add_header("Content-Type", content_type)
3029 try:
3030 f = self.opener.open(req)
3031 response = f.read()
3032 f.close()
3033 return response
3034 except urllib2.HTTPError, e:
3035 if tries > 3:
3036 raise
3037 elif e.code == 401 or e.code == 302:
3038 self._Authenticate()
3039 else:
3040 raise
3041 finally:
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.
3058 Returns:
3059 A urllib2.OpenerDirector object.
3060 """
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):
3072 try:
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.
3078 pass
3079 else:
3080 # Create an empty cookie file with mode 600
3081 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
3082 os.close(fd)
3083 # Always chmod the cookie file
3084 os.chmod(self.cookie_file, 0600)
3085 else:
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))
3089 return opener
3092 def GetRpcServer(options):
3093 """Returns an instance of an AbstractRpcServer.
3095 Returns:
3096 A new AbstractRpcServer, on which RPC calls can be made.
3097 """
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
3105 st = global_status
3106 global_status = None
3108 email = options.email
3109 if email is None:
3110 email = GetEmail("Email (login for uploading to %s)" % options.server)
3111 password = getpass.getpass("Password for %s: " % email)
3113 # Put status back.
3114 global_status = st
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
3121 if email is None:
3122 email = "test@example.com"
3123 logging.info("Using debug user %s. Override with --email" % email)
3124 server = rpc_server_class(
3125 options.server,
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
3132 return server
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.
3141 Args:
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
3144 uploaded as files.
3145 Returns:
3146 (content_type, body) ready for httplib.HTTP instance.
3148 Source:
3149 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
3150 """
3151 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
3152 CRLF = '\r\n'
3153 lines = []
3154 for (key, value) in fields:
3155 typecheck(key, str)
3156 typecheck(value, str)
3157 lines.append('--' + BOUNDARY)
3158 lines.append('Content-Disposition: form-data; name="%s"' % key)
3159 lines.append('')
3160 lines.append(value)
3161 for (key, filename, value) in files:
3162 typecheck(key, str)
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))
3168 lines.append('')
3169 lines.append(value)
3170 lines.append('--' + BOUNDARY + '--')
3171 lines.append('')
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.
3189 Args:
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).
3195 Returns:
3196 Tuple (output, return code)
3197 """
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)
3201 if print_output:
3202 output_array = []
3203 while True:
3204 line = p.stdout.readline()
3205 if not line:
3206 break
3207 print line.strip("\n")
3208 output_array.append(line)
3209 output = "".join(output_array)
3210 else:
3211 output = p.stdout.read()
3212 p.wait()
3213 errout = p.stderr.read()
3214 if print_output and errout:
3215 print >>sys.stderr, errout
3216 p.stdout.close()
3217 p.stderr.close()
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)
3224 if retcode:
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)
3228 return data
3231 class VersionControlSystem(object):
3232 """Abstract base class providing an interface to the VCS."""
3234 def __init__(self, options):
3235 """Constructor.
3237 Args:
3238 options: Command line options.
3239 """
3240 self.options = options
3242 def GenerateDiff(self, args):
3243 """Return the current diff as a string.
3245 Args:
3246 args: Extra arguments to pass to the diff command.
3247 """
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()
3259 if unknown_files:
3260 print "The following files are not added to version control:"
3261 for line in unknown_files:
3262 print line
3263 prompt = "Are you sure to continue?(y/N) "
3264 answer = raw_input(prompt).strip()
3265 if answer != "y":
3266 ErrorExit("User aborted")
3268 def GetBaseFile(self, filename):
3269 """Get the content of the upstream version of a file.
3271 Returns:
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.
3279 """
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.
3288 Returns:
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:".
3292 """
3293 files = {}
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 '\'
3298 # instead of '/'.
3299 filename = to_slash(filename.strip())
3300 files[filename] = self.GetBaseFile(filename)
3301 return files
3304 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
3305 files):
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
3312 if is_base:
3313 type = "base"
3314 else:
3315 type = "current"
3316 if len(content) > MAX_UPLOAD_SIZE:
3317 print ("Not uploading the %s file for %s because it's too large." %
3318 (type, filename))
3319 file_too_large = True
3320 content = ""
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)
3325 form_fields = [
3326 ("filename", filename),
3327 ("status", status),
3328 ("checksum", checksum),
3329 ("is_binary", str(is_binary)),
3330 ("is_current", str(not is_base)),
3332 if file_too_large:
3333 form_fields.append(("file_too_large", "1"))
3334 if options.email:
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)
3340 sys.exit(1)
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)
3349 upload_threads = []
3350 finished_upload_threads = []
3352 class UploadFileThread(threading.Thread):
3353 def __init__(self, args):
3354 threading.Thread.__init__(self)
3355 self.args = args
3356 def run(self):
3357 UploadFile(*self.args)
3358 finished_upload_threads.append(self)
3359 sema.release()
3361 def StartUploadFile(*args):
3362 sema.acquire()
3363 while len(finished_upload_threads) > 0:
3364 t = finished_upload_threads.pop()
3365 upload_threads.remove(t)
3366 t.join()
3367 t = UploadFileThread(args)
3368 upload_threads.append(t)
3369 t.start()
3371 def WaitForUploads():
3372 for t in upload_threads:
3373 t.join()
3375 patches = dict()
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:
3381 base_content = None
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)
3388 WaitForUploads()
3390 def IsImage(self, filename):
3391 """Returns true if the filename has an image extension."""
3392 mimetype = mimetypes.guess_type(filename)[0]
3393 if not mimetype:
3394 return False
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]
3400 if not mimetype:
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:
3404 return False
3405 return not mimetype.startswith("text/")
3408 class FakeMercurialUI(object):
3409 def __init__(self):
3410 self.quiet = True
3411 self.output = ''
3413 def write(self, *args, **opts):
3414 self.output += ' '.join(args)
3415 def copy(self):
3416 return self
3417 def status(self, *args, **opts):
3418 pass
3420 def formatter(self, topic, opts):
3421 from mercurial.formatter import plainformatter
3422 return plainformatter(self, topic, opts)
3424 def readconfig(self, *args, **opts):
3425 pass
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)
3440 self.ui = ui
3441 self.repo = repo
3442 self.status = None
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
3451 else:
3452 mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3453 if not err and mqparent != "":
3454 self.base_rev = mqparent
3455 else:
3456 out = RunShell(["hg", "parents", "-q"], silent_ok=True).strip()
3457 if not out:
3458 # No revisions; use 0 to mean a repository with nothing.
3459 out = "0:0"
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)
3472 svndiff = []
3473 filecount = 0
3474 for line in data.splitlines():
3475 m = re.match("diff --git a/(\S+) b/(\S+)", line)
3476 if m:
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)
3485 filecount += 1
3486 logging.info(line)
3487 else:
3488 svndiff.append(line)
3489 if not filecount:
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."""
3495 args = []
3496 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3497 silent_ok=True)
3498 unknown_files = []
3499 for line in status.splitlines():
3500 st, fn = line.split(" ", 1)
3501 if st == "?":
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:
3511 if use_hg_shell:
3512 out = RunShell(["hg", "status", "-C", "--rev", rev])
3513 else:
3514 fui = FakeMercurialUI()
3515 ret = hg_commands.status(fui, self.repo, *[], **{'rev': [rev], 'copies': True})
3516 if ret:
3517 raise hg_util.Abort(ret)
3518 out = fui.output
3519 self.status = out.splitlines()
3520 for i in range(len(self.status)):
3521 # line is
3522 # A path
3523 # M path
3524 # etc
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
3536 # to the repo root.
3537 base_content = ""
3538 new_content = None
3539 is_binary = False
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()
3545 status = "M"
3546 if ":" in self.base_rev:
3547 base_rev = self.base_rev.split(":", 1)[0]
3548 else:
3549 base_rev = self.base_rev
3550 if status != "A":
3551 if use_hg_shell:
3552 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3553 else:
3554 base_content = str(self.repo[base_rev][oldrelpath].data())
3555 is_binary = "\0" in base_content # Mercurial's heuristic
3556 if status != "R":
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):
3564 new_content = None
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.
3572 Args:
3573 data: A string containing the output of svn diff.
3575 Returns:
3576 A list of 2-tuple (filename, text) where text is the svn diff output
3577 pertaining to filename.
3578 """
3579 patches = []
3580 filename = None
3581 diff = []
3582 for line in data.splitlines(True):
3583 new_filename = None
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
3596 if new_filename:
3597 if filename and diff:
3598 patches.append((filename, ''.join(diff)))
3599 filename = new_filename
3600 diff = [line]
3601 continue
3602 if diff is not None:
3603 diff.append(line)
3604 if filename and diff:
3605 patches.append((filename, ''.join(diff)))
3606 return patches
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.
3613 """
3614 patches = SplitPatch(data)
3615 rv = []
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.")
3621 continue
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)
3633 sys.exit(1)
3634 rv.append([lines[1], patch[0]])
3635 return rv