Merge the two bionicbb services into one.

Change-Id: I6490da1ec96b2e24b330296950be84424e11bd35
diff --git a/tools/bionicbb/README.md b/tools/bionicbb/README.md
index 4d3291f..a285984 100644
--- a/tools/bionicbb/README.md
+++ b/tools/bionicbb/README.md
@@ -8,6 +8,7 @@
 ------------
 
  * Python 2.7
+ * [Advanced Python Scheduler](https://apscheduler.readthedocs.org/en/latest/)
  * [Flask](http://flask.pocoo.org/)
  * [Google API Client Library](https://developers.google.com/api-client-library/python/start/installation)
  * [jenkinsapi](https://pypi.python.org/pypi/jenkinsapi)
diff --git a/tools/bionicbb/build_listener.py b/tools/bionicbb/bionicbb.py
similarity index 90%
rename from tools/bionicbb/build_listener.py
rename to tools/bionicbb/bionicbb.py
index fa55d37..20d6460 100644
--- a/tools/bionicbb/build_listener.py
+++ b/tools/bionicbb/bionicbb.py
@@ -16,11 +16,15 @@
 #
 import json
 import logging
+import os
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from flask import Flask, request
 import requests
 
 import gerrit
+import tasks
 
-from flask import Flask, request
 app = Flask(__name__)
 
 
@@ -115,4 +119,12 @@
 
 
 if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
+
+    # Prevent the job from being rescheduled by the reloader.
+    if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
+        scheduler = BackgroundScheduler()
+        scheduler.start()
+        scheduler.add_job(tasks.get_and_process_jobs, 'interval', minutes=5)
+
     app.run(host='0.0.0.0', debug=True)
diff --git a/tools/bionicbb/gmail.py b/tools/bionicbb/gmail.py
new file mode 100644
index 0000000..f088ad6
--- /dev/null
+++ b/tools/bionicbb/gmail.py
@@ -0,0 +1,71 @@
+#
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import base64
+import httplib2
+
+import config
+
+
+def get_body(msg):
+    if 'attachmentId' in msg['payload']['body']:
+        raise NotImplementedError('Handling of messages contained in '
+                                  'attachments not yet implemented.')
+    b64_body = msg['payload']['body']['data']
+    return base64.urlsafe_b64decode(b64_body.encode('ASCII'))
+
+
+def build_service():
+    from apiclient.discovery import build
+    from oauth2client.client import flow_from_clientsecrets
+    from oauth2client.file import Storage
+    from oauth2client.tools import run
+
+    OAUTH_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
+    STORAGE = Storage('oauth.storage')
+
+    # Start the OAuth flow to retrieve credentials
+    flow = flow_from_clientsecrets(config.client_secret_file,
+                                   scope=OAUTH_SCOPE)
+    http = httplib2.Http()
+
+    # Try to retrieve credentials from storage or run the flow to generate them
+    credentials = STORAGE.get()
+    if credentials is None or credentials.invalid:
+        credentials = run(flow, STORAGE, http=http)
+
+    http = credentials.authorize(http)
+    return build('gmail', 'v1', http=http)
+
+
+def get_gerrit_label(labels):
+    for label in labels:
+        if label['name'] == 'gerrit':
+            return label['id']
+    return None
+
+
+def get_all_messages(service, label):
+    msgs = []
+    response = service.users().messages().list(
+        userId='me', labelIds=label).execute()
+    if 'messages' in response:
+        msgs.extend(response['messages'])
+    while 'nextPageToken' in response:
+        page_token = response['nextPageToken']
+        response = service.users().messages().list(
+            userId='me', pageToken=page_token).execute()
+        msgs.extend(response['messages'])
+    return msgs
diff --git a/tools/bionicbb/gmail_listener.py b/tools/bionicbb/gmail_listener.py
deleted file mode 100644
index 134258a..0000000
--- a/tools/bionicbb/gmail_listener.py
+++ /dev/null
@@ -1,354 +0,0 @@
-#!/usr/bin/env python2
-#
-# Copyright (C) 2015 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the 'License');
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an 'AS IS' BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-import base64
-import httplib
-import httplib2
-import jenkinsapi
-import json
-import logging
-import os
-import re
-import requests
-import socket
-import sys
-import time
-
-import apiclient.errors
-
-import config
-import gerrit
-
-
-class GmailError(RuntimeError):
-    def __init__(self, message):
-        super(GmailError, self).__init__(message)
-
-
-def get_gerrit_label(labels):
-    for label in labels:
-        if label['name'] == 'gerrit':
-            return label['id']
-    return None
-
-
-def get_headers(msg):
-    headers = {}
-    for hdr in msg['payload']['headers']:
-        headers[hdr['name']] = hdr['value']
-    return headers
-
-
-def is_untrusted_committer(change_id, patch_set):
-    # TODO(danalbert): Needs to be based on the account that made the comment.
-    commit = gerrit.get_commit(change_id, patch_set)
-    committer = commit['committer']['email']
-    return not committer.endswith('@google.com')
-
-
-def contains_cleanspec(change_id, patch_set):
-    files = gerrit.get_files_for_revision(change_id, patch_set)
-    return 'CleanSpec.mk' in [os.path.basename(f) for f in files]
-
-
-def contains_bionicbb(change_id, patch_set):
-    files = gerrit.get_files_for_revision(change_id, patch_set)
-    return any('tools/bionicbb' in f for f in files)
-
-
-def should_skip_build(info):
-    if info['MessageType'] not in ('newchange', 'newpatchset', 'comment'):
-        raise ValueError('should_skip_build() is only valid for new '
-                         'changes, patch sets, and commits.')
-
-    change_id = info['Change-Id']
-    patch_set = info['PatchSet']
-
-    checks = [
-        is_untrusted_committer,
-        contains_cleanspec,
-        contains_bionicbb,
-    ]
-    for check in checks:
-        if check(change_id, patch_set):
-            return True
-    return False
-
-
-def build_service():
-    from apiclient.discovery import build
-    from oauth2client.client import flow_from_clientsecrets
-    from oauth2client.file import Storage
-    from oauth2client.tools import run
-
-    OAUTH_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
-    STORAGE = Storage('oauth.storage')
-
-    # Start the OAuth flow to retrieve credentials
-    flow = flow_from_clientsecrets(config.client_secret_file,
-                                   scope=OAUTH_SCOPE)
-    http = httplib2.Http()
-
-    # Try to retrieve credentials from storage or run the flow to generate them
-    credentials = STORAGE.get()
-    if credentials is None or credentials.invalid:
-        credentials = run(flow, STORAGE, http=http)
-
-    http = credentials.authorize(http)
-    return build('gmail', 'v1', http=http)
-
-
-def get_all_messages(service, label):
-    msgs = []
-    response = service.users().messages().list(
-        userId='me', labelIds=label).execute()
-    if 'messages' in response:
-        msgs.extend(response['messages'])
-    while 'nextPageToken' in response:
-        page_token = response['nextPageToken']
-        response = service.users().messages().list(
-            userId='me', pageToken=page_token).execute()
-        msgs.extend(response['messages'])
-    return msgs
-
-
-def get_body(msg):
-    if 'attachmentId' in msg['payload']['body']:
-        raise NotImplementedError('Handling of messages contained in '
-                                  'attachments not yet implemented.')
-    b64_body = msg['payload']['body']['data']
-    return base64.urlsafe_b64decode(b64_body.encode('ASCII'))
-
-
-def get_gerrit_info(body):
-    info = {}
-    gerrit_pattern = r'^Gerrit-(\S+): (.+)$'
-    for match in re.finditer(gerrit_pattern, body, flags=re.MULTILINE):
-        info[match.group(1)] = match.group(2).strip()
-    return info
-
-
-def clean_project(dry_run):
-    username = config.jenkins_credentials['username']
-    password = config.jenkins_credentials['password']
-    jenkins_url = config.jenkins_url
-    jenkins = jenkinsapi.api.Jenkins(jenkins_url, username, password)
-
-    build = 'clean-bionic-presubmit'
-    if build in jenkins:
-        if not dry_run:
-            job = jenkins[build].invoke()
-            url = job.get_build().baseurl
-        else:
-            url = 'DRY_RUN_URL'
-        logging.info('Cleaning: %s %s', build, url)
-    else:
-        logging.error('Failed to clean: could not find project %s', build)
-    return True
-
-
-def build_project(gerrit_info, dry_run, lunch_target=None):
-    project_to_jenkins_map = {
-        'platform/bionic': 'bionic-presubmit',
-        'platform/build': 'bionic-presubmit',
-        'platform/external/jemalloc': 'bionic-presubmit',
-        'platform/external/libcxx': 'bionic-presubmit',
-        'platform/external/libcxxabi': 'bionic-presubmit',
-        'platform/external/compiler-rt': 'bionic-presubmit',
-    }
-
-    username = config.jenkins_credentials['username']
-    password = config.jenkins_credentials['password']
-    jenkins_url = config.jenkins_url
-    jenkins = jenkinsapi.api.Jenkins(jenkins_url, username, password)
-
-    project = gerrit_info['Project']
-    change_id = gerrit_info['Change-Id']
-    if project in project_to_jenkins_map:
-        build = project_to_jenkins_map[project]
-    else:
-        build = 'bionic-presubmit'
-
-    if build in jenkins:
-        project_path = '/'.join(project.split('/')[1:])
-        if not project_path:
-            raise RuntimeError('bogus project: {}'.format(project))
-        if project_path.startswith('platform/'):
-            raise RuntimeError('Bad project mapping: {} => {}'.format(
-                project, project_path))
-        ref = gerrit.ref_for_change(change_id)
-        params = {
-            'REF': ref,
-            'CHANGE_ID': change_id,
-            'PROJECT': project_path
-        }
-        if lunch_target is not None:
-            params['LUNCH_TARGET'] = lunch_target
-        if not dry_run:
-            _ = jenkins[build].invoke(build_params=params)
-            # https://issues.jenkins-ci.org/browse/JENKINS-27256
-            # url = job.get_build().baseurl
-            url = 'URL UNAVAILABLE'
-        else:
-            url = 'DRY_RUN_URL'
-        logging.info('Building: %s => %s %s %s', project, build, url,
-                     change_id)
-    else:
-        logging.error('Unknown build: %s => %s %s', project, build, change_id)
-    return True
-
-
-def handle_change(gerrit_info, _, dry_run):
-    if should_skip_build(gerrit_info):
-        return True
-    return build_project(gerrit_info, dry_run)
-handle_newchange = handle_change
-handle_newpatchset = handle_change
-
-
-def drop_rejection(gerrit_info, dry_run):
-    request_data = {
-        'changeid': gerrit_info['Change-Id'],
-        'patchset': gerrit_info['PatchSet']
-    }
-    url = '{}/{}'.format(config.build_listener_url, 'drop-rejection')
-    headers = {'Content-Type': 'application/json;charset=UTF-8'}
-    if not dry_run:
-        try:
-            requests.post(url, headers=headers, data=json.dumps(request_data))
-        except requests.exceptions.ConnectionError as ex:
-            logging.error('Failed to drop rejection: %s', ex)
-            return False
-    logging.info('Dropped rejection: %s', gerrit_info['Change-Id'])
-    return True
-
-
-def handle_comment(gerrit_info, body, dry_run):
-    if 'Verified+1' in body:
-        drop_rejection(gerrit_info, dry_run)
-
-    if should_skip_build(gerrit_info):
-        return True
-
-    command_map = {
-        'clean': lambda: clean_project(dry_run),
-        'retry': lambda: build_project(gerrit_info, dry_run),
-
-        'arm': lambda: build_project(gerrit_info, dry_run,
-                                     lunch_target='aosp_arm-eng'),
-        'aarch64': lambda: build_project(gerrit_info, dry_run,
-                                         lunch_target='aosp_arm64-eng'),
-        'mips': lambda: build_project(gerrit_info, dry_run,
-                                      lunch_target='aosp_mips-eng'),
-        'mips64': lambda: build_project(gerrit_info, dry_run,
-                                        lunch_target='aosp_mips64-eng'),
-        'x86': lambda: build_project(gerrit_info, dry_run,
-                                     lunch_target='aosp_x86-eng'),
-        'x86_64': lambda: build_project(gerrit_info, dry_run,
-                                        lunch_target='aosp_x86_64-eng'),
-    }
-
-    def handle_unknown_command():
-        pass    # TODO(danalbert): should complain to the commenter.
-
-    commands = [match.group(1).strip() for match in
-                re.finditer(r'^bionicbb:\s*(.+)$', body, flags=re.MULTILINE)]
-
-    for command in commands:
-        if command in command_map:
-            command_map[command]()
-        else:
-            handle_unknown_command()
-
-    return True
-
-
-def skip_handler(gerrit_info, _, __):
-    logging.info('Skipping %s: %s', gerrit_info['MessageType'],
-                 gerrit_info['Change-Id'])
-    return True
-
-
-handle_abandon = skip_handler
-handle_merge_failed = skip_handler
-handle_merged = skip_handler
-handle_restore = skip_handler
-handle_revert = skip_handler
-
-
-def process_message(msg, dry_run):
-    try:
-        body = get_body(msg)
-        gerrit_info = get_gerrit_info(body)
-        if not gerrit_info:
-            logging.fatal('No Gerrit info found: %s', msg.subject)
-        msg_type = gerrit_info['MessageType']
-        handler = 'handle_{}'.format(
-            gerrit_info['MessageType'].replace('-', '_'))
-        if handler in globals():
-            return globals()[handler](gerrit_info, body, dry_run)
-        else:
-            logging.warning('MessageType %s unhandled.', msg_type)
-        return False
-    except NotImplementedError as ex:
-        logging.error("%s", ex)
-        return False
-    except gerrit.GerritError as ex:
-        change_id = gerrit_info['Change-Id']
-        logging.error('Gerrit error (%d): %s %s', ex.code, change_id, ex.url)
-        return ex.code == 404
-
-
-def main(argc, argv):
-    dry_run = False
-    if argc == 2 and argv[1] == '--dry-run':
-        dry_run = True
-    elif argc > 2:
-        sys.exit('usage: python {} [--dry-run]'.format(argv[0]))
-
-    gmail_service = build_service()
-    msg_service = gmail_service.users().messages()
-
-    while True:
-        try:
-            labels = gmail_service.users().labels().list(userId='me').execute()
-            if not labels['labels']:
-                raise GmailError('Could not retrieve Gmail labels')
-            label_id = get_gerrit_label(labels['labels'])
-            if not label_id:
-                raise GmailError('Could not find gerrit label')
-
-            for msg in get_all_messages(gmail_service, label_id):
-                msg = msg_service.get(userId='me', id=msg['id']).execute()
-                if process_message(msg, dry_run) and not dry_run:
-                    msg_service.trash(userId='me', id=msg['id']).execute()
-            time.sleep(60 * 5)
-        except GmailError as ex:
-            logging.error('Gmail error: %s', ex)
-            time.sleep(60 * 5)
-        except apiclient.errors.HttpError as ex:
-            logging.error('API Client HTTP error: %s', ex)
-            time.sleep(60 * 5)
-        except httplib.BadStatusLine:
-            pass
-        except httplib2.ServerNotFoundError:
-            pass
-        except socket.error:
-            pass
-
-
-if __name__ == '__main__':
-    main(len(sys.argv), sys.argv)
diff --git a/tools/bionicbb/presubmit.py b/tools/bionicbb/presubmit.py
new file mode 100644
index 0000000..cc6f3cc
--- /dev/null
+++ b/tools/bionicbb/presubmit.py
@@ -0,0 +1,203 @@
+#
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+from __future__ import absolute_import
+
+import json
+import logging
+import os.path
+import re
+import requests
+
+import jenkinsapi
+
+import gerrit
+
+import config
+
+
+def is_untrusted_committer(change_id, patch_set):
+    # TODO(danalbert): Needs to be based on the account that made the comment.
+    commit = gerrit.get_commit(change_id, patch_set)
+    committer = commit['committer']['email']
+    return not committer.endswith('@google.com')
+
+
+def contains_cleanspec(change_id, patch_set):
+    files = gerrit.get_files_for_revision(change_id, patch_set)
+    return 'CleanSpec.mk' in [os.path.basename(f) for f in files]
+
+
+def contains_bionicbb(change_id, patch_set):
+    files = gerrit.get_files_for_revision(change_id, patch_set)
+    return any('tools/bionicbb' in f for f in files)
+
+
+def should_skip_build(info):
+    if info['MessageType'] not in ('newchange', 'newpatchset', 'comment'):
+        raise ValueError('should_skip_build() is only valid for new '
+                         'changes, patch sets, and commits.')
+
+    change_id = info['Change-Id']
+    patch_set = info['PatchSet']
+
+    checks = [
+        is_untrusted_committer,
+        contains_cleanspec,
+        contains_bionicbb,
+    ]
+    for check in checks:
+        if check(change_id, patch_set):
+            return True
+    return False
+
+
+def clean_project(dry_run):
+    username = config.jenkins_credentials['username']
+    password = config.jenkins_credentials['password']
+    jenkins_url = config.jenkins_url
+    jenkins = jenkinsapi.api.Jenkins(jenkins_url, username, password)
+
+    build = 'clean-bionic-presubmit'
+    if build in jenkins:
+        if not dry_run:
+            job = jenkins[build].invoke()
+            url = job.get_build().baseurl
+        else:
+            url = 'DRY_RUN_URL'
+        logging.info('Cleaning: %s %s', build, url)
+    else:
+        logging.error('Failed to clean: could not find project %s', build)
+    return True
+
+
+def build_project(gerrit_info, dry_run, lunch_target=None):
+    project_to_jenkins_map = {
+        'platform/bionic': 'bionic-presubmit',
+        'platform/build': 'bionic-presubmit',
+        'platform/external/jemalloc': 'bionic-presubmit',
+        'platform/external/libcxx': 'bionic-presubmit',
+        'platform/external/libcxxabi': 'bionic-presubmit',
+        'platform/external/compiler-rt': 'bionic-presubmit',
+    }
+
+    username = config.jenkins_credentials['username']
+    password = config.jenkins_credentials['password']
+    jenkins_url = config.jenkins_url
+    jenkins = jenkinsapi.api.Jenkins(jenkins_url, username, password)
+
+    project = gerrit_info['Project']
+    change_id = gerrit_info['Change-Id']
+    if project in project_to_jenkins_map:
+        build = project_to_jenkins_map[project]
+    else:
+        build = 'bionic-presubmit'
+
+    if build in jenkins:
+        project_path = '/'.join(project.split('/')[1:])
+        if not project_path:
+            raise RuntimeError('bogus project: {}'.format(project))
+        if project_path.startswith('platform/'):
+            raise RuntimeError('Bad project mapping: {} => {}'.format(
+                project, project_path))
+        ref = gerrit.ref_for_change(change_id)
+        params = {
+            'REF': ref,
+            'CHANGE_ID': change_id,
+            'PROJECT': project_path
+        }
+        if lunch_target is not None:
+            params['LUNCH_TARGET'] = lunch_target
+        if not dry_run:
+            _ = jenkins[build].invoke(build_params=params)
+            # https://issues.jenkins-ci.org/browse/JENKINS-27256
+            # url = job.get_build().baseurl
+            url = 'URL UNAVAILABLE'
+        else:
+            url = 'DRY_RUN_URL'
+        logging.info('Building: %s => %s %s %s', project, build, url,
+                     change_id)
+    else:
+        logging.error('Unknown build: %s => %s %s', project, build, change_id)
+    return True
+
+
+def handle_change(gerrit_info, _, dry_run):
+    if should_skip_build(gerrit_info):
+        return True
+    return build_project(gerrit_info, dry_run)
+
+
+def drop_rejection(gerrit_info, dry_run):
+    request_data = {
+        'changeid': gerrit_info['Change-Id'],
+        'patchset': gerrit_info['PatchSet']
+    }
+    url = '{}/{}'.format(config.build_listener_url, 'drop-rejection')
+    headers = {'Content-Type': 'application/json;charset=UTF-8'}
+    if not dry_run:
+        try:
+            requests.post(url, headers=headers, data=json.dumps(request_data))
+        except requests.exceptions.ConnectionError as ex:
+            logging.error('Failed to drop rejection: %s', ex)
+            return False
+    logging.info('Dropped rejection: %s', gerrit_info['Change-Id'])
+    return True
+
+
+def handle_comment(gerrit_info, body, dry_run):
+    if 'Verified+1' in body:
+        drop_rejection(gerrit_info, dry_run)
+
+    if should_skip_build(gerrit_info):
+        return True
+
+    command_map = {
+        'clean': lambda: clean_project(dry_run),
+        'retry': lambda: build_project(gerrit_info, dry_run),
+
+        'arm': lambda: build_project(gerrit_info, dry_run,
+                                     lunch_target='aosp_arm-eng'),
+        'aarch64': lambda: build_project(gerrit_info, dry_run,
+                                         lunch_target='aosp_arm64-eng'),
+        'mips': lambda: build_project(gerrit_info, dry_run,
+                                      lunch_target='aosp_mips-eng'),
+        'mips64': lambda: build_project(gerrit_info, dry_run,
+                                        lunch_target='aosp_mips64-eng'),
+        'x86': lambda: build_project(gerrit_info, dry_run,
+                                     lunch_target='aosp_x86-eng'),
+        'x86_64': lambda: build_project(gerrit_info, dry_run,
+                                        lunch_target='aosp_x86_64-eng'),
+    }
+
+    def handle_unknown_command():
+        pass    # TODO(danalbert): should complain to the commenter.
+
+    commands = [match.group(1).strip() for match in
+                re.finditer(r'^bionicbb:\s*(.+)$', body, flags=re.MULTILINE)]
+
+    for command in commands:
+        if command in command_map:
+            command_map[command]()
+        else:
+            handle_unknown_command()
+
+    return True
+
+
+def skip_handler(gerrit_info, _, __):
+    logging.info('Skipping %s: %s', gerrit_info['MessageType'],
+                 gerrit_info['Change-Id'])
+    return True
diff --git a/tools/bionicbb/tasks.py b/tools/bionicbb/tasks.py
new file mode 100644
index 0000000..4c39a98
--- /dev/null
+++ b/tools/bionicbb/tasks.py
@@ -0,0 +1,108 @@
+#
+# Copyright (C) 2015 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import httplib
+import httplib2
+import logging
+import re
+import socket
+
+import apiclient.errors
+
+import gerrit
+import gmail
+import presubmit
+
+
+def get_gerrit_info(body):
+    info = {}
+    gerrit_pattern = r'^Gerrit-(\S+): (.+)$'
+    for match in re.finditer(gerrit_pattern, body, flags=re.MULTILINE):
+        info[match.group(1)] = match.group(2).strip()
+    return info
+
+
+def process_message(msg, dry_run):
+    try:
+        body = gmail.get_body(msg)
+        gerrit_info = get_gerrit_info(body)
+        if not gerrit_info:
+            logging.fatal('No Gerrit info found: %s', msg.subject)
+        msg_type = gerrit_info['MessageType']
+        handlers = {
+            'comment': presubmit.handle_comment,
+            'newchange': presubmit.handle_change,
+            'newpatchset': presubmit.handle_change,
+
+            'abandon': presubmit.skip_handler,
+            'merge-failed': presubmit.skip_handler,
+            'merged': presubmit.skip_handler,
+            'restore': presubmit.skip_handler,
+            'revert': presubmit.skip_handler,
+        }
+
+        message_type = gerrit_info['MessageType']
+        if message_type in handlers:
+            return handlers[message_type](gerrit_info, body, dry_run)
+        else:
+            logging.warning('MessageType %s unhandled.', msg_type)
+        return False
+    except NotImplementedError as ex:
+        logging.error("%s", ex)
+        return False
+    except gerrit.GerritError as ex:
+        change_id = gerrit_info['Change-Id']
+        logging.error('Gerrit error (%d): %s %s', ex.code, change_id, ex.url)
+        return ex.code == 404
+
+
+def get_and_process_jobs():
+    dry_run = False
+
+    gmail_service = gmail.build_service()
+    msg_service = gmail_service.users().messages()
+
+    # We run in a loop because some of the exceptions thrown here mean we just
+    # need to retry. For errors where we should back off (typically any gmail
+    # API exceptions), process_changes catches the error and returns normally.
+    while True:
+        try:
+            process_changes(gmail_service, msg_service, dry_run)
+            return
+        except httplib.BadStatusLine:
+            pass
+        except httplib2.ServerNotFoundError:
+            pass
+        except socket.error:
+            pass
+
+
+def process_changes(gmail_service, msg_service, dry_run):
+    try:
+        labels = gmail_service.users().labels().list(userId='me').execute()
+        if not labels['labels']:
+            logging.error('Could not retrieve Gmail labels')
+            return
+        label_id = gmail.get_gerrit_label(labels['labels'])
+        if not label_id:
+            logging.error('Could not find gerrit label')
+            return
+
+        for msg in gmail.get_all_messages(gmail_service, label_id):
+            msg = msg_service.get(userId='me', id=msg['id']).execute()
+            if process_message(msg, dry_run) and not dry_run:
+                msg_service.trash(userId='me', id=msg['id']).execute()
+    except apiclient.errors.HttpError as ex:
+        logging.error('API Client HTTP error: %s', ex)
diff --git a/tools/bionicbb/test_gmail_listener.py b/tools/bionicbb/test_tasks.py
similarity index 74%
rename from tools/bionicbb/test_gmail_listener.py
rename to tools/bionicbb/test_tasks.py
index f8b9ab6..b36cbad 100644
--- a/tools/bionicbb/test_gmail_listener.py
+++ b/tools/bionicbb/test_tasks.py
@@ -1,11 +1,12 @@
-import gmail_listener
 import mock
 import unittest
 
+import presubmit
+
 
 class TestShouldSkipBuild(unittest.TestCase):
-    @mock.patch('gmail_listener.contains_bionicbb')
-    @mock.patch('gmail_listener.contains_cleanspec')
+    @mock.patch('presubmit.contains_bionicbb')
+    @mock.patch('presubmit.contains_cleanspec')
     @mock.patch('gerrit.get_commit')
     def test_accepts_googlers(self, mock_commit, *other_checks):
         mock_commit.return_value = {
@@ -16,14 +17,14 @@
             other_check.return_value = False
 
         for message_type in ('newchange', 'newpatchset', 'comment'):
-            self.assertFalse(gmail_listener.should_skip_build({
+            self.assertFalse(presubmit.should_skip_build({
                 'MessageType': message_type,
                 'Change-Id': '',
                 'PatchSet': '',
             }))
 
-    @mock.patch('gmail_listener.contains_bionicbb')
-    @mock.patch('gmail_listener.contains_cleanspec')
+    @mock.patch('presubmit.contains_bionicbb')
+    @mock.patch('presubmit.contains_cleanspec')
     @mock.patch('gerrit.get_commit')
     def test_rejects_googlish_domains(self, mock_commit, *other_checks):
         mock_commit.return_value = {
@@ -34,14 +35,14 @@
             other_check.return_value = False
 
         for message_type in ('newchange', 'newpatchset', 'comment'):
-            self.assertTrue(gmail_listener.should_skip_build({
+            self.assertTrue(presubmit.should_skip_build({
                 'MessageType': message_type,
                 'Change-Id': '',
                 'PatchSet': '',
             }))
 
-    @mock.patch('gmail_listener.contains_bionicbb')
-    @mock.patch('gmail_listener.contains_cleanspec')
+    @mock.patch('presubmit.contains_bionicbb')
+    @mock.patch('presubmit.contains_cleanspec')
     @mock.patch('gerrit.get_commit')
     def test_rejects_non_googlers(self, mock_commit, *other_checks):
         mock_commit.return_value = {
@@ -52,14 +53,14 @@
             other_check.return_value = False
 
         for message_type in ('newchange', 'newpatchset', 'comment'):
-            self.assertTrue(gmail_listener.should_skip_build({
+            self.assertTrue(presubmit.should_skip_build({
                 'MessageType': message_type,
                 'Change-Id': '',
                 'PatchSet': '',
             }))
 
-    @mock.patch('gmail_listener.contains_bionicbb')
-    @mock.patch('gmail_listener.is_untrusted_committer')
+    @mock.patch('presubmit.contains_bionicbb')
+    @mock.patch('presubmit.is_untrusted_committer')
     @mock.patch('gerrit.get_files_for_revision')
     def test_skips_cleanspecs(self, mock_files, *other_checks):
         mock_files.return_value = ['foo/CleanSpec.mk']
@@ -67,14 +68,14 @@
             other_check.return_value = False
 
         for message_type in ('newchange', 'newpatchset', 'comment'):
-            self.assertTrue(gmail_listener.should_skip_build({
+            self.assertTrue(presubmit.should_skip_build({
                 'MessageType': message_type,
                 'Change-Id': '',
                 'PatchSet': '',
             }))
 
-    @mock.patch('gmail_listener.contains_cleanspec')
-    @mock.patch('gmail_listener.is_untrusted_committer')
+    @mock.patch('presubmit.contains_cleanspec')
+    @mock.patch('presubmit.is_untrusted_committer')
     @mock.patch('gerrit.get_files_for_revision')
     def test_skips_bionicbb(self, mock_files, *other_checks):
         mock_files.return_value = ['tools/bionicbb/common.sh']
@@ -82,7 +83,7 @@
             other_check.return_value = False
 
         for message_type in ('newchange', 'newpatchset', 'comment'):
-            self.assertTrue(gmail_listener.should_skip_build({
+            self.assertTrue(presubmit.should_skip_build({
                 'MessageType': message_type,
                 'Change-Id': '',
                 'PatchSet': '',