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