2 # Copyright 2013 the V8 project authors. All rights reserved.
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
7 # * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 # * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following
11 # disclaimer in the documentation and/or other materials provided
12 # with the distribution.
13 # * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived
15 # from this software without specific prior written permission.
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
41 from git_recipes import GitRecipesMixin
42 from git_recipes import GitFailedException
44 PERSISTFILE_BASENAME = "PERSISTFILE_BASENAME"
45 BRANCHNAME = "BRANCHNAME"
46 DOT_GIT_LOCATION = "DOT_GIT_LOCATION"
47 VERSION_FILE = "VERSION_FILE"
48 CHANGELOG_FILE = "CHANGELOG_FILE"
49 CHANGELOG_ENTRY_FILE = "CHANGELOG_ENTRY_FILE"
50 COMMITMSG_FILE = "COMMITMSG_FILE"
51 PATCH_FILE = "PATCH_FILE"
54 def TextToFile(text, file_name):
55 with open(file_name, "w") as f:
59 def AppendToFile(text, file_name):
60 with open(file_name, "a") as f:
64 def LinesInFile(file_name):
65 with open(file_name) as f:
70 def FileToText(file_name):
71 with open(file_name) as f:
75 def MSub(rexp, replacement, text):
76 return re.sub(rexp, replacement, text, flags=re.MULTILINE)
80 # Replace tabs and remove surrounding space.
81 line = re.sub(r"\t", r" ", line.strip())
83 # Format with 8 characters indentation and line width 80.
84 return textwrap.fill(line, width=80, initial_indent=" ",
85 subsequent_indent=" ")
88 def MakeComment(text):
89 return MSub(r"^( ?)", "#", text)
92 def StripComments(text):
93 # Use split not splitlines to keep terminal newlines.
94 return "\n".join(filter(lambda x: not x.startswith("#"), text.split("\n")))
97 def MakeChangeLogBody(commit_messages, auto_format=False):
100 for (title, body, author) in commit_messages:
101 # TODO(machenbach): Better check for reverts. A revert should remove the
102 # original CL from the actual log entry.
103 title = title.strip()
105 # Only add commits that set the LOG flag correctly.
106 log_exp = r"^[ \t]*LOG[ \t]*=[ \t]*(?:(?:Y(?:ES)?)|TRUE)"
107 if not re.search(log_exp, body, flags=re.I | re.M):
109 # Never include reverts.
110 if title.startswith("Revert "):
112 # Don't include duplicates.
113 if title in added_titles:
116 # Add and format the commit's title and bug reference. Move dot to the end.
117 added_titles.add(title)
118 raw_title = re.sub(r"(\.|\?|!)$", "", title)
119 bug_reference = MakeChangeLogBugReference(body)
120 space = " " if bug_reference else ""
121 result += "%s\n" % Fill80("%s%s%s." % (raw_title, space, bug_reference))
123 # Append the commit's author for reference if not in auto-format mode.
125 result += "%s\n" % Fill80("(%s)" % author.strip())
131 def MakeChangeLogBugReference(body):
132 """Grep for "BUG=xxxx" lines in the commit message and convert them to
139 ref = re.match(r"^BUG[ \t]*=[ \t]*(.+)$", text.strip())
142 for bug in ref.group(1).split(","):
144 match = re.match(r"^v8:(\d+)$", bug)
145 if match: v8bugs.append(int(match.group(1)))
147 match = re.match(r"^(?:chromium:)?(\d+)$", bug)
148 if match: crbugs.append(int(match.group(1)))
150 # Add issues to crbugs and v8bugs.
151 map(AddIssues, body.splitlines())
153 # Filter duplicates, sort, stringify.
154 crbugs = map(str, sorted(set(crbugs)))
155 v8bugs = map(str, sorted(set(v8bugs)))
158 def FormatIssues(prefix, bugs):
160 plural = "s" if len(bugs) > 1 else ""
161 bug_groups.append("%sissue%s %s" % (prefix, plural, ", ".join(bugs)))
163 FormatIssues("", v8bugs)
164 FormatIssues("Chromium ", crbugs)
166 if len(bug_groups) > 0:
167 return "(%s)" % ", ".join(bug_groups)
172 # Some commands don't like the pipe, e.g. calling vi from within the script or
173 # from subscripts like git cl upload.
174 def Command(cmd, args="", prefix="", pipe=True):
175 # TODO(machenbach): Use timeout.
176 cmd_line = "%s %s %s" % (prefix, cmd, args)
177 print "Command: %s" % cmd_line
181 return subprocess.check_output(cmd_line, shell=True)
183 return subprocess.check_call(cmd_line, shell=True)
184 except subprocess.CalledProcessError:
191 # Wrapper for side effects.
192 class SideEffectHandler(object): # pragma: no cover
193 def Call(self, fun, *args, **kwargs):
194 return fun(*args, **kwargs)
196 def Command(self, cmd, args="", prefix="", pipe=True):
197 return Command(cmd, args, prefix, pipe)
200 return sys.stdin.readline().strip()
202 def ReadURL(self, url, params=None):
203 # pylint: disable=E1121
204 url_fh = urllib2.urlopen(url, params, 60)
210 def Sleep(self, seconds):
214 return datetime.date.today().strftime("%Y-%m-%d")
216 DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()
219 class NoRetryException(Exception):
223 class Step(GitRecipesMixin):
224 def __init__(self, text, requires, number, config, state, options, handler):
226 self._requires = requires
227 self._number = number
228 self._config = config
230 self._options = options
231 self._side_effect_handler = handler
232 assert self._number >= 0
233 assert self._config is not None
234 assert self._state is not None
235 assert self._side_effect_handler is not None
237 def __getitem__(self, key):
238 # Convenience method to allow direct [] access on step classes for
239 # manipulating the backed state dict.
240 return self._state[key]
242 def __setitem__(self, key, value):
243 # Convenience method to allow direct [] access on step classes for
244 # manipulating the backed state dict.
245 self._state[key] = value
247 def Config(self, key):
248 return self._config[key]
252 state_file = "%s-state.json" % self._config[PERSISTFILE_BASENAME]
253 if not self._state and os.path.exists(state_file):
254 self._state.update(json.loads(FileToText(state_file)))
256 # Skip step if requirement is not met.
257 if self._requires and not self._state.get(self._requires):
260 print ">>> Step %d: %s" % (self._number, self._text)
262 return self.RunStep()
265 TextToFile(json.dumps(self._state), state_file)
267 def RunStep(self): # pragma: no cover
268 raise NotImplementedError
270 def Retry(self, cb, retry_on=None, wait_plan=None):
271 """ Retry a function.
273 cb: The function to retry.
274 retry_on: A callback that takes the result of the function and returns
275 True if the function should be retried. A function throwing an
276 exception is always retried.
277 wait_plan: A list of waiting delays between retries in seconds. The
278 maximum number of retries is len(wait_plan).
280 retry_on = retry_on or (lambda x: False)
281 wait_plan = list(wait_plan or [])
284 got_exception = False
287 except NoRetryException, e:
291 if got_exception or retry_on(result):
292 if not wait_plan: # pragma: no cover
293 raise Exception("Retried too often. Giving up.")
294 wait_time = wait_plan.pop()
295 print "Waiting for %f seconds." % wait_time
296 self._side_effect_handler.Sleep(wait_time)
301 def ReadLine(self, default=None):
302 # Don't prompt in forced mode.
303 if self._options.force_readline_defaults and default is not None:
304 print "%s (forced)" % default
307 return self._side_effect_handler.ReadLine()
309 def Git(self, args="", prefix="", pipe=True, retry_on=None):
310 cmd = lambda: self._side_effect_handler.Command("git", args, prefix, pipe)
311 result = self.Retry(cmd, retry_on, [5, 30])
313 raise GitFailedException("'git %s' failed." % args)
316 def SVN(self, args="", prefix="", pipe=True, retry_on=None):
317 cmd = lambda: self._side_effect_handler.Command("svn", args, prefix, pipe)
318 return self.Retry(cmd, retry_on, [5, 30])
320 def Editor(self, args):
321 if self._options.requires_editor:
322 return self._side_effect_handler.Command(os.environ["EDITOR"], args,
325 def ReadURL(self, url, params=None, retry_on=None, wait_plan=None):
326 wait_plan = wait_plan or [3, 60, 600]
327 cmd = lambda: self._side_effect_handler.ReadURL(url, params)
328 return self.Retry(cmd, retry_on, wait_plan)
331 return self._side_effect_handler.GetDate()
333 def Die(self, msg=""):
335 print "Error: %s" % msg
339 def DieNoManualMode(self, msg=""):
340 if not self._options.manual: # pragma: no cover
341 msg = msg or "Only available in manual mode."
344 def Confirm(self, msg):
345 print "%s [Y/n] " % msg,
346 answer = self.ReadLine(default="Y")
347 return answer == "" or answer == "Y" or answer == "y"
349 def DeleteBranch(self, name):
350 for line in self.GitBranch().splitlines():
351 if re.match(r".*\s+%s$" % name, line):
352 msg = "Branch %s exists, do you want to delete it?" % name
353 if self.Confirm(msg):
354 self.GitDeleteBranch(name)
355 print "Branch %s deleted." % name
357 msg = "Can't continue. Please delete branch %s and try again." % name
360 def InitialEnvironmentChecks(self):
361 # Cancel if this is not a git checkout.
362 if not os.path.exists(self._config[DOT_GIT_LOCATION]): # pragma: no cover
363 self.Die("This is not a git checkout, this script won't work for you.")
365 # Cancel if EDITOR is unset or not executable.
366 if (self._options.requires_editor and (not os.environ.get("EDITOR") or
367 Command("which", os.environ["EDITOR"]) is None)): # pragma: no cover
368 self.Die("Please set your EDITOR environment variable, you'll need it.")
370 def CommonPrepare(self):
371 # Check for a clean workdir.
372 if not self.GitIsWorkdirClean(): # pragma: no cover
373 self.Die("Workspace is not clean. Please commit or undo your changes.")
375 # Persist current branch.
376 self["current_branch"] = self.GitCurrentBranch()
378 # Fetch unfetched revisions.
381 def PrepareBranch(self):
382 # Delete the branch that will be created later if it exists already.
383 self.DeleteBranch(self._config[BRANCHNAME])
385 def CommonCleanup(self):
386 self.GitCheckout(self["current_branch"])
387 if self._config[BRANCHNAME] != self["current_branch"]:
388 self.GitDeleteBranch(self._config[BRANCHNAME])
390 # Clean up all temporary files.
391 Command("rm", "-f %s*" % self._config[PERSISTFILE_BASENAME])
393 def ReadAndPersistVersion(self, prefix=""):
394 def ReadAndPersist(var_name, def_name):
395 match = re.match(r"^#define %s\s+(\d*)" % def_name, line)
397 value = match.group(1)
398 self["%s%s" % (prefix, var_name)] = value
399 for line in LinesInFile(self._config[VERSION_FILE]):
400 for (var_name, def_name) in [("major", "MAJOR_VERSION"),
401 ("minor", "MINOR_VERSION"),
402 ("build", "BUILD_NUMBER"),
403 ("patch", "PATCH_LEVEL")]:
404 ReadAndPersist(var_name, def_name)
406 def WaitForLGTM(self):
407 print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit "
408 "your change. (If you need to iterate on the patch or double check "
409 "that it's sane, do so in another shell, but remember to not "
410 "change the headline of the uploaded CL.")
412 while answer != "LGTM":
414 answer = self.ReadLine(None if self._options.wait_for_lgtm else "LGTM")
416 print "That was not 'LGTM'."
418 def WaitForResolvingConflicts(self, patch_file):
419 print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", "
420 "or resolve the conflicts, stage *all* touched files with "
421 "'git add', and type \"RESOLVED<Return>\"")
422 self.DieNoManualMode()
424 while answer != "RESOLVED":
425 if answer == "ABORT":
426 self.Die("Applying the patch failed.")
428 print "That was not 'RESOLVED' or 'ABORT'."
430 answer = self.ReadLine()
432 # Takes a file containing the patch to apply as first argument.
433 def ApplyPatch(self, patch_file, revert=False):
435 self.GitApplyPatch(patch_file, revert)
436 except GitFailedException:
437 self.WaitForResolvingConflicts(patch_file)
439 def FindLastTrunkPush(self, parent_hash="", include_patches=False):
440 push_pattern = "^Version [[:digit:]]*\.[[:digit:]]*\.[[:digit:]]*"
441 if not include_patches:
442 # Non-patched versions only have three numbers followed by the "(based
444 push_pattern += " (based"
445 branch = "" if parent_hash else "svn/trunk"
446 return self.GitLog(n=1, format="%H", grep=push_pattern,
447 parent_hash=parent_hash, branch=branch)
450 class UploadStep(Step):
451 MESSAGE = "Upload for code review."
454 if self._options.reviewer:
455 print "Using account %s for review." % self._options.reviewer
456 reviewer = self._options.reviewer
458 print "Please enter the email address of a V8 reviewer for your patch: ",
459 self.DieNoManualMode("A reviewer must be specified in forced mode.")
460 reviewer = self.ReadLine()
461 self.GitUpload(reviewer, self._options.author, self._options.force_upload)
464 class DetermineV8Sheriff(Step):
465 MESSAGE = "Determine the V8 sheriff for code review."
468 self["sheriff"] = None
469 if not self._options.sheriff: # pragma: no cover
473 # The googlers mapping maps @google.com accounts to @chromium.org
475 googlers = imp.load_source('googlers_mapping',
476 self._options.googlers_mapping)
477 googlers = googlers.list_to_dict(googlers.get_list())
478 except: # pragma: no cover
479 print "Skip determining sheriff without googler mapping."
482 # The sheriff determined by the rotation on the waterfall has a
483 # @google.com account.
484 url = "https://chromium-build.appspot.com/p/chromium/sheriff_v8.js"
485 match = re.match(r"document\.write\('(\w+)'\)", self.ReadURL(url))
487 # If "channel is sheriff", we can't match an account.
489 g_name = match.group(1)
490 self["sheriff"] = googlers.get(g_name + "@google.com",
491 g_name + "@chromium.org")
492 self._options.reviewer = self["sheriff"]
493 print "Found active sheriff: %s" % self["sheriff"]
495 print "No active sheriff found."
498 def MakeStep(step_class=Step, number=0, state=None, config=None,
499 options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER):
500 # Allow to pass in empty dictionaries.
501 state = state if state is not None else {}
502 config = config if config is not None else {}
505 message = step_class.MESSAGE
506 except AttributeError:
507 message = step_class.__name__
509 requires = step_class.REQUIRES
510 except AttributeError:
513 return step_class(message, requires, number=number, config=config,
514 state=state, options=options,
515 handler=side_effect_handler)
518 class ScriptsBase(object):
519 # TODO(machenbach): Move static config here.
520 def __init__(self, config, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER,
522 self._config = config
523 self._side_effect_handler = side_effect_handler
524 self._state = state if state is not None else {}
526 def _Description(self):
529 def _PrepareOptions(self, parser):
532 def _ProcessOptions(self, options):
535 def _Steps(self): # pragma: no cover
536 raise Exception("Not implemented.")
538 def MakeOptions(self, args=None):
539 parser = argparse.ArgumentParser(description=self._Description())
540 parser.add_argument("-a", "--author", default="",
541 help="The author email used for rietveld.")
542 parser.add_argument("-g", "--googlers-mapping",
543 help="Path to the script mapping google accounts.")
544 parser.add_argument("-r", "--reviewer", default="",
545 help="The account name to be used for reviews.")
546 parser.add_argument("--sheriff", default=False, action="store_true",
547 help=("Determine current sheriff to review CLs. On "
548 "success, this will overwrite the reviewer "
550 parser.add_argument("-s", "--step",
551 help="Specify the step where to start work. Default: 0.",
554 self._PrepareOptions(parser)
556 if args is None: # pragma: no cover
557 options = parser.parse_args()
559 options = parser.parse_args(args)
561 # Process common options.
562 if options.step < 0: # pragma: no cover
563 print "Bad step number %d" % options.step
566 if options.sheriff and not options.googlers_mapping: # pragma: no cover
567 print "To determine the current sheriff, requires the googler mapping"
571 # Defaults for options, common to all scripts.
572 options.manual = getattr(options, "manual", True)
573 options.force = getattr(options, "force", False)
576 options.requires_editor = not options.force
577 options.wait_for_lgtm = not options.force
578 options.force_readline_defaults = not options.manual
579 options.force_upload = not options.manual
581 # Process script specific options.
582 if not self._ProcessOptions(options):
587 def RunSteps(self, step_classes, args=None):
588 options = self.MakeOptions(args)
592 state_file = "%s-state.json" % self._config[PERSISTFILE_BASENAME]
593 if options.step == 0 and os.path.exists(state_file):
594 os.remove(state_file)
597 for (number, step_class) in enumerate(step_classes):
598 steps.append(MakeStep(step_class, number, self._state, self._config,
599 options, self._side_effect_handler))
600 for step in steps[options.step:]:
605 def Run(self, args=None):
606 return self.RunSteps(self._Steps(), args)