1#!/usr/bin/env python
2# Copyright 2015 the V8 project authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import os
8import sys
9import tempfile
10import urllib2
11
12from common_includes import *
13
14class Preparation(Step):
15  MESSAGE = "Preparation."
16
17  def RunStep(self):
18    fetchspecs = [
19      "+refs/heads/*:refs/heads/*",
20      "+refs/pending/*:refs/pending/*",
21      "+refs/pending-tags/*:refs/pending-tags/*",
22    ]
23    self.Git("fetch origin %s" % " ".join(fetchspecs))
24    self.GitCheckout("origin/master")
25    self.DeleteBranch("work-branch")
26
27
28class PrepareBranchRevision(Step):
29  MESSAGE = "Check from which revision to branch off."
30
31  def RunStep(self):
32    self["push_hash"] = (self._options.revision or
33                         self.GitLog(n=1, format="%H", branch="origin/master"))
34    assert self["push_hash"]
35    print "Release revision %s" % self["push_hash"]
36
37
38class IncrementVersion(Step):
39  MESSAGE = "Increment version number."
40
41  def RunStep(self):
42    latest_version = self.GetLatestVersion()
43
44    # The version file on master can be used to bump up major/minor at
45    # branch time.
46    self.GitCheckoutFile(VERSION_FILE, self.vc.RemoteMasterBranch())
47    self.ReadAndPersistVersion("master_")
48    master_version = self.ArrayToVersion("master_")
49
50    # Use the highest version from master or from tags to determine the new
51    # version.
52    authoritative_version = sorted(
53        [master_version, latest_version], key=SortingKey)[1]
54    self.StoreVersion(authoritative_version, "authoritative_")
55
56    # Variables prefixed with 'new_' contain the new version numbers for the
57    # ongoing candidates push.
58    self["new_major"] = self["authoritative_major"]
59    self["new_minor"] = self["authoritative_minor"]
60    self["new_build"] = str(int(self["authoritative_build"]) + 1)
61
62    # Make sure patch level is 0 in a new push.
63    self["new_patch"] = "0"
64
65    # The new version is not a candidate.
66    self["new_candidate"] = "0"
67
68    self["version"] = "%s.%s.%s" % (self["new_major"],
69                                    self["new_minor"],
70                                    self["new_build"])
71
72    print ("Incremented version to %s" % self["version"])
73
74
75class DetectLastRelease(Step):
76  MESSAGE = "Detect commit ID of last release base."
77
78  def RunStep(self):
79    self["last_push_master"] = self.GetLatestReleaseBase()
80
81
82class PrepareChangeLog(Step):
83  MESSAGE = "Prepare raw ChangeLog entry."
84
85  def Reload(self, body):
86    """Attempts to reload the commit message from rietveld in order to allow
87    late changes to the LOG flag. Note: This is brittle to future changes of
88    the web page name or structure.
89    """
90    match = re.search(r"^Review URL: https://codereview\.chromium\.org/(\d+)$",
91                      body, flags=re.M)
92    if match:
93      cl_url = ("https://codereview.chromium.org/%s/description"
94                % match.group(1))
95      try:
96        # Fetch from Rietveld but only retry once with one second delay since
97        # there might be many revisions.
98        body = self.ReadURL(cl_url, wait_plan=[1])
99      except urllib2.URLError:  # pragma: no cover
100        pass
101    return body
102
103  def RunStep(self):
104    self["date"] = self.GetDate()
105    output = "%s: Version %s\n\n" % (self["date"], self["version"])
106    TextToFile(output, self.Config("CHANGELOG_ENTRY_FILE"))
107    commits = self.GitLog(format="%H",
108        git_hash="%s..%s" % (self["last_push_master"],
109                             self["push_hash"]))
110
111    # Cache raw commit messages.
112    commit_messages = [
113      [
114        self.GitLog(n=1, format="%s", git_hash=commit),
115        self.Reload(self.GitLog(n=1, format="%B", git_hash=commit)),
116        self.GitLog(n=1, format="%an", git_hash=commit),
117      ] for commit in commits.splitlines()
118    ]
119
120    # Auto-format commit messages.
121    body = MakeChangeLogBody(commit_messages, auto_format=True)
122    AppendToFile(body, self.Config("CHANGELOG_ENTRY_FILE"))
123
124    msg = ("        Performance and stability improvements on all platforms."
125           "\n#\n# The change log above is auto-generated. Please review if "
126           "all relevant\n# commit messages from the list below are included."
127           "\n# All lines starting with # will be stripped.\n#\n")
128    AppendToFile(msg, self.Config("CHANGELOG_ENTRY_FILE"))
129
130    # Include unformatted commit messages as a reference in a comment.
131    comment_body = MakeComment(MakeChangeLogBody(commit_messages))
132    AppendToFile(comment_body, self.Config("CHANGELOG_ENTRY_FILE"))
133
134
135class EditChangeLog(Step):
136  MESSAGE = "Edit ChangeLog entry."
137
138  def RunStep(self):
139    print ("Please press <Return> to have your EDITOR open the ChangeLog "
140           "entry, then edit its contents to your liking. When you're done, "
141           "save the file and exit your EDITOR. ")
142    self.ReadLine(default="")
143    self.Editor(self.Config("CHANGELOG_ENTRY_FILE"))
144
145    # Strip comments and reformat with correct indentation.
146    changelog_entry = FileToText(self.Config("CHANGELOG_ENTRY_FILE")).rstrip()
147    changelog_entry = StripComments(changelog_entry)
148    changelog_entry = "\n".join(map(Fill80, changelog_entry.splitlines()))
149    changelog_entry = changelog_entry.lstrip()
150
151    if changelog_entry == "":  # pragma: no cover
152      self.Die("Empty ChangeLog entry.")
153
154    # Safe new change log for adding it later to the candidates patch.
155    TextToFile(changelog_entry, self.Config("CHANGELOG_ENTRY_FILE"))
156
157
158class MakeBranch(Step):
159  MESSAGE = "Create the branch."
160
161  def RunStep(self):
162    self.Git("reset --hard origin/master")
163    self.Git("checkout -b work-branch %s" % self["push_hash"])
164    self.GitCheckoutFile(CHANGELOG_FILE, self["latest_version"])
165    self.GitCheckoutFile(VERSION_FILE, self["latest_version"])
166    self.GitCheckoutFile(WATCHLISTS_FILE, self["latest_version"])
167
168
169class AddChangeLog(Step):
170  MESSAGE = "Add ChangeLog changes to release branch."
171
172  def RunStep(self):
173    changelog_entry = FileToText(self.Config("CHANGELOG_ENTRY_FILE"))
174    old_change_log = FileToText(os.path.join(self.default_cwd, CHANGELOG_FILE))
175    new_change_log = "%s\n\n\n%s" % (changelog_entry, old_change_log)
176    TextToFile(new_change_log, os.path.join(self.default_cwd, CHANGELOG_FILE))
177
178
179class SetVersion(Step):
180  MESSAGE = "Set correct version for candidates."
181
182  def RunStep(self):
183    self.SetVersion(os.path.join(self.default_cwd, VERSION_FILE), "new_")
184
185
186class EnableMergeWatchlist(Step):
187  MESSAGE = "Enable watchlist entry for merge notifications."
188
189  def RunStep(self):
190    old_watchlist_content = FileToText(os.path.join(self.default_cwd,
191                                                    WATCHLISTS_FILE))
192    new_watchlist_content = re.sub("(# 'v8-merges@googlegroups\.com',)",
193                                   "'v8-merges@googlegroups.com',",
194                                   old_watchlist_content)
195    TextToFile(new_watchlist_content, os.path.join(self.default_cwd,
196                                                   WATCHLISTS_FILE))
197
198
199class CommitBranch(Step):
200  MESSAGE = "Commit version and changelog to new branch."
201
202  def RunStep(self):
203    # Convert the ChangeLog entry to commit message format.
204    text = FileToText(self.Config("CHANGELOG_ENTRY_FILE"))
205
206    # Remove date and trailing white space.
207    text = re.sub(r"^%s: " % self["date"], "", text.rstrip())
208
209    # Remove indentation and merge paragraphs into single long lines, keeping
210    # empty lines between them.
211    def SplitMapJoin(split_text, fun, join_text):
212      return lambda text: join_text.join(map(fun, text.split(split_text)))
213    text = SplitMapJoin(
214        "\n\n", SplitMapJoin("\n", str.strip, " "), "\n\n")(text)
215
216    if not text:  # pragma: no cover
217      self.Die("Commit message editing failed.")
218    self["commit_title"] = text.splitlines()[0]
219    TextToFile(text, self.Config("COMMITMSG_FILE"))
220
221    self.GitCommit(file_name = self.Config("COMMITMSG_FILE"))
222    os.remove(self.Config("COMMITMSG_FILE"))
223    os.remove(self.Config("CHANGELOG_ENTRY_FILE"))
224
225
226class FixBrokenTag(Step):
227  MESSAGE = "Check for a missing tag and fix that instead."
228
229  def RunStep(self):
230    commit = None
231    try:
232      commit = self.GitLog(
233          n=1, format="%H",
234          grep=self["commit_title"],
235          branch="origin/%s" % self["version"],
236      )
237    except GitFailedException:
238      # In the normal case, the remote doesn't exist yet and git will fail.
239      pass
240    if commit:
241      print "Found %s. Trying to repair tag and bail out." % self["version"]
242      self.Git("tag %s %s" % (self["version"], commit))
243      self.Git("push origin refs/tags/%s" % self["version"])
244      return True
245
246
247class PushBranch(Step):
248  MESSAGE = "Push changes."
249
250  def RunStep(self):
251    pushspecs = [
252      "refs/heads/work-branch:refs/pending/heads/%s" % self["version"],
253      "%s:refs/pending-tags/heads/%s" % (self["push_hash"], self["version"]),
254      "%s:refs/heads/%s" % (self["push_hash"], self["version"]),
255    ]
256    cmd = "push origin %s" % " ".join(pushspecs)
257    if self._options.dry_run:
258      print "Dry run. Command:\ngit %s" % cmd
259    else:
260      self.Git(cmd)
261
262
263class TagRevision(Step):
264  MESSAGE = "Tag the new revision."
265
266  def RunStep(self):
267    if self._options.dry_run:
268      print ("Dry run. Tagging \"%s\" with %s" %
269             (self["commit_title"], self["version"]))
270    else:
271      self.vc.Tag(self["version"],
272                  "origin/%s" % self["version"],
273                  self["commit_title"])
274
275
276class CleanUp(Step):
277  MESSAGE = "Done!"
278
279  def RunStep(self):
280    print("Congratulations, you have successfully created version %s."
281          % self["version"])
282
283    self.GitCheckout("origin/master")
284    self.DeleteBranch("work-branch")
285    self.Git("gc")
286
287
288class CreateRelease(ScriptsBase):
289  def _PrepareOptions(self, parser):
290    group = parser.add_mutually_exclusive_group()
291    group.add_argument("-f", "--force",
292                      help="Don't prompt the user.",
293                      default=True, action="store_true")
294    group.add_argument("-m", "--manual",
295                      help="Prompt the user at every important step.",
296                      default=False, action="store_true")
297    parser.add_argument("-R", "--revision",
298                        help="The git commit ID to push (defaults to HEAD).")
299
300  def _ProcessOptions(self, options):  # pragma: no cover
301    if not options.author or not options.reviewer:
302      print "Reviewer (-r) and author (-a) are required."
303      return False
304    return True
305
306  def _Config(self):
307    return {
308      "PERSISTFILE_BASENAME": "/tmp/create-releases-tempfile",
309      "CHANGELOG_ENTRY_FILE":
310          "/tmp/v8-create-releases-tempfile-changelog-entry",
311      "COMMITMSG_FILE": "/tmp/v8-create-releases-tempfile-commitmsg",
312    }
313
314  def _Steps(self):
315    return [
316      Preparation,
317      PrepareBranchRevision,
318      IncrementVersion,
319      DetectLastRelease,
320      PrepareChangeLog,
321      EditChangeLog,
322      MakeBranch,
323      AddChangeLog,
324      SetVersion,
325      EnableMergeWatchlist,
326      CommitBranch,
327      FixBrokenTag,
328      PushBranch,
329      TagRevision,
330      CleanUp,
331    ]
332
333
334if __name__ == "__main__":  # pragma: no cover
335  sys.exit(CreateRelease().Run())
336