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