keep a running tally of consecutive number of successful or failed builds

This CL adds a google-storage backed streak counter, which counts the
number of consecutive passed or failed builds for each commit queue
builder. This is done by extending the functionality of GSCounter, and
adding a step to ReportStage which updates the gs-backed counter.

BUG=chromium:288741
TEST=Unit tests pass. cbuildbot --buildbot --debug

Change-Id: Ib069f8559dfb6b756c6f30ee73b929ab9dd0294f
Reviewed-on: https://chromium-review.googlesource.com/175142
Reviewed-by: Aviv Keshet <akeshet@chromium.org>
Tested-by: Aviv Keshet <akeshet@chromium.org>
Commit-Queue: Aviv Keshet <akeshet@chromium.org>
diff --git a/buildbot/cbuildbot_stages.py b/buildbot/cbuildbot_stages.py
index bfb043a..d98a12f 100644
--- a/buildbot/cbuildbot_stages.py
+++ b/buildbot/cbuildbot_stages.py
@@ -349,9 +349,9 @@
     for entry in results_lib.Results.Get():
       timestr = datetime.timedelta(seconds=math.ceil(entry.time))
       if entry.result in results_lib.Results.NON_FAILURE_TYPES:
-        status = 'passed'
+        status = constants.FINAL_STATUS_PASSED
       else:
-        status = 'failed'
+        status = constants.FINAL_STATUS_FAILED
       metadata['results'].append({
           'name': entry.name,
           'status': status,
@@ -3218,6 +3218,39 @@
     self._sync_instance = sync_instance
     self._completion_instance = completion_instance
 
+  def _UpdateStreakCounter(self, final_status, counter_name,
+                           dry_run=False):
+    """Update the given streak counter based on the final status of build.
+
+    A streak counter counts the number of consecutive passes or failures of
+    a particular builder. Consecutive passes are indicated by a positive value,
+    consecutive failures by a negative value.
+
+    Args:
+      final_status: String indicating final status of build,
+                    constants.FINAL_STATUS_PASSED indicating success.
+      counter_name: Name of counter to increment, typically the name of the
+                    build config.
+      dry_run: Pretend to update counter only. Default: False.
+
+    Returns:
+      The new value of the streak counter.
+    """
+    gs_ctx = gs.GSContext(dry_run=dry_run)
+    counter_url = os.path.join(constants.MANIFEST_VERSIONS_GS_URL,
+                               constants.STREAK_COUNTERS,
+                               counter_name)
+    gs_counter = gs.GSCounter(gs_ctx, counter_url)
+
+    if final_status == constants.FINAL_STATUS_PASSED:
+      streak_value = gs_counter.StreakIncrement()
+    else:
+      streak_value = gs_counter.StreakDecrement()
+
+    logging.debug('Streak counter value is %s', streak_value)
+    return streak_value
+
+
   def PerformStage(self):
     acl = ArchivingStage.GetUploadACL(self._build_config)
     archive_urls = {}
@@ -3237,14 +3270,21 @@
 
       # Generate the final metadata before we look at the uploaded list.
       if results_lib.Results.BuildSucceededSoFar():
-        final_status = 'passed'
+        final_status = constants.FINAL_STATUS_PASSED
       else:
-        final_status = 'failed'
+        final_status = constants.FINAL_STATUS_FAILED
       archive_stage.UploadMetadata(final_status=final_status,
                                    sync_instance=self._sync_instance,
                                    completion_instance=
                                    self._completion_instance)
 
+      # If this was a Commit Queue build, update the streak counter
+      if (self._sync_instance and
+          isinstance(self._sync_instance, CommitQueueSyncStage)):
+        self._UpdateStreakCounter(final_status=final_status,
+                                  counter_name=board_config.name,
+                                  dry_run=archive_stage.debug)
+
       # Generate the index page needed for public reading.
       uploaded = os.path.join(path, commands.UPLOADED_LIST_FILENAME)
       if not os.path.exists(uploaded):
diff --git a/buildbot/cbuildbot_stages_unittest.py b/buildbot/cbuildbot_stages_unittest.py
index 4d6b0ab..2531e51 100755
--- a/buildbot/cbuildbot_stages_unittest.py
+++ b/buildbot/cbuildbot_stages_unittest.py
@@ -1072,11 +1072,11 @@
     results_skipped = ('SignerTests',)
     for result in json_data['results']:
       if result['name'] in results_passed:
-        self.assertEquals(result['status'], 'passed')
+        self.assertEquals(result['status'], constants.FINAL_STATUS_PASSED)
       elif result['name'] in results_failed:
-        self.assertEquals(result['status'], 'failed')
+        self.assertEquals(result['status'], constants.FINAL_STATUS_FAILED)
       elif result['name'] in results_skipped:
-        self.assertEquals(result['status'], 'passed')
+        self.assertEquals(result['status'], constants.FINAL_STATUS_PASSED)
         self.assertTrue('skipped' in result['summary'].lower())
 
     # The buildtools manifest doesn't have any overlays. In this case, we can't
@@ -1659,7 +1659,8 @@
 
   def setUp(self):
     for cmd in ((osutils, 'ReadFile'), (osutils, 'WriteFile'),
-                (commands, 'UploadArchivedFile'),):
+                (commands, 'UploadArchivedFile'),
+                (stages.ReportStage, '_UpdateStreakCounter')):
       self.StartPatcher(mock.patch.object(*cmd, autospec=True))
     self.StartPatcher(ArchiveStageMock())
     self.cq = CLStatusMock()
diff --git a/buildbot/constants.py b/buildbot/constants.py
index a6ba6f9..a8233d4 100644
--- a/buildbot/constants.py
+++ b/buildbot/constants.py
@@ -30,6 +30,11 @@
 SDK_TOOLCHAINS_OUTPUT = 'tmp/toolchain-pkgs'
 AUTOTEST_BUILD_PATH = 'usr/local/build/autotest'
 
+# TODO: Eliminate these or merge with manifest_version.py:STATUS_PASSED
+# crbug.com/318930
+FINAL_STATUS_PASSED = 'passed'
+FINAL_STATUS_FAILED = 'failed'
+
 # Re-execution API constants.
 # Used by --resume and --bootstrap to decipher which options they
 # can pass to the target cbuildbot (since it may not have that
@@ -122,6 +127,8 @@
 MANIFEST_VERSIONS_GS_URL = 'gs://chromeos-manifest-versions'
 TRASH_BUCKET = 'gs://chromeos-throw-away-bucket'
 
+STREAK_COUNTERS = 'streak_counters'
+
 PATCH_BRANCH = 'patch_branch'
 STABLE_EBUILD_BRANCH = 'stabilizing_branch'
 MERGE_BRANCH = 'merge_branch'
diff --git a/lib/gs.py b/lib/gs.py
index a9cc938..ffccc5c 100644
--- a/lib/gs.py
+++ b/lib/gs.py
@@ -93,12 +93,21 @@
     except GSNoSuchKey:
       return 0
 
-  def Increment(self):
-    """Atomically increment the counter."""
+  def AtomicCounterOperation(self, default_value, operation):
+    """Atomically set the counter value using |operation|.
+
+    Args:
+      default_value: Default value to use for counter, if counter
+                     does not exist.
+      operation: Function that takes the current counter value as a
+                 parameter, and returns the new desired value.
+    Returns:
+      The new counter value. None if value could not be set.
+    """
     generation, _ = self.ctx.GetGeneration(self.path)
     for _ in xrange(self.ctx.retries + 1):
       try:
-        value = 1 if generation == 0 else self.Get() + 1
+        value = default_value if generation == 0 else operation(self.Get())
         self.ctx.Copy('-', self.path, input=str(value), version=generation)
         return value
       except (GSContextPreconditionFailed, GSNoSuchKey):
@@ -111,6 +120,42 @@
           raise
         generation = new_generation
 
+  def Increment(self):
+    """Increment the counter.
+
+    Returns:
+      The new counter value. None if value could not be set.
+    """
+    return self.AtomicCounterOperation(1, lambda x: x + 1)
+
+  def Decrement(self):
+    """Decrement the counter.
+
+    Returns:
+      The new counter value. None if value could not be set."""
+    return self.AtomicCounterOperation(-1, lambda x: x - 1)
+
+  def Reset(self):
+    """Reset the counter to zero.
+
+    Returns:
+      The new counter value. None if value could not be set."""
+    return self.AtomicCounterOperation(0, lambda x: 0)
+
+  def StreakIncrement(self):
+    """Increment the counter if it is positive, otherwise set it to 1.
+
+    Returns:
+      The new counter value. None if value could not be set."""
+    return self.AtomicCounterOperation(1, lambda x: x + 1 if x > 0 else 1)
+
+  def StreakDecrement(self):
+    """Decrement the counter if it is negative, otherwise set it to -1.
+
+    Returns:
+      The new counter value. None if value could not be set."""
+    return self.AtomicCounterOperation(-1, lambda x: x - 1 if x < 0 else -1)
+
 
 class GSContext(object):
   """A class to wrap common google storage operations."""