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