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