1#!/usr/bin/env python
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
5# met:
6#
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.
16#
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.
28
29import argparse
30import os
31import sys
32import tempfile
33import urllib2
34
35from common_includes import *
36
37PUSH_MSG_GIT_SUFFIX = " (based on %s)"
38
39
40class Preparation(Step):
41  MESSAGE = "Preparation."
42
43  def RunStep(self):
44    self.InitialEnvironmentChecks(self.default_cwd)
45    self.CommonPrepare()
46
47    if(self["current_branch"] == self.Config("CANDIDATESBRANCH")
48       or self["current_branch"] == self.Config("BRANCHNAME")):
49      print "Warning: Script started on branch %s" % self["current_branch"]
50
51    self.PrepareBranch()
52    self.DeleteBranch(self.Config("CANDIDATESBRANCH"))
53
54
55class FreshBranch(Step):
56  MESSAGE = "Create a fresh branch."
57
58  def RunStep(self):
59    self.GitCreateBranch(self.Config("BRANCHNAME"),
60                         self.vc.RemoteMasterBranch())
61
62
63class PreparePushRevision(Step):
64  MESSAGE = "Check which revision to push."
65
66  def RunStep(self):
67    if self._options.revision:
68      self["push_hash"] = self._options.revision
69    else:
70      self["push_hash"] = self.GitLog(n=1, format="%H", git_hash="HEAD")
71    if not self["push_hash"]:  # pragma: no cover
72      self.Die("Could not determine the git hash for the push.")
73
74
75class IncrementVersion(Step):
76  MESSAGE = "Increment version number."
77
78  def RunStep(self):
79    latest_version = self.GetLatestVersion()
80
81    # The version file on master can be used to bump up major/minor at
82    # branch time.
83    self.GitCheckoutFile(VERSION_FILE, self.vc.RemoteMasterBranch())
84    self.ReadAndPersistVersion("master_")
85    master_version = self.ArrayToVersion("master_")
86
87    # Use the highest version from master or from tags to determine the new
88    # version.
89    authoritative_version = sorted(
90        [master_version, latest_version], key=SortingKey)[1]
91    self.StoreVersion(authoritative_version, "authoritative_")
92
93    # Variables prefixed with 'new_' contain the new version numbers for the
94    # ongoing candidates push.
95    self["new_major"] = self["authoritative_major"]
96    self["new_minor"] = self["authoritative_minor"]
97    self["new_build"] = str(int(self["authoritative_build"]) + 1)
98
99    # Make sure patch level is 0 in a new push.
100    self["new_patch"] = "0"
101
102    self["version"] = "%s.%s.%s" % (self["new_major"],
103                                    self["new_minor"],
104                                    self["new_build"])
105
106    print ("Incremented version to %s" % self["version"])
107
108
109class DetectLastRelease(Step):
110  MESSAGE = "Detect commit ID of last release base."
111
112  def RunStep(self):
113    if self._options.last_master:
114      self["last_push_master"] = self._options.last_master
115    else:
116      self["last_push_master"] = self.GetLatestReleaseBase()
117
118
119class PrepareChangeLog(Step):
120  MESSAGE = "Prepare raw ChangeLog entry."
121
122  def Reload(self, body):
123    """Attempts to reload the commit message from rietveld in order to allow
124    late changes to the LOG flag. Note: This is brittle to future changes of
125    the web page name or structure.
126    """
127    match = re.search(r"^Review URL: https://codereview\.chromium\.org/(\d+)$",
128                      body, flags=re.M)
129    if match:
130      cl_url = ("https://codereview.chromium.org/%s/description"
131                % match.group(1))
132      try:
133        # Fetch from Rietveld but only retry once with one second delay since
134        # there might be many revisions.
135        body = self.ReadURL(cl_url, wait_plan=[1])
136      except urllib2.URLError:  # pragma: no cover
137        pass
138    return body
139
140  def RunStep(self):
141    self["date"] = self.GetDate()
142    output = "%s: Version %s\n\n" % (self["date"], self["version"])
143    TextToFile(output, self.Config("CHANGELOG_ENTRY_FILE"))
144    commits = self.GitLog(format="%H",
145        git_hash="%s..%s" % (self["last_push_master"],
146                             self["push_hash"]))
147
148    # Cache raw commit messages.
149    commit_messages = [
150      [
151        self.GitLog(n=1, format="%s", git_hash=commit),
152        self.Reload(self.GitLog(n=1, format="%B", git_hash=commit)),
153        self.GitLog(n=1, format="%an", git_hash=commit),
154      ] for commit in commits.splitlines()
155    ]
156
157    # Auto-format commit messages.
158    body = MakeChangeLogBody(commit_messages, auto_format=True)
159    AppendToFile(body, self.Config("CHANGELOG_ENTRY_FILE"))
160
161    msg = ("        Performance and stability improvements on all platforms."
162           "\n#\n# The change log above is auto-generated. Please review if "
163           "all relevant\n# commit messages from the list below are included."
164           "\n# All lines starting with # will be stripped.\n#\n")
165    AppendToFile(msg, self.Config("CHANGELOG_ENTRY_FILE"))
166
167    # Include unformatted commit messages as a reference in a comment.
168    comment_body = MakeComment(MakeChangeLogBody(commit_messages))
169    AppendToFile(comment_body, self.Config("CHANGELOG_ENTRY_FILE"))
170
171
172class EditChangeLog(Step):
173  MESSAGE = "Edit ChangeLog entry."
174
175  def RunStep(self):
176    print ("Please press <Return> to have your EDITOR open the ChangeLog "
177           "entry, then edit its contents to your liking. When you're done, "
178           "save the file and exit your EDITOR. ")
179    self.ReadLine(default="")
180    self.Editor(self.Config("CHANGELOG_ENTRY_FILE"))
181
182    # Strip comments and reformat with correct indentation.
183    changelog_entry = FileToText(self.Config("CHANGELOG_ENTRY_FILE")).rstrip()
184    changelog_entry = StripComments(changelog_entry)
185    changelog_entry = "\n".join(map(Fill80, changelog_entry.splitlines()))
186    changelog_entry = changelog_entry.lstrip()
187
188    if changelog_entry == "":  # pragma: no cover
189      self.Die("Empty ChangeLog entry.")
190
191    # Safe new change log for adding it later to the candidates patch.
192    TextToFile(changelog_entry, self.Config("CHANGELOG_ENTRY_FILE"))
193
194
195class StragglerCommits(Step):
196  MESSAGE = ("Fetch straggler commits that sneaked in since this script was "
197             "started.")
198
199  def RunStep(self):
200    self.vc.Fetch()
201    self.GitCheckout(self.vc.RemoteMasterBranch())
202
203
204class SquashCommits(Step):
205  MESSAGE = "Squash commits into one."
206
207  def RunStep(self):
208    # Instead of relying on "git rebase -i", we'll just create a diff, because
209    # that's easier to automate.
210    TextToFile(self.GitDiff(self.vc.RemoteCandidateBranch(),
211                            self["push_hash"]),
212               self.Config("PATCH_FILE"))
213
214    # Convert the ChangeLog entry to commit message format.
215    text = FileToText(self.Config("CHANGELOG_ENTRY_FILE"))
216
217    # Remove date and trailing white space.
218    text = re.sub(r"^%s: " % self["date"], "", text.rstrip())
219
220    # Show the used master hash in the commit message.
221    suffix = PUSH_MSG_GIT_SUFFIX % self["push_hash"]
222    text = MSub(r"^(Version \d+\.\d+\.\d+)$", "\\1%s" % suffix, text)
223
224    # Remove indentation and merge paragraphs into single long lines, keeping
225    # empty lines between them.
226    def SplitMapJoin(split_text, fun, join_text):
227      return lambda text: join_text.join(map(fun, text.split(split_text)))
228    strip = lambda line: line.strip()
229    text = SplitMapJoin("\n\n", SplitMapJoin("\n", strip, " "), "\n\n")(text)
230
231    if not text:  # pragma: no cover
232      self.Die("Commit message editing failed.")
233    self["commit_title"] = text.splitlines()[0]
234    TextToFile(text, self.Config("COMMITMSG_FILE"))
235
236
237class NewBranch(Step):
238  MESSAGE = "Create a new branch from candidates."
239
240  def RunStep(self):
241    self.GitCreateBranch(self.Config("CANDIDATESBRANCH"),
242                         self.vc.RemoteCandidateBranch())
243
244
245class ApplyChanges(Step):
246  MESSAGE = "Apply squashed changes."
247
248  def RunStep(self):
249    self.ApplyPatch(self.Config("PATCH_FILE"))
250    os.remove(self.Config("PATCH_FILE"))
251    # The change log has been modified by the patch. Reset it to the version
252    # on candidates and apply the exact changes determined by this
253    # PrepareChangeLog step above.
254    self.GitCheckoutFile(CHANGELOG_FILE, self.vc.RemoteCandidateBranch())
255    # The version file has been modified by the patch. Reset it to the version
256    # on candidates.
257    self.GitCheckoutFile(VERSION_FILE, self.vc.RemoteCandidateBranch())
258
259
260class CommitSquash(Step):
261  MESSAGE = "Commit to local candidates branch."
262
263  def RunStep(self):
264    # Make a first commit with a slightly different title to not confuse
265    # the tagging.
266    msg = FileToText(self.Config("COMMITMSG_FILE")).splitlines()
267    msg[0] = msg[0].replace("(based on", "(squashed - based on")
268    self.GitCommit(message = "\n".join(msg))
269
270
271class PrepareVersionBranch(Step):
272  MESSAGE = "Prepare new branch to commit version and changelog file."
273
274  def RunStep(self):
275    self.GitCheckout("master")
276    self.Git("fetch")
277    self.GitDeleteBranch(self.Config("CANDIDATESBRANCH"))
278    self.GitCreateBranch(self.Config("CANDIDATESBRANCH"),
279                         self.vc.RemoteCandidateBranch())
280
281
282class AddChangeLog(Step):
283  MESSAGE = "Add ChangeLog changes to candidates branch."
284
285  def RunStep(self):
286    changelog_entry = FileToText(self.Config("CHANGELOG_ENTRY_FILE"))
287    old_change_log = FileToText(os.path.join(self.default_cwd, CHANGELOG_FILE))
288    new_change_log = "%s\n\n\n%s" % (changelog_entry, old_change_log)
289    TextToFile(new_change_log, os.path.join(self.default_cwd, CHANGELOG_FILE))
290    os.remove(self.Config("CHANGELOG_ENTRY_FILE"))
291
292
293class SetVersion(Step):
294  MESSAGE = "Set correct version for candidates."
295
296  def RunStep(self):
297    self.SetVersion(os.path.join(self.default_cwd, VERSION_FILE), "new_")
298
299
300class CommitCandidate(Step):
301  MESSAGE = "Commit version and changelog to local candidates branch."
302
303  def RunStep(self):
304    self.GitCommit(file_name = self.Config("COMMITMSG_FILE"))
305    os.remove(self.Config("COMMITMSG_FILE"))
306
307
308class SanityCheck(Step):
309  MESSAGE = "Sanity check."
310
311  def RunStep(self):
312    # TODO(machenbach): Run presubmit script here as it is now missing in the
313    # prepare push process.
314    if not self.Confirm("Please check if your local checkout is sane: Inspect "
315        "%s, compile, run tests. Do you want to commit this new candidates "
316        "revision to the repository?" % VERSION_FILE):
317      self.Die("Execution canceled.")  # pragma: no cover
318
319
320class Land(Step):
321  MESSAGE = "Land the patch."
322
323  def RunStep(self):
324    self.vc.CLLand()
325
326
327class TagRevision(Step):
328  MESSAGE = "Tag the new revision."
329
330  def RunStep(self):
331    self.vc.Tag(
332        self["version"], self.vc.RemoteCandidateBranch(), self["commit_title"])
333
334
335class CleanUp(Step):
336  MESSAGE = "Done!"
337
338  def RunStep(self):
339    print("Congratulations, you have successfully created the candidates "
340          "revision %s."
341          % self["version"])
342
343    self.CommonCleanup()
344    if self.Config("CANDIDATESBRANCH") != self["current_branch"]:
345      self.GitDeleteBranch(self.Config("CANDIDATESBRANCH"))
346
347
348class PushToCandidates(ScriptsBase):
349  def _PrepareOptions(self, parser):
350    group = parser.add_mutually_exclusive_group()
351    group.add_argument("-f", "--force",
352                      help="Don't prompt the user.",
353                      default=False, action="store_true")
354    group.add_argument("-m", "--manual",
355                      help="Prompt the user at every important step.",
356                      default=False, action="store_true")
357    parser.add_argument("-b", "--last-master",
358                        help=("The git commit ID of the last master "
359                              "revision that was pushed to candidates. This is"
360                              " used for the auto-generated ChangeLog entry."))
361    parser.add_argument("-l", "--last-push",
362                        help="The git commit ID of the last candidates push.")
363    parser.add_argument("-R", "--revision",
364                        help="The git commit ID to push (defaults to HEAD).")
365
366  def _ProcessOptions(self, options):  # pragma: no cover
367    if not options.manual and not options.reviewer:
368      print "A reviewer (-r) is required in (semi-)automatic mode."
369      return False
370    if not options.manual and not options.author:
371      print "Specify your chromium.org email with -a in (semi-)automatic mode."
372      return False
373
374    options.tbr_commit = not options.manual
375    return True
376
377  def _Config(self):
378    return {
379      "BRANCHNAME": "prepare-push",
380      "CANDIDATESBRANCH": "candidates-push",
381      "PERSISTFILE_BASENAME": "/tmp/v8-push-to-candidates-tempfile",
382      "CHANGELOG_ENTRY_FILE":
383          "/tmp/v8-push-to-candidates-tempfile-changelog-entry",
384      "PATCH_FILE": "/tmp/v8-push-to-candidates-tempfile-patch-file",
385      "COMMITMSG_FILE": "/tmp/v8-push-to-candidates-tempfile-commitmsg",
386    }
387
388  def _Steps(self):
389    return [
390      Preparation,
391      FreshBranch,
392      PreparePushRevision,
393      IncrementVersion,
394      DetectLastRelease,
395      PrepareChangeLog,
396      EditChangeLog,
397      StragglerCommits,
398      SquashCommits,
399      NewBranch,
400      ApplyChanges,
401      CommitSquash,
402      SanityCheck,
403      Land,
404      PrepareVersionBranch,
405      AddChangeLog,
406      SetVersion,
407      CommitCandidate,
408      Land,
409      TagRevision,
410      CleanUp,
411    ]
412
413
414if __name__ == "__main__":  # pragma: no cover
415  sys.exit(PushToCandidates().Run())
416