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