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