Merge "Drop formatStatusHTML() methods"
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index ef6259c..21d3bae 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -476,6 +476,10 @@
   A boolean value (``true`` or ``false``) that indicates whether the change
   must be open or closed in order to be enqueued.
 
+  **current-patchset**
+  A boolean value (``true`` or ``false``) that indicates whether the change
+  must be the current patchset in order to be enqueued.
+
   **status**
   A string value that corresponds with the status of the change
   reported by the trigger.  For example, when using the Gerrit
diff --git a/etc/status/public_html/index.html b/etc/status/public_html/index.html
index aac5024..8884069 100644
--- a/etc/status/public_html/index.html
+++ b/etc/status/public_html/index.html
@@ -21,64 +21,7 @@
     <title>Zuul Status</title>
     <link rel="stylesheet" href="bootstrap/css/bootstrap.min.css">
     <link rel="stylesheet" href="bootstrap/css/bootstrap-responsive.min.css">
-    <style>
-        .zuul-change {
-            margin-bottom: 10px;
-        }
-
-        .zuul-change-id {
-            float: right;
-        }
-
-        .zuul-job-result {
-            float: right;
-            width: 70px;
-            height: 15px;
-            margin: 2px 0 0 0;
-        }
-
-        .zuul-change-total-result {
-            height: 10px;
-            width: 100px;
-            margin: 5px 0 0 0;
-        }
-
-        .zuul-spinner,
-        .zuul-spinner:hover {
-            opacity: 0;
-            transition: opacity 0.5s ease-out;
-            cursor: default;
-            pointer-events: none;
-        }
-
-        .zuul-spinner-on,
-        .zuul-spinner-on:hover {
-            opacity: 1;
-            transition-duration: 0.2s;
-            cursor: progress;
-        }
-
-        .zuul-change-cell {
-            padding-left: 5px;
-        }
-
-        .zuul-change-job {
-            padding: 2px 8px;
-        }
-
-        .zuul-job-name {
-            font-size: small;
-        }
-
-        .zuul-non-voting-desc {
-            font-size: smaller;
-        }
-
-        .zuul-patchset-header {
-            font-size: small;
-            padding: 8px 12px;
-        }
-    </style>
+    <link rel="stylesheet" href="styles/zuul.css" />
 </head>
 <body>
     <div class="container">
diff --git a/etc/status/public_html/styles/zuul.css b/etc/status/public_html/styles/zuul.css
new file mode 100644
index 0000000..e833f4b
--- /dev/null
+++ b/etc/status/public_html/styles/zuul.css
@@ -0,0 +1,56 @@
+.zuul-change {
+    margin-bottom: 10px;
+}
+
+.zuul-change-id {
+    float: right;
+}
+
+.zuul-job-result {
+    float: right;
+    width: 70px;
+    height: 15px;
+    margin: 2px 0 0 0;
+}
+
+.zuul-change-total-result {
+    height: 10px;
+    width: 100px;
+    margin: 5px 0 0 0;
+}
+
+.zuul-spinner,
+.zuul-spinner:hover {
+    opacity: 0;
+    transition: opacity 0.5s ease-out;
+    cursor: default;
+    pointer-events: none;
+}
+
+.zuul-spinner-on,
+.zuul-spinner-on:hover {
+    opacity: 1;
+    transition-duration: 0.2s;
+    cursor: progress;
+}
+
+.zuul-change-cell {
+    padding-left: 5px;
+}
+
+.zuul-change-job {
+    padding: 2px 8px;
+}
+
+.zuul-job-name {
+    font-size: small;
+}
+
+.zuul-non-voting-desc {
+    font-size: smaller;
+}
+
+.zuul-patchset-header {
+    font-size: small;
+    padding: 8px 12px;
+}
\ No newline at end of file
diff --git a/tests/fixtures/layout-current-patchset.yaml b/tests/fixtures/layout-current-patchset.yaml
new file mode 100644
index 0000000..dc8f768
--- /dev/null
+++ b/tests/fixtures/layout-current-patchset.yaml
@@ -0,0 +1,24 @@
+includes:
+  - python-file: custom_functions.py
+
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    require:
+      current-patchset: True
+    trigger:
+      gerrit:
+        - event: patchset-created
+        - event: comment-added
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+projects:
+  - name: org/project
+    merge-mode: cherry-pick
+    check:
+      - project-check
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index f66c2fe..f5070bb 100755
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -132,6 +132,7 @@
         self.upstream_root = upstream_root
         self.addPatchset()
         self.data['submitRecords'] = self.getSubmitRecords()
+        self.open = True
 
     def add_fake_change_to_repo(self, msg, fn, large):
         path = os.path.join(self.upstream_root, self.project)
@@ -221,6 +222,23 @@
                  "reason": ""}
         return event
 
+    def getChangeCommentEvent(self, patchset):
+        event = {"type": "comment-added",
+                 "change": {"project": self.project,
+                            "branch": self.branch,
+                            "id": "I5459869c07352a31bfb1e7a8cac379cabfcb25af",
+                            "number": str(self.number),
+                            "subject": self.subject,
+                            "owner": {"name": "User Name"},
+                            "url": "https://hostname/3"},
+                 "patchSet": self.patchsets[patchset - 1],
+                 "author": {"name": "User Name"},
+                 "approvals": [{"type": "Code-Review",
+                                "description": "Code-Review",
+                                "value": "0"}],
+                 "comment": "This is a comment"}
+        return event
+
     def addApproval(self, category, value, username='jenkins',
                     granted_on=None):
         if not granted_on:
@@ -4063,3 +4081,35 @@
             self.getJobFromHistory('experimental-project-test').result,
             'SUCCESS')
         self.assertEqual(A.reported, 1)
+
+    def test_old_patchset_doesnt_trigger(self):
+        "Test that jobs never run against old patchsets"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-current-patchset.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+        # Create two patchsets and let their tests settle out. Then
+        # comment on first patchset and check that no additional
+        # jobs are run.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        # Added because the layout file really wants an approval but this
+        # doesn't match anyways.
+        self.fake_gerrit.addEvent(A.addApproval('CRVW', 1))
+        self.waitUntilSettled()
+        A.addPatchset()
+        self.fake_gerrit.addEvent(A.addApproval('CRVW', 1))
+        self.waitUntilSettled()
+
+        old_history_count = len(self.history)
+        self.assertEqual(old_history_count, 2)  # one job for each ps
+        self.fake_gerrit.addEvent(A.getChangeCommentEvent(1))
+        self.waitUntilSettled()
+
+        # Assert no new jobs ran after event for old patchset.
+        self.assertEqual(len(self.history), old_history_count)
+
+        # The last thing we did was add an event for a change then do
+        # nothing with a pipeline, so it will be in the cache;
+        # clean it up so it does not fail the test.
+        for pipeline in self.sched.layout.pipelines.values():
+            pipeline.trigger.maintainCache([])
diff --git a/tests/test_stack_dump.py b/tests/test_stack_dump.py
index cc8cf8f..824e04c 100644
--- a/tests/test_stack_dump.py
+++ b/tests/test_stack_dump.py
@@ -17,7 +17,7 @@
 import signal
 import testtools
 
-import zuul.cmd.server
+import zuul.cmd
 
 
 class TestStackDump(testtools.TestCase):
@@ -29,6 +29,6 @@
     def test_stack_dump_logs(self):
         "Test that stack dumps end up in logs."
 
-        zuul.cmd.server.stack_dump_handler(signal.SIGUSR2, None)
+        zuul.cmd.stack_dump_handler(signal.SIGUSR2, None)
         self.assertIn("Thread", self.log_fixture.output)
         self.assertIn("test_stack_dump_logs", self.log_fixture.output)
diff --git a/zuul/cmd/__init__.py b/zuul/cmd/__init__.py
index e69de29..e17ad5b 100644
--- a/zuul/cmd/__init__.py
+++ b/zuul/cmd/__init__.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2013 OpenStack Foundation
+#
+# 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 ConfigParser
+import logging
+import logging.config
+import os
+import signal
+import sys
+import traceback
+
+# No zuul imports here because they pull in paramiko which must not be
+# imported until after the daemonization.
+# https://github.com/paramiko/paramiko/issues/59
+# Similar situation with gear and statsd.
+
+
+def stack_dump_handler(signum, frame):
+    signal.signal(signal.SIGUSR2, signal.SIG_IGN)
+    log_str = ""
+    for thread_id, stack_frame in sys._current_frames().items():
+        log_str += "Thread: %s\n" % thread_id
+        log_str += "".join(traceback.format_stack(stack_frame))
+    log = logging.getLogger("zuul.stack_dump")
+    log.debug(log_str)
+    signal.signal(signal.SIGUSR2, stack_dump_handler)
+
+
+class ZuulApp(object):
+
+    def __init__(self):
+        self.args = None
+        self.config = None
+
+    def _get_version(self):
+        from zuul.version import version_info as zuul_version_info
+        return "Zuul version: %s" % zuul_version_info.version_string()
+
+    def read_config(self):
+        self.config = ConfigParser.ConfigParser()
+        if self.args.config:
+            locations = [self.args.config]
+        else:
+            locations = ['/etc/zuul/zuul.conf',
+                         '~/zuul.conf']
+        for fp in locations:
+            if os.path.exists(os.path.expanduser(fp)):
+                self.config.read(os.path.expanduser(fp))
+                return
+        raise Exception("Unable to locate config file in %s" % locations)
+
+    def setup_logging(self, section, parameter):
+        if self.config.has_option(section, parameter):
+            fp = os.path.expanduser(self.config.get(section, parameter))
+            if not os.path.exists(fp):
+                raise Exception("Unable to read logging config file at %s" %
+                                fp)
+            logging.config.fileConfig(fp)
+        else:
+            logging.basicConfig(level=logging.DEBUG)
diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py
index 147fade..766a4ef 100644
--- a/zuul/cmd/client.py
+++ b/zuul/cmd/client.py
@@ -16,26 +16,20 @@
 
 import argparse
 import babel.dates
-import ConfigParser
 import datetime
 import logging
-import logging.config
-import os
 import prettytable
 import sys
 import time
 
+
 import zuul.rpcclient
+import zuul.cmd
 
 
-class Client(object):
+class Client(zuul.cmd.ZuulApp):
     log = logging.getLogger("zuul.Client")
 
-    def __init__(self):
-        self.args = None
-        self.config = None
-        self.gear_server_pid = None
-
     def parse_arguments(self):
         parser = argparse.ArgumentParser(
             description='Zuul Project Gating System Client.')
@@ -89,24 +83,8 @@
 
         self.args = parser.parse_args()
 
-    def _get_version(self):
-        from zuul.version import version_info as zuul_version_info
-        return "Zuul version: %s" % zuul_version_info.version_string()
-
-    def read_config(self):
-        self.config = ConfigParser.ConfigParser()
-        if self.args.config:
-            locations = [self.args.config]
-        else:
-            locations = ['/etc/zuul/zuul.conf',
-                         '~/zuul.conf']
-        for fp in locations:
-            if os.path.exists(os.path.expanduser(fp)):
-                self.config.read(os.path.expanduser(fp))
-                return
-        raise Exception("Unable to locate config file in %s" % locations)
-
     def setup_logging(self):
+        """Client logging does not rely on conf file"""
         if self.args.verbose:
             logging.basicConfig(level=logging.DEBUG)
 
diff --git a/zuul/cmd/merger.py b/zuul/cmd/merger.py
index edf8da9..dc3484a 100644
--- a/zuul/cmd/merger.py
+++ b/zuul/cmd/merger.py
@@ -15,7 +15,6 @@
 # under the License.
 
 import argparse
-import ConfigParser
 import daemon
 import extras
 
@@ -23,12 +22,11 @@
 # instead it depends on lockfile-0.9.1 which uses pidfile.
 pid_file_module = extras.try_imports(['daemon.pidlockfile', 'daemon.pidfile'])
 
-import logging
-import logging.config
 import os
 import sys
 import signal
-import traceback
+
+import zuul.cmd
 
 # No zuul imports here because they pull in paramiko which must not be
 # imported until after the daemonization.
@@ -36,21 +34,7 @@
 # Similar situation with gear and statsd.
 
 
-def stack_dump_handler(signum, frame):
-    signal.signal(signal.SIGUSR2, signal.SIG_IGN)
-    log_str = ""
-    for thread_id, stack_frame in sys._current_frames().items():
-        log_str += "Thread: %s\n" % thread_id
-        log_str += "".join(traceback.format_stack(stack_frame))
-    log = logging.getLogger("zuul.stack_dump")
-    log.debug(log_str)
-    signal.signal(signal.SIGUSR2, stack_dump_handler)
-
-
-class Merger(object):
-    def __init__(self):
-        self.args = None
-        self.config = None
+class Merger(zuul.cmd.ZuulApp):
 
     def parse_arguments(self):
         parser = argparse.ArgumentParser(description='Zuul merge worker.')
@@ -58,33 +42,11 @@
                             help='specify the config file')
         parser.add_argument('-d', dest='nodaemon', action='store_true',
                             help='do not run as a daemon')
-        parser.add_argument('--version', dest='version', action='store_true',
+        parser.add_argument('--version', dest='version', action='version',
+                            version=self._get_version(),
                             help='show zuul version')
         self.args = parser.parse_args()
 
-    def read_config(self):
-        self.config = ConfigParser.ConfigParser()
-        if self.args.config:
-            locations = [self.args.config]
-        else:
-            locations = ['/etc/zuul/zuul.conf',
-                         '~/zuul.conf']
-        for fp in locations:
-            if os.path.exists(os.path.expanduser(fp)):
-                self.config.read(os.path.expanduser(fp))
-                return
-        raise Exception("Unable to locate config file in %s" % locations)
-
-    def setup_logging(self, section, parameter):
-        if self.config.has_option(section, parameter):
-            fp = os.path.expanduser(self.config.get(section, parameter))
-            if not os.path.exists(fp):
-                raise Exception("Unable to read logging config file at %s" %
-                                fp)
-            logging.config.fileConfig(fp)
-        else:
-            logging.basicConfig(level=logging.DEBUG)
-
     def exit_handler(self, signum, frame):
         signal.signal(signal.SIGUSR1, signal.SIG_IGN)
         self.merger.stop()
@@ -100,7 +62,7 @@
         self.merger.start()
 
         signal.signal(signal.SIGUSR1, self.exit_handler)
-        signal.signal(signal.SIGUSR2, stack_dump_handler)
+        signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler)
         while True:
             try:
                 signal.pause()
@@ -113,11 +75,6 @@
     server = Merger()
     server.parse_arguments()
 
-    if server.args.version:
-        from zuul.version import version_info as zuul_version_info
-        print "Zuul version: %s" % zuul_version_info.version_string()
-        sys.exit(0)
-
     server.read_config()
 
     if server.config.has_option('zuul', 'state_dir'):
diff --git a/zuul/cmd/server.py b/zuul/cmd/server.py
index 8caa1fd..06ea780 100755
--- a/zuul/cmd/server.py
+++ b/zuul/cmd/server.py
@@ -15,7 +15,6 @@
 # under the License.
 
 import argparse
-import ConfigParser
 import daemon
 import extras
 
@@ -24,11 +23,11 @@
 pid_file_module = extras.try_imports(['daemon.pidlockfile', 'daemon.pidfile'])
 
 import logging
-import logging.config
 import os
 import sys
 import signal
-import traceback
+
+import zuul.cmd
 
 # No zuul imports here because they pull in paramiko which must not be
 # imported until after the daemonization.
@@ -36,21 +35,9 @@
 # Similar situation with gear and statsd.
 
 
-def stack_dump_handler(signum, frame):
-    signal.signal(signal.SIGUSR2, signal.SIG_IGN)
-    log_str = ""
-    for thread_id, stack_frame in sys._current_frames().items():
-        log_str += "Thread: %s\n" % thread_id
-        log_str += "".join(traceback.format_stack(stack_frame))
-    log = logging.getLogger("zuul.stack_dump")
-    log.debug(log_str)
-    signal.signal(signal.SIGUSR2, stack_dump_handler)
-
-
-class Server(object):
+class Server(zuul.cmd.ZuulApp):
     def __init__(self):
-        self.args = None
-        self.config = None
+        super(Server, self).__init__()
         self.gear_server_pid = None
 
     def parse_arguments(self):
@@ -71,33 +58,6 @@
                             help='show zuul version')
         self.args = parser.parse_args()
 
-    def _get_version(self):
-        from zuul.version import version_info as zuul_version_info
-        return "Zuul version: %s" % zuul_version_info.version_string()
-
-    def read_config(self):
-        self.config = ConfigParser.ConfigParser()
-        if self.args.config:
-            locations = [self.args.config]
-        else:
-            locations = ['/etc/zuul/zuul.conf',
-                         '~/zuul.conf']
-        for fp in locations:
-            if os.path.exists(os.path.expanduser(fp)):
-                self.config.read(os.path.expanduser(fp))
-                return
-        raise Exception("Unable to locate config file in %s" % locations)
-
-    def setup_logging(self, section, parameter):
-        if self.config.has_option(section, parameter):
-            fp = os.path.expanduser(self.config.get(section, parameter))
-            if not os.path.exists(fp):
-                raise Exception("Unable to read logging config file at %s" %
-                                fp)
-            logging.config.fileConfig(fp)
-        else:
-            logging.basicConfig(level=logging.DEBUG)
-
     def reconfigure_handler(self, signum, frame):
         signal.signal(signal.SIGHUP, signal.SIG_IGN)
         self.read_config()
@@ -235,7 +195,7 @@
 
         signal.signal(signal.SIGHUP, self.reconfigure_handler)
         signal.signal(signal.SIGUSR1, self.exit_handler)
-        signal.signal(signal.SIGUSR2, stack_dump_handler)
+        signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler)
         signal.signal(signal.SIGTERM, self.term_handler)
         while True:
             try:
diff --git a/zuul/layoutvalidator.py b/zuul/layoutvalidator.py
index 3e0a0ab..9a448a3 100644
--- a/zuul/layoutvalidator.py
+++ b/zuul/layoutvalidator.py
@@ -71,6 +71,7 @@
 
     require = {'approval': toList(require_approval),
                'open': bool,
+               'current-patchset': bool,
                'status': toList(str)}
 
     window = v.All(int, v.Range(min=0))
diff --git a/zuul/model.py b/zuul/model.py
index 9d8ff53..2210d92 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1147,8 +1147,10 @@
 
 
 class ChangeishFilter(object):
-    def __init__(self, open=None, statuses=[], approvals=[]):
+    def __init__(self, open=None, current_patchset=None,
+                 statuses=[], approvals=[]):
         self.open = open
+        self.current_patchset = current_patchset
         self.statuses = statuses
         self.approvals = approvals
 
@@ -1165,10 +1167,12 @@
 
         if self.open is not None:
             ret += ' open: %s' % self.open
+        if self.current_patchset is not None:
+            ret += ' current-patchset: %s' % self.current_patchset
         if self.statuses:
             ret += ' statuses: %s' % ', '.join(self.statuses)
         if self.approvals:
-            ret += ' approvals: %s' % ', '.join(str(self.approvals))
+            ret += ' approvals: %s' % str(self.approvals)
         ret += '>'
 
         return ret
@@ -1178,6 +1182,10 @@
             if self.open != change.open:
                 return False
 
+        if self.current_patchset is not None:
+            if self.current_patchset != change.is_current_patchset:
+                return False
+
         if self.statuses:
             if change.status not in self.statuses:
                 return False
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index ae74066..4782eae 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -276,9 +276,11 @@
 
             if 'require' in conf_pipeline:
                 require = conf_pipeline['require']
-                f = ChangeishFilter(open=require.get('open'),
-                                    statuses=toList(require.get('status')),
-                                    approvals=toList(require.get('approval')))
+                f = ChangeishFilter(
+                    open=require.get('open'),
+                    current_patchset=require.get('current-patchset'),
+                    statuses=toList(require.get('status')),
+                    approvals=toList(require.get('approval')))
                 manager.changeish_filters.append(f)
 
             # TODO: move this into triggers (may require pluggable