diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..7fdea582c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+*.egg-info
diff --git a/example/ceilometer.yml b/example/ceilometer.yml
new file mode 100644
index 000000000..a17dbc532
--- /dev/null
+++ b/example/ceilometer.yml
@@ -0,0 +1,80 @@
+- project:
+    name: ceilometer
+    github-org: stackforge
+    node: oneiric
+    
+    jobs:
+      - python-jobs
+
+
+- job:
+    name: 'gate-ceilometer-python26-essex'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/stackforge/ceilometer
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    # TODO: logrotate this job
+    #logrotate:
+    #  daysToKeep: 28
+    #  numToKeep: -1
+    #  artifactDaysToKeep: -1
+    #  artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - python26_essex
+
+    # >= precise does not have python2.6
+    node: oneiric
+  
+
+- job:
+    name: 'gate-ceilometer-python27-essex'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/stackforge/ceilometer
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    # TODO: logrotate this job
+    #logrotate:
+    #  daysToKeep: 28
+    #  numToKeep: -1
+    #  artifactDaysToKeep: -1
+    #  artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - python27_essex
+
+    node: oneiric
diff --git a/example/cinder.yml b/example/cinder.yml
new file mode 100644
index 000000000..5a0499eed
--- /dev/null
+++ b/example/cinder.yml
@@ -0,0 +1,10 @@
+- project:
+    name: cinder
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/devstack-gate.yml b/example/devstack-gate.yml
new file mode 100644
index 000000000..c26a1fe59
--- /dev/null
+++ b/example/devstack-gate.yml
@@ -0,0 +1,273 @@
+- job:
+    name: gate-devstack-gate-merge
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/devstack-gate
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+
+- job:
+    name: gate-integration-tests-devstack-vm
+    project-type: freestyle
+    concurrent: true
+    node: devstack-precise
+
+    properties:
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    parameters:
+      - label:
+          name: NODE_LABEL
+          description: Label of node to use for this build
+          default: devstack-precise
+
+    wrappers:
+      - timeout:
+          timeout: 40
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - trigger-builds:
+          - project: devstack-update-inprogress
+            predefined_parameters:
+              DEVSTACK_NODE_NAME=${NODE_NAME}
+      - shell: |
+          #!/bin/bash -xe
+          #
+          # This job also gates devstack-gate, but in case a previous run fails,
+          # we need to always make sure that we're starting with the latest copy
+          # from master, before we start applying changes to it.  If a previous run
+          # leaves a bad copy of the gate script, we may get stuck.
+          #
+          if [[ ! -e devstack-gate ]]; then
+              git clone https://review.openstack.org/p/openstack-ci/devstack-gate
+          else
+              cd devstack-gate
+              git remote update
+              git reset --hard
+              git clean -x -f
+              git checkout master
+              git reset --hard remotes/origin/master
+              git clean -x -f
+              cd ..
+          fi
+      - shell: |
+          #!/bin/bash -xe
+          export PYTHONUNBUFFERED=true
+          cp devstack-gate/devstack-vm-gate-wrap.sh ./safe-devstack-vm-gate-wrap.sh
+          ./safe-devstack-vm-gate-wrap.sh
+
+    publishers:
+      - archive: 
+          artifacts: logs/*
+      - trigger-parameterized-builds:
+          - project: devstack-update-complete
+            when: complete
+            predefined_parameters:
+              DEVSTACK_NODE_NAME=${NODE_NAME}
+
+
+- job:
+    name: gate-tempest-devstack-vm
+    project-type: freestyle
+    concurrent: true
+    node: devstack-precise
+
+    properties:
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    parameters:
+      - label:
+          name: NODE_LABEL
+          description: Label of node to use for this build
+          default: devstack-precise
+
+    wrappers:
+      - timeout:
+          timeout: 90
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - trigger-builds:
+          - project: devstack-update-inprogress
+            predefined_parameters:
+              DEVSTACK_NODE_NAME=${NODE_NAME}
+      - shell: |
+          #!/bin/bash -xe
+          #
+          # This job also gates devstack-gate, but in case a previous run fails,
+          # we need to always make sure that we're starting with the latest copy
+          # from master, before we start applying changes to it.  If a previous run
+          # leaves a bad copy of the gate script, we may get stuck.
+          #
+          if [[ ! -e devstack-gate ]]; then
+              git clone https://review.openstack.org/p/openstack-ci/devstack-gate
+          else
+              cd devstack-gate
+              git remote update
+              git reset --hard
+              git clean -x -f
+              git checkout master
+              git reset --hard remotes/origin/master
+              git clean -x -f
+              cd ..
+          fi
+      - shell: |
+          #!/bin/bash -xe
+          export PYTHONUNBUFFERED=true
+          export DEVSTACK_GATE_TEMPEST=1
+          cp devstack-gate/devstack-vm-gate-wrap.sh ./safe-devstack-vm-gate-wrap.sh
+          ./safe-devstack-vm-gate-wrap.sh
+
+    publishers:
+      - archive: 
+          artifacts: logs/*
+      - trigger-parameterized-builds:
+          - project: devstack-update-complete
+            when: complete
+            predefined_parameters:
+              DEVSTACK_NODE_NAME=${NODE_NAME}
+
+
+# Called by devstack jobs to alert that they have started so that the
+# jenkins slave they are running on can be disabled.
+- job:
+    name: devstack-update-inprogress
+    project-type: freestyle
+    concurrent: false
+    node: master
+
+    properties:
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: 100
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 10
+          fail: true
+      - timestamps
+
+    builders:
+      - shell: |
+          #!/bin/bash -xe
+
+          if [[ ! -e devstack-gate ]]; then
+              git clone https://review.openstack.org/p/openstack-ci/devstack-gate
+          else
+              cd devstack-gate
+              git remote update
+              git pull --ff-only origin
+              cd ..
+          fi
+      - shell: |
+          #!/bin/bash -xe
+          export PYTHONUNBUFFERED=true
+          $WORKSPACE/devstack-gate/devstack-vm-inprogress.py $DEVSTACK_NODE_NAME
+
+
+# Called by devstack jobs to alert that they have completed so that the
+# jenkins slave may be deleted.
+- job:
+    name: devstack-update-complete
+    project-type: freestyle
+    concurrent: false
+    node: master
+
+    properties:
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: 100
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 10
+          fail: true
+      - timestamps
+
+    builders:
+      - shell: |
+          #!/bin/bash -xe
+
+          if [[ ! -e devstack-gate ]]; then
+              git clone https://review.openstack.org/p/openstack-ci/devstack-gate
+          else
+              cd devstack-gate
+              git remote update
+              git pull --ff-only origin
+              cd ..
+          fi
+      - shell: |
+          #!/bin/bash -xe
+          export PYTHONUNBUFFERED=true
+          $WORKSPACE/devstack-gate/devstack-vm-delete.py $DEVSTACK_NODE_NAME
diff --git a/example/devstack.yml b/example/devstack.yml
new file mode 100644
index 000000000..e78ddf03c
--- /dev/null
+++ b/example/devstack.yml
@@ -0,0 +1,33 @@
+- job:
+    name: gate-devstack-merge
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-dev/devstack
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    # TODO: logrotate this job
+    #logrotate:
+    #  daysToKeep: 28
+    #  numToKeep: -1
+    #  artifactDaysToKeep: -1
+    #  artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
diff --git a/example/gerrit-verification-status-plugin.yml b/example/gerrit-verification-status-plugin.yml
new file mode 100644
index 000000000..264a1cded
--- /dev/null
+++ b/example/gerrit-verification-status-plugin.yml
@@ -0,0 +1,35 @@
+- project:
+    name: gerrit-verification-status-plugin
+    github-org: openstack-ci
+    node: precise
+    
+      # TODO: standardize
+    #jobs:
+      #- gate-{name}-merge
+
+- job:
+    name: gate-gerrit-verification-status-plugin-merge
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/gerrit-verification-status-plugin
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+    node: precise
diff --git a/example/gerrit.yml b/example/gerrit.yml
new file mode 100644
index 000000000..3f200cc28
--- /dev/null
+++ b/example/gerrit.yml
@@ -0,0 +1,159 @@
+- project:
+    name: gerrit
+    github-org: openstack-ci
+    node: precise
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+
+
+- job:
+    name: gate-gerrit-merge
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/gerrit
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+    node: precise
+
+
+- job:
+    name: check-gerrit-unittests
+    project-type: maven
+    concurrent: true
+    node: precise
+
+    wrappers:
+      - timeout:
+          timeout: 40
+          fail: true
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/gerrit
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    prebuilders:
+      - gerrit_git_prep
+      - gerrit_preclean
+
+    maven:
+      root_module:
+        group_id: com.google.gerrit
+        artifact_id: gerrit-parent
+      goals: 'clean package -Dgerrit.include-documentation=1 -X'
+
+    postbuilders:
+      - gerrit_postrun
+
+    publishers:
+      - war:
+          site: 'nova.openstack.org'
+          warfile: 'gerrit-war/target/gerrit*.war'
+          target: 'tarballs/ci/test/'
+
+
+- job:
+    name: gate-gerrit-unittests
+    project-type: maven
+    concurrent: true
+    node: precise
+
+    wrappers:
+      - timeout:
+          timeout: 40
+          fail: true
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/gerrit
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    prebuilders:
+      - gerrit_git_prep
+      - gerrit_preclean
+
+    maven:
+      root_module:
+        group_id: com.google.gerrit
+        artifact_id: gerrit-parent
+      goals: 'clean package -Dgerrit.include-documentation=1 -X'
+
+    postbuilders:
+      - gerrit_postrun
+
+
+- job:
+    name: gerrit-package
+    project-type: maven
+    concurrent: true
+    node: precise
+
+    wrappers:
+      - timeout:
+          timeout: 40
+          fail: true
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/gerrit
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul_post
+
+    prebuilders:
+      - gerrit_git_prep
+      - gerrit_preclean
+
+    maven:
+      root_module:
+        group_id: com.google.gerrit
+        artifact_id: gerrit-parent
+      goals: 'clean package -Dgerrit.include-documentation=1 -X'
+
+    postbuilders:
+      - gerrit_postrun
+
+    publishers:
+      - war:
+          site: 'nova.openstack.org'
+          warfile: 'gerrit-war/target/gerrit*.war'
+          target: 'tarballs/ci/'
diff --git a/example/gerritbot.yml b/example/gerritbot.yml
new file mode 100644
index 000000000..b5ba6b380
--- /dev/null
+++ b/example/gerritbot.yml
@@ -0,0 +1,90 @@
+- project:
+    name: gerritbot
+    github-org: openstack-ci
+    node: precise
+    doc-publisher-site: ci.openstack.org
+    tarball-publisher-site: ci.openstack.org
+    
+    jobs:
+      - gate-{name}-merge
+      - gate-{name}-pep8
+      - gate-{name}-pyflakes
+      # TODO: standardize
+      #- '{name}-sdist-tarball'
+      #- '{name}-pypi'
+
+- job:
+    name: 'gerritbot-sdist-tarball'
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: 'https://github.com/openstack-ci/gerritbot'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+
+    triggers:
+      - zuul_post
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    builders:
+      - gerrit_git_prep
+      - shell: |
+          #!/bin/bash -xe
+          BRANCH=$GERRIT_REFNAME
+          BRANCH_PATH=`echo $BRANCH | tr / -`
+
+          tox -v -evenv python setup.py sdist
+          cp dist/* dist/gerritbot-$BRANCH_PATH.tar.gz
+
+    publishers:
+      - tarball:
+          project: 'gerritbot'
+          site: 'ci.openstack.org'
+
+
+- job:
+    name: 'gerritbot-pypi'
+    concurrent: true
+    node: pypi
+
+    properties:
+      - github:
+          url: 'https://github.com/openstack-ci/gerritbot'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+
+    triggers:
+      - zuul_post
+
+    builders:
+      - gerrit_git_prep
+      - shell: tox -v -evenv python setup.py sdist upload
+
+    publishers:
+      - tarball:
+          project: 'gerritbot'
+          site: 'ci.openstack.org'
diff --git a/example/gerritlib.yml b/example/gerritlib.yml
new file mode 100644
index 000000000..1e775c9e5
--- /dev/null
+++ b/example/gerritlib.yml
@@ -0,0 +1,45 @@
+- project:
+    name: gerritlib
+    github-org: openstack-ci
+    node: precise
+    doc-publisher-site: ci.openstack.org
+    tarball-publisher-site: ci.openstack.org
+    
+    jobs:
+      - gate-{name}-merge
+      - gate-{name}-pep8
+      - gate-{name}-pyflakes
+      # TODO: standardize
+      #- '{name}-pypi'
+
+- job:
+    name: 'gerritlib-pypi'
+    concurrent: true
+    node: pypi
+
+    properties:
+      - github:
+          url: 'https://github.com/openstack-ci/gerritlib'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    builders:
+      - gerrit_git_prep
+      - shell: tox -v -evenv python setup.py sdist upload
+
+    publishers:
+      - tarball:
+          project: 'ci'
+          site: 'nova.openstack.org'
diff --git a/example/glance.yml b/example/glance.yml
new file mode 100644
index 000000000..7806057eb
--- /dev/null
+++ b/example/glance.yml
@@ -0,0 +1,10 @@
+- project:
+    name: glance
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/heat.yml b/example/heat.yml
new file mode 100644
index 000000000..85ba5c00d
--- /dev/null
+++ b/example/heat.yml
@@ -0,0 +1,7 @@
+- project:
+    name: heat
+    github-org: heat-api
+    node: oneiric
+    
+    jobs:
+      - python-jobs
diff --git a/example/horizon.yml b/example/horizon.yml
new file mode 100644
index 000000000..92970a1b7
--- /dev/null
+++ b/example/horizon.yml
@@ -0,0 +1,43 @@
+- project:
+    name: horizon
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
+
+- job:
+    name: gate-horizon-selenium
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack/horizon
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - selenium
diff --git a/example/jenkins-job-builder.yml b/example/jenkins-job-builder.yml
new file mode 100644
index 000000000..29f395294
--- /dev/null
+++ b/example/jenkins-job-builder.yml
@@ -0,0 +1,34 @@
+- project:
+    name: jenkins-job-builder
+    github-org: openstack
+    node: precise
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+
+- job:
+    name: gate-jenkins-job-builder-merge
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/jenkins-job-builder
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
diff --git a/example/keystone.yml b/example/keystone.yml
new file mode 100644
index 000000000..a1a0d9865
--- /dev/null
+++ b/example/keystone.yml
@@ -0,0 +1,10 @@
+- project:
+    name: keystone
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/macros.yml b/example/macros.yml
new file mode 100644
index 000000000..8af5dcad1
--- /dev/null
+++ b/example/macros.yml
@@ -0,0 +1,115 @@
+- builder:
+    name: gerrit_git_prep
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/gerrit-git-prep.sh review.openstack.org"
+
+- builder:
+    name: coverage
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-cover.sh"
+
+- builder:
+    name: docs
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-docs.sh"
+
+- builder:
+    name: maven_test
+    builders:
+      - shell: "mvn test"
+
+- builder:
+    name: maven_package
+    builders:
+      - shell: "mvn package"
+
+- builder:
+    name: gerrit_package
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/package-gerrit.sh"
+
+- builder:
+    name: gerrit_preclean
+    #TODO: multiline:
+    builders:
+      - shell: "#!/bin/bash -xe\nrm -fr ~/.m2\nrm -fr ~/.java\n./tools/version.sh --release"
+
+- builder:
+    name: gerrit_postrun
+    builders:
+      - shell: "./tools/version.sh --reset"
+
+- builder:
+    name: pep8
+    builders:
+      - shell: "set -o pipefail ; tox -v -epep8 | tee pep8.txt ; set +o pipefail"
+
+- builder:
+    name: pyflakes
+    builders:
+      - shell: "tox -v -epyflakes"
+
+- builder:
+    name: puppet_syntax
+    builders:
+      # TODO: remove blank line
+      - shell: |
+
+          find . -iname *.pp | xargs puppet parser validate --modulepath=`pwd`/modules
+          for f in `find . -iname *.erb` ; do
+            erb -x -T '-' $f | ruby -c
+          done
+
+- builder:
+    name: selenium
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-selenium.sh"
+
+- builder:
+    name: python26
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-tox.sh 26"
+      - something:
+          arg: value
+
+- builder:
+    name: python27
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-tox.sh 27"
+
+- builder:
+    name: python26_essex
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-tox.sh 26-essex"
+
+- builder:
+    name: python27_essex
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/run-tox.sh 27-essex"
+
+- builder:
+    name: tarball
+    builders:
+      - shell: "/usr/local/jenkins/slave_scripts/create-tarball.sh {project}"
+
+# ======================================================================
+
+- publisher:
+    name: tarball
+    publishers:
+      - archive:
+          artifacts: 'dist/*.tar.gz'
+      - scp:
+          site: '{site}'
+          source: 'dist/*.tar.gz'
+          target: 'tarballs/{project}/'
+
+- publisher:
+    name: war
+    publishers:
+      - archive:
+          artifacts: '{warfile}'
+      - scp:
+          site: '{site}'
+          source: '{warfile}'
+          target: '{target}'
diff --git a/example/mraas.yml b/example/mraas.yml
new file mode 100644
index 000000000..e148cef2c
--- /dev/null
+++ b/example/mraas.yml
@@ -0,0 +1,82 @@
+- project:
+    name: MRaaS
+    github-org: stackforge
+    node: precise
+
+- job:
+    name: gate-MRaaS-merge
+    concurrent: false
+    node: oneiric
+
+    properties:
+      - github:
+          url: https://github.com/stackforge/MRaaS
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - gerrit:
+          triggerOnPatchsetUploadedEvent: 'false'
+          triggerOnChangeMergedEvent: 'false'
+          triggerOnCommentAddedEvent: 'true'
+          triggerOnRefUpdatedEvent: 'false'
+          triggerApprovalCategory: 'APRV'
+          triggerApprovalValue: 1
+          failureMessage: 'This change was unable to be automatically merged with the current state of the repository. Please rebase your change and upload a new patchset.'
+          projects:
+            - projectCompareType: 'PLAIN'
+              projectPattern: 'stackforge/MRaaS'
+              branchCompareType: 'ANT'
+              branchPattern: '**'
+
+    builders:
+      - gerrit_git_prep
+
+
+- job:
+    name: check-MRaaS-merge
+    concurrent: false
+    node: oneiric
+
+    properties:
+      - github:
+          url: https://github.com/stackforge/MRaaS
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - gerrit:
+          triggerOnPatchsetUploadedEvent: 'true'
+          triggerOnChangeMergedEvent: 'false'
+          triggerOnCommentAddedEvent: 'false'
+          triggerOnRefUpdatedEvent: 'false'
+          overrideVotes: 'true'
+          gerritBuildSuccessfulVerifiedValue: 1
+          gerritBuildFailedVerifiedValue: -1
+          failureMessage: 'This change was unable to be automatically merged with the current state of the repository. Please rebase your change and upload a new patchset.'
+          projects:
+            - projectCompareType: 'PLAIN'
+              projectPattern: 'stackforge/MRaaS'
+              branchCompareType: 'ANT'
+              branchPattern: '**'
+
+    builders:
+      - gerrit_git_prep
diff --git a/example/nova.yml b/example/nova.yml
new file mode 100644
index 000000000..0dfd2a8eb
--- /dev/null
+++ b/example/nova.yml
@@ -0,0 +1,12 @@
+- project:
+    name: nova
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - python-diablo-bitrot-jobs
+      - python-essex-bitrot-jobs
+      - openstack-publish-jobs
diff --git a/example/openstack-ci-puppet.yml b/example/openstack-ci-puppet.yml
new file mode 100644
index 000000000..668039061
--- /dev/null
+++ b/example/openstack-ci-puppet.yml
@@ -0,0 +1,63 @@
+- project:
+    name: ci-puppet
+    github-org: openstack
+    node: precise
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+
+- job:
+    name: gate-ci-puppet-merge
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack/openstack-ci-puppet
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+
+- job:
+    name: gate-ci-puppet-syntax
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack/openstack-ci-puppet
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - puppet_syntax
+      - pyflakes
diff --git a/example/openstack-common.yml b/example/openstack-common.yml
new file mode 100644
index 000000000..16b5005b9
--- /dev/null
+++ b/example/openstack-common.yml
@@ -0,0 +1,10 @@
+- project:
+    name: openstack-common
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/openstack-publish-jobs.yml b/example/openstack-publish-jobs.yml
new file mode 100644
index 000000000..a78c53f35
--- /dev/null
+++ b/example/openstack-publish-jobs.yml
@@ -0,0 +1,87 @@
+- job-template:
+    name: '{name}-docs'
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    builders:
+      - gerrit_git_prep
+      - docs
+
+    publishers:
+      - ftp:
+          site: '{doc-publisher-site}'
+          source: 'doc/build/html/**'
+          target: 'developer/{name}'
+          remove-prefix: 'doc/build/html'
+          excludes: ''
+
+- job-template:
+    name: '{name}-tarball'
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    builders:
+      - gerrit_git_prep
+      - tarball:
+          project: '{name}'
+
+    publishers:
+      - tarball:
+          project: '{name}'
+          site: '{tarball-publisher-site}'
+
+- job-group:
+    name: openstack-publish-jobs
+    jobs:
+      - '{name}-docs'
+      - '{name}-tarball'
diff --git a/example/pbr.yml b/example/pbr.yml
new file mode 100644
index 000000000..6025e33eb
--- /dev/null
+++ b/example/pbr.yml
@@ -0,0 +1,10 @@
+- project:
+    name: pbr
+    github-org: openstack-dev
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/pypi-jobs.yml b/example/pypi-jobs.yml
new file mode 100644
index 000000000..68fc96559
--- /dev/null
+++ b/example/pypi-jobs.yml
@@ -0,0 +1,85 @@
+- job-template:
+    name: '{name}-sdist-tarball'
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    builders:
+      - gerrit_git_prep
+      - shell: |
+          #!/bin/bash -xe
+          BRANCH=$GERRIT_REFNAME
+          BRANCH_PATH=`echo $BRANCH | tr / -`
+
+          tox -v -evenv python setup.py sdist
+          cp dist/* dist/{name}-$BRANCH_PATH.tar.gz
+
+    publishers:
+      - tarball:
+          project: '{name}'
+          site: '{tarball-publisher-site}'
+
+
+- job-template:
+    name: '{name}-pypi'
+    project-type: freestyle
+    concurrent: true
+    node: pypi
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    builders:
+      - gerrit_git_prep
+      - shell: tox -v -evenv python setup.py sdist upload
+
+    publishers:
+      - tarball:
+          project: '{name}'
+          site: '{tarball-publisher-site}'
+
+- job-group:
+    name: pypi-jobs
+    jobs:
+      - '{name}-docs'
+      - '{name}-sdist-tarball'
+      - '{name}-pypi'
diff --git a/example/pypi-mirror.yml b/example/pypi-mirror.yml
new file mode 100644
index 000000000..bed7e5808
--- /dev/null
+++ b/example/pypi-mirror.yml
@@ -0,0 +1,64 @@
+- project:
+    name: pypi-mirror
+    github-org: openstack-ci
+    node: precise
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+    #  - gate-{name}-pyflakes
+
+- job:
+    name: gate-pypi-mirror-merge
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/pypi-mirror
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+- job:
+    name: gate-pypi-mirror-pyflakes
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/pypi-mirror
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - pyflakes
diff --git a/example/python-bitrot-jobs.yml b/example/python-bitrot-jobs.yml
new file mode 100644
index 000000000..5b1d6fc22
--- /dev/null
+++ b/example/python-bitrot-jobs.yml
@@ -0,0 +1,140 @@
+- job-template:
+    name: 'periodic-{name}-python26-{branch-name}'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - timed: '@daily'
+
+    builders:
+      - python26
+
+    scm:
+      - git: 
+          url: 'git://github.com/{github-org}/{name}.git'
+          branches:
+            - 'origin/{branch}'
+
+    # >= precise does not have python2.6
+    node: oneiric
+  
+
+- job-template:
+    name: 'periodic-{name}-python27-{branch-name}'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - timed: '@daily'
+
+    builders:
+      - python27
+
+    scm:
+      - git: 
+          url: 'git://github.com/{github-org}/{name}.git'
+          branches:
+            - 'origin/{branch}'
+
+    node: '{node}'
+  
+
+- job-template:
+    name: 'periodic-{name}-docs-{branch-name}'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - timed: '@daily'
+
+    builders:
+      - docs
+
+    scm:
+      - git: 
+          url: 'git://github.com/{github-org}/{name}.git'
+          branches:
+            - 'origin/{branch}'
+
+    node: '{node}'
+
+  
+- job-group:
+    name: python-diablo-bitrot-jobs
+    branch: 'stable/diablo'
+    branch-name: 'stable-diablo'
+    node: oneiric
+    jobs:
+      - 'periodic-{name}-python26-{branch-name}'
+      - 'periodic-{name}-python27-{branch-name}'
+      - 'periodic-{name}-docs-{branch-name}'
+
+- job-group:
+    name: python-essex-bitrot-jobs
+    branch: 'stable/essex'
+    branch-name: 'stable-essex'
+    node: precise
+    jobs:
+      - 'periodic-{name}-python26-{branch-name}'
+      - 'periodic-{name}-python27-{branch-name}'
+      - 'periodic-{name}-docs-{branch-name}'
diff --git a/example/python-cinderclient.yml b/example/python-cinderclient.yml
new file mode 100644
index 000000000..d05a16f76
--- /dev/null
+++ b/example/python-cinderclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-cinderclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-glanceclient.yml b/example/python-glanceclient.yml
new file mode 100644
index 000000000..691737e26
--- /dev/null
+++ b/example/python-glanceclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-glanceclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-jobs.yml b/example/python-jobs.yml
new file mode 100644
index 000000000..20233bb8d
--- /dev/null
+++ b/example/python-jobs.yml
@@ -0,0 +1,267 @@
+- job-template:
+    name: '{name}-coverage'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - zuul_post
+
+    builders:
+      - gerrit_git_prep
+      - coverage
+
+    publishers:
+       - coverage
+
+    node: '{node}'
+  
+
+- job-template:
+    name: 'gate-{name}-pep8'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - pep8
+
+    publishers:
+      - pep8
+
+    node: '{node}'
+  
+
+- job-template:
+    name: 'gate-{name}-python26'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - python26
+
+    # >= precise does not have python2.6
+    node: oneiric
+  
+
+- job-template:
+    name: 'gate-{name}-python27'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    parameters:
+      - label: 
+          name: NODE_LABEL
+          description: Label of node to use for this build
+          default: '{node}'
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - python27
+
+    node: '{node}'
+  
+
+- job-template:
+    name: 'gate-{name}-merge'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+    node: '{node}'
+
+
+- job-template:
+    name: 'gate-{name}-docs'
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - docs
+
+    node: '{node}'
+
+  
+- job-template:
+    name: 'gate-{name}-pyflakes'
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: 'https://github.com/{github-org}/{name}'
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - pyflakes
+
+
+- job-group:
+    name: python-jobs
+    jobs:
+      - '{name}-coverage'
+      - 'gate-{name}-merge'
+      - 'gate-{name}-pep8'
+      - 'gate-{name}-python26'
+      - 'gate-{name}-python27'
+      - 'gate-{name}-docs'
+      # pyflakes isn't standard
diff --git a/example/python-keystoneclient.yml b/example/python-keystoneclient.yml
new file mode 100644
index 000000000..6d4f94d1c
--- /dev/null
+++ b/example/python-keystoneclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-keystoneclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-novaclient.yml b/example/python-novaclient.yml
new file mode 100644
index 000000000..08ac2a3ab
--- /dev/null
+++ b/example/python-novaclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-novaclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-openstackclient.yml b/example/python-openstackclient.yml
new file mode 100644
index 000000000..caac05c5a
--- /dev/null
+++ b/example/python-openstackclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-openstackclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-quantumclient.yml b/example/python-quantumclient.yml
new file mode 100644
index 000000000..64c5c3f8d
--- /dev/null
+++ b/example/python-quantumclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-quantumclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/python-swiftclient.yml b/example/python-swiftclient.yml
new file mode 100644
index 000000000..ab0e47b66
--- /dev/null
+++ b/example/python-swiftclient.yml
@@ -0,0 +1,10 @@
+- project:
+    name: python-swiftclient
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: swift.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - pypi-jobs
diff --git a/example/quantum.yml b/example/quantum.yml
new file mode 100644
index 000000000..3bae88f7e
--- /dev/null
+++ b/example/quantum.yml
@@ -0,0 +1,10 @@
+- project:
+    name: quantum
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/reddwarf.yml b/example/reddwarf.yml
new file mode 100644
index 000000000..7786f8e6e
--- /dev/null
+++ b/example/reddwarf.yml
@@ -0,0 +1,7 @@
+- project:
+    name: reddwarf
+    github-org: stackforge
+    node: oneiric
+    
+    jobs:
+      - python-jobs
diff --git a/example/requirements.yml b/example/requirements.yml
new file mode 100644
index 000000000..c0c01a942
--- /dev/null
+++ b/example/requirements.yml
@@ -0,0 +1,34 @@
+- project:
+    name: requirements
+    github-org: openstack
+    node: oneiric
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+
+- job:
+    name: gate-requirements-merge
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+
+    properties:
+      - github:
+          url: https://github.com/openstack/requirements
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+    node: precise
diff --git a/example/swift.yml b/example/swift.yml
new file mode 100644
index 000000000..6faa2c651
--- /dev/null
+++ b/example/swift.yml
@@ -0,0 +1,10 @@
+- project:
+    name: swift
+    github-org: openstack
+    node: precise
+    tarball-publisher-site: nova.openstack.org
+    doc-publisher-site: docs.openstack.org
+    
+    jobs:
+      - python-jobs
+      - openstack-publish-jobs
diff --git a/example/tempest.yml b/example/tempest.yml
new file mode 100644
index 000000000..4f4739b0a
--- /dev/null
+++ b/example/tempest.yml
@@ -0,0 +1,67 @@
+- project:
+    name: tempest
+    github-org: openstack
+    node: precise
+    
+    # TODO: standardize
+    #jobs:
+    #  - gate-{name}-merge
+    #  - gate-{name}-pep8
+
+- job:
+    name: gate-tempest-merge
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/tempest
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+
+    node: precise
+
+- job:
+    name: gate-tempest-pep8
+    concurrent: true
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/tempest
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - pep8
+
+    publishers:
+      - pep8
+
+    node: precise
diff --git a/example/zuul.yml b/example/zuul.yml
new file mode 100644
index 000000000..bbe44c0b6
--- /dev/null
+++ b/example/zuul.yml
@@ -0,0 +1,79 @@
+- project:
+    name: zuul
+    github-org: openstack-ci
+    node: precise
+    jobs:
+      - python-jobs
+      # TODO: standardize these
+      #- gate-{name}-pyflakes
+      #- '{name}-docs
+
+- job:
+    name: gate-zuul-pyflakes
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/zuul
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul
+
+    builders:
+      - gerrit_git_prep
+      - pyflakes
+
+
+- job:
+    name: zuul-docs
+    project-type: freestyle
+    concurrent: true
+    node: precise
+
+    properties:
+      - github:
+          url: https://github.com/openstack-ci/zuul
+      - throttle:
+          max-per-node: 0
+          max-total: 0
+          option: project
+          enabled: false
+
+    wrappers:
+      - timeout:
+          timeout: 30
+          fail: true
+      - timestamps
+
+    triggers:
+      - zuul_post
+
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
+
+    builders:
+      - gerrit_git_prep
+      - docs
+
+    publishers:
+      - scp:
+          site: '173.203.107.207'
+          source: 'doc/build/html/**/*'
+          target: 'ci/zuul'
+          keep-hierarchy: true
diff --git a/jenkins-jobs b/jenkins-jobs
new file mode 100755
index 000000000..6e74058f5
--- /dev/null
+++ b/jenkins-jobs
@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+
+import jenkins_jobs.builder
+import argparse
+
+def main():
+    parser = argparse.ArgumentParser()
+    subparser = parser.add_subparsers(help='update, test or delete job',
+                                      dest='command')
+    parser_update = subparser.add_parser('update')
+    parser_update.add_argument('path', help='Path to YAML file or directory')
+    parser_update.add_argument('name', help='name of job')
+    parser_test = subparser.add_parser('test')
+    parser_test.add_argument('path', help='Path to YAML file or directory')
+    parser_test.add_argument('-o', dest='output_dir', help='Path to output XML')
+    parser_test.add_argument('name', help='name of job', nargs='?')
+    parser_delete = subparser.add_parser('delete')
+    parser_delete.add_argument('name', help='name of job')
+    parser.add_argument('--conf', dest='conf', help='Configuration file')
+    options = parser.parse_args()
+
+    if options.conf:
+        conf = options.conf
+    else:
+        conf = 'jenkins_jobs.ini'
+
+    if not options.command == 'test':
+        conffp = open(conf, 'r')
+        config = ConfigParser.ConfigParser()
+        config.readfp(conffp)
+    else:
+        config = {}
+
+    builder = jenkins_jobs.builder.Builder(config.get('jenkins','url'),
+                                           config.get('jenkins','user'),
+                                           config.get('jenkins','password'))
+
+    if options.command == 'delete':
+        builder.delete_job()
+    elif options.command == 'update':
+        builder.update_job()
+    elif options.command == 'test':
+        builder.update_job(options.path, options.name,
+                           output_dir=options.output_dir)
+
+if __name__ == '__main__':
+    main()
diff --git a/jenkins_jobs.py b/jenkins_jobs.py
deleted file mode 100644
index 92bce197a..000000000
--- a/jenkins_jobs.py
+++ /dev/null
@@ -1,279 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Manage jobs in Jenkins server
-
-import os
-import argparse
-import hashlib
-import yaml
-import xml.etree.ElementTree as XML
-from xml.dom import minidom
-import jenkins
-import ConfigParser
-from StringIO import StringIO
-import re
-import pkgutil
-
-import modules
-
-class JenkinsJobsException(Exception): pass
-
-parser = argparse.ArgumentParser()
-subparser = parser.add_subparsers(help='update, test or delete job', dest='command')
-parser_update = subparser.add_parser('update')
-parser_update.add_argument('file', help='YAML file for update')
-parser_update = subparser.add_parser('test')
-parser_update.add_argument('file', help='YAML file for test')
-parser_delete = subparser.add_parser('delete')
-parser_delete.add_argument('name', help='name of job')
-parser.add_argument('--conf', dest='conf', help='Configuration file')
-options = parser.parse_args()
-
-if options.conf:
-    conf = options.conf
-else:
-    conf = 'jenkins_jobs.ini'
-
-if not options.command == 'test':
-    conffp = open(conf, 'r')
-    config = ConfigParser.ConfigParser()
-    config.readfp(conffp)
-
-class YamlParser(object):
-    def __init__(self, yfile):
-        self.registry = ModuleRegistry()
-        self.data = yaml.load_all(yfile)
-        self.it = self.data.__iter__()
-        self.job_name = None
-        self.template_data = None
-        self.current = None
-        self.current_template = None
-        self.template_it = None
-        self.reading_template = False
-        self.eof = False
-        self.seek_next_xml()
-
-    def process_template(self):
-        project_data = self.current['project']
-        template_file = file('templates/' + project_data['template']  + '.yml', 'r')
-        template = template_file.read()
-        template_file.close()
-        values = self.current['values'].iteritems()
-        for key, value in values:
-            key = '@' + key.upper() + '@'
-            template = template.replace(key, value)
-        template_steam = StringIO(template)
-        self.template_data = yaml.load_all(template_steam)
-        self.template_it = self.template_data.__iter__()
-        self.reading_template = True
-
-    def get_next_xml(self):
-        if not self.eof:
-            if self.reading_template:
-                data = XmlParser(self.current_template, self.registry)
-                self.job_name = self.current_template['main']['name']
-            else:
-                data = XmlParser(self.current, self.registry)
-                self.job_name = self.current['main']['name']
-            self.seek_next_xml()
-            return data
-        else:
-            raise JenkinsJobsException('End of file')
-
-    def seek_next_xml(self):
-        if self.reading_template:
-            try:
-                self.current_template = self.template_it.next()
-                return
-            except StopIteration:
-                self.reading_template = False
-        try:
-            self.current = self.it.next()
-        except StopIteration:
-            self.eof = True
-
-        if self.current.has_key('project'):
-            self.process_template()
-            self.current_template = self.template_it.next()
-
-    def get_name(self):
-        return self.job_name
-
-class ModuleRegistry(object):
-    # TODO: make this extensible
-
-    def __init__(self):
-        self.modules = []
-        self.handlers = {}
-
-        for importer, modname, ispkg in pkgutil.iter_modules(modules.__path__):
-            module = __import__('modules.'+modname, fromlist=['register'])
-            register = getattr(module, 'register', None)
-            if register:
-                register(self)
-
-    def registerModule(self, mod):
-        self.modules.append(mod)
-        self.modules.sort(lambda a, b: cmp(a.sequence, b.sequence))
-
-    def registerHandler(self, category, name, method):
-        cat_dict = self.handlers.get(category, {})
-        if not cat_dict:
-            self.handlers[category] = cat_dict
-        cat_dict[name] = method
-
-    def getHandler(self, category, name):
-        return self.handlers[category][name]
-
-class XmlParser(object):
-    def __init__(self, data, registry):
-        self.data = data
-        self.registry = registry
-        self._build()
-
-    def _build(self):
-        for module in self.registry.modules:
-            if hasattr(module, 'root_xml'):
-                element = module.root_xml(self.data)
-                if element is not None:
-                    self.xml = element
-
-        for module in self.registry.modules:
-            if hasattr(module, 'handle_data'):
-                module.handle_data(self.data)
-        
-        XML.SubElement(self.xml, 'actions')
-        description = XML.SubElement(self.xml, 'description')
-        description.text = "THIS JOB IS MANAGED BY PUPPET AND WILL BE OVERWRITTEN.\n\n\
-DON'T EDIT THIS JOB THROUGH THE WEB\n\n\
-If you would like to make changes to this job, please see:\n\n\
-https://github.com/openstack/openstack-ci-puppet\n\n\
-In modules/jenkins_jobs"
-        XML.SubElement(self.xml, 'keepDependencies').text = 'false'
-        if self.data['main'].get('disabled'):
-            XML.SubElement(self.xml, 'disabled').text = 'true'
-        else:
-            XML.SubElement(self.xml, 'disabled').text = 'false'
-        XML.SubElement(self.xml, 'blockBuildWhenDownstreamBuilding').text = 'false'
-        XML.SubElement(self.xml, 'blockBuildWhenUpstreamBuilding').text = 'false'
-        if self.data['main'].get('concurrent'):
-            XML.SubElement(self.xml, 'concurrentBuild').text = 'true'
-        else:
-            XML.SubElement(self.xml, 'concurrentBuild').text = 'false'
-
-        for module in self.registry.modules:
-            if hasattr(module, 'gen_xml'):
-                module.gen_xml(self.xml, self.data)
-
-    def md5(self):
-        return hashlib.md5(self.output()).hexdigest()
-
-    # Pretty printing ideas from http://stackoverflow.com/questions/749796/pretty-printing-xml-in-python
-    pretty_text_re = re.compile('>\n\s+([^<>\s].*?)\n\s+</', re.DOTALL)
-
-    def output(self):
-        out = minidom.parseString(XML.tostring(self.xml)).toprettyxml(indent='  ')
-        return self.pretty_text_re.sub('>\g<1></', out)
-
-
-class CacheStorage(object):
-     def __init__(self):
-         self.cachefilename = os.path.expanduser('~/.jenkins_jobs_cache.yml')
-         try:
-             yfile = file(self.cachefilename, 'r')
-         except IOError:
-             self.data = {}
-             return
-         self.data = yaml.load(yfile)
-         yfile.close()
-
-     def set(self, job, md5):
-         self.data[job] = md5
-         yfile = file(self.cachefilename, 'w')
-         yaml.dump(self.data, yfile)
-         yfile.close()
-
-     def is_cached(self, job):
-         if self.data.has_key(job):
-            return True
-         return False
-
-     def has_changed(self, job, md5):
-         if self.data.has_key(job) and self.data[job] == md5:
-            return False
-         return True
-         
-class Jenkins(object):
-     def __init__(self, url, user, password):
-         self.jenkins = jenkins.Jenkins(url, user, password)
-
-     def update_job(self, job_name, xml):
-         if self.is_job(job_name):
-             self.jenkins.reconfig_job(job_name, xml)
-         else:
-             self.jenkins.create_job(job_name, xml)
-
-     def is_job(self, job_name):
-         return self.jenkins.job_exists(job_name)
-
-     def get_job_md5(self, job_name):
-         xml = self.jenkins.get_job_config(job_name)
-         return hashlib.md5(xml).hexdigest()
-
-     def delete_job(self, job_name):
-         if self.is_job(job_name):
-             self.jenkins.delete_job(job_name)
-
-def delete_job():
-    remote_jenkins = Jenkins(config.get('jenkins','url'), config.get('jenkins','user'), config.get('jenkins','password'))
-    remote_jenkins.delete_job(options.name)
-
-def update_job(test = False):
-    if os.path.isdir(options.file):
-        files_to_process = [os.path.join(options.file, f)
-                            for f in os.listdir(options.file)]
-    else:
-        files_to_process = [options.file]
-    cache = CacheStorage()
-    if not test:
-        remote_jenkins = Jenkins(config.get('jenkins','url'), config.get('jenkins','user'), config.get('jenkins','password'))
-    for in_file in files_to_process:
-        yparse = YamlParser(open(in_file, 'r'))
-        while True:
-            try:
-                xml = yparse.get_next_xml()
-                job = yparse.get_name()
-                if test:
-                    print xml.output()
-                    continue
-                md5 = xml.md5()
-                if remote_jenkins.is_job(job) and not cache.is_cached(job):
-                    old_md5 = remote_jenkins.get_job_md5(job)
-                    cache.set(job, old_md5)
-
-                if cache.has_changed(job, md5):
-                    remote_jenkins.update_job(job, xml.output())
-                    cache.set(job, md5)
-            except JenkinsJobsException:
-                break
-
-if options.command == 'delete':
-    delete_job()
-elif options.command == 'update':
-    update_job()
-elif options.command == 'test':
-    update_job(True)
-
diff --git a/modules/__init__.py b/jenkins_jobs/__init__.py
similarity index 100%
rename from modules/__init__.py
rename to jenkins_jobs/__init__.py
diff --git a/jenkins_jobs/builder.py b/jenkins_jobs/builder.py
new file mode 100644
index 000000000..80bddbbc5
--- /dev/null
+++ b/jenkins_jobs/builder.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python
+# Copyright (C) 2012 OpenStack, LLC.
+#
+# 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.
+
+# Manage jobs in Jenkins server
+
+import os
+import hashlib
+import yaml
+import xml.etree.ElementTree as XML
+from xml.dom import minidom
+import jenkins
+import ConfigParser
+from StringIO import StringIO
+import re
+import pkgutil
+import pkg_resources
+import pprint
+import sys
+
+class JenkinsJobsException(Exception): pass
+
+class YamlParser(object):
+    def __init__(self):
+        self.registry = ModuleRegistry()
+        self.data = {}
+        self.jobs = []
+
+    def parse(self, fn):
+        data = yaml.load(open(fn))
+        for item in data:
+            cls, dfn = item.items()[0]
+            group = self.data.get(cls, {})
+            name = dfn['name']
+            group[name] = dfn
+            self.data[cls] = group
+
+    def getJob(self, name):
+        return self.data.get('job', {}).get(name, None)
+
+    def getJobGroup(self, name):
+        return self.data.get('job-group', {}).get(name, None)
+
+    def getJobTemplate(self, name):
+        return self.data.get('job-template', {}).get(name, None)
+
+    def generateXML(self):
+        changed = True
+        while changed:
+            changed = False
+            for module in self.registry.modules:
+                if hasattr(module, 'handle_data'):
+                    if module.handle_data(self):
+                        changed = True
+
+        for job in self.data.get('job', {}).values():
+            self.getXMLForJob(job)
+        for project in self.data.get('project', {}).values():
+            for jobname in project.get('jobs', []):
+                job = self.getJob(jobname)
+                if job:
+                    # Just naming an existing defined job
+                    continue
+                # see if it's a job group
+                group = self.getJobGroup(jobname)
+                if group:
+                    for group_jobname in group['jobs']:
+                        job = self.getJob(group_jobname)
+                        if job:
+                            continue
+                        template = self.getJobTemplate(group_jobname)
+                        # Allow a group to override parameters set by a project
+                        d = {}
+                        d.update(project)
+                        d.update(group)
+                        # Except name, since the group's name is not useful
+                        d['name'] = project['name']
+                        if template:
+                            self.getXMLForTemplateJob(d, template)
+                    continue
+                # see if it's a template
+                template = self.getJobTemplate(jobname)
+                if template:
+                    self.getXMLForTemplateJob(project, template)
+
+    def getXMLForTemplateJob(self, project, template):
+        s = yaml.dump(template, default_flow_style=False)
+        s = s.format(**project)
+        data = yaml.load(s)
+        self.getXMLForJob(data)
+
+    def getXMLForJob(self, data):
+        kind = data.get('project-type', 'freestyle')
+        for ep in pkg_resources.iter_entry_points(
+            group='jenkins_jobs.projects', name=kind):
+            Mod = ep.load()
+            mod = Mod(self.registry)
+            xml = mod.root_xml(data)
+            self.gen_xml(xml, data)
+            job = XmlJob(xml, data['name'])
+            self.jobs.append(job)
+            break
+
+    def gen_xml(self, xml, data):
+        XML.SubElement(xml, 'actions')
+        description = XML.SubElement(xml, 'description')
+        description.text = "THIS JOB IS MANAGED BY PUPPET AND WILL BE OVERWRITTEN.\n\n\
+DON'T EDIT THIS JOB THROUGH THE WEB\n\n\
+If you would like to make changes to this job, please see:\n\n\
+https://github.com/openstack/openstack-ci-puppet\n\n\
+In modules/jenkins_jobs"
+        XML.SubElement(xml, 'keepDependencies').text = 'false'
+        if data.get('disabled'):
+            XML.SubElement(xml, 'disabled').text = 'true'
+        else:
+            XML.SubElement(xml, 'disabled').text = 'false'
+        XML.SubElement(xml, 'blockBuildWhenDownstreamBuilding').text = 'false'
+        XML.SubElement(xml, 'blockBuildWhenUpstreamBuilding').text = 'false'
+        if data.get('concurrent'):
+            XML.SubElement(xml, 'concurrentBuild').text = 'true'
+        else:
+            XML.SubElement(xml, 'concurrentBuild').text = 'false'
+
+        for module in self.registry.modules:
+            if hasattr(module, 'gen_xml'):
+                module.gen_xml(self, xml, data)
+
+
+class ModuleRegistry(object):
+    # TODO: make this extensible
+
+    def __init__(self):
+        self.modules = []
+        self.handlers = {}
+
+        for entrypoint in pkg_resources.iter_entry_points(
+            group='jenkins_jobs.modules'):
+            Mod = entrypoint.load()
+            mod = Mod(self)
+            self.modules.append(mod)
+            self.modules.sort(lambda a, b: cmp(a.sequence, b.sequence))
+
+    def registerHandler(self, category, name, method):
+        cat_dict = self.handlers.get(category, {})
+        if not cat_dict:
+            self.handlers[category] = cat_dict
+        cat_dict[name] = method
+
+    def getHandler(self, category, name):
+        return self.handlers[category][name]
+
+class XmlJob(object):
+    def __init__(self, xml, name):
+        self.xml = xml
+        self.name = name
+
+    def md5(self):
+        return hashlib.md5(self.output()).hexdigest()
+
+    # Pretty printing ideas from http://stackoverflow.com/questions/749796/pretty-printing-xml-in-python
+    pretty_text_re = re.compile('>\n\s+([^<>\s].*?)\n\s+</', re.DOTALL)
+
+    def output(self):
+        out = minidom.parseString(XML.tostring(self.xml)).toprettyxml(indent='  ')
+        return self.pretty_text_re.sub('>\g<1></', out)
+
+
+class CacheStorage(object):
+     def __init__(self):
+         self.cachefilename = os.path.expanduser('~/.jenkins_jobs_cache.yml')
+         try:
+             yfile = file(self.cachefilename, 'r')
+         except IOError:
+             self.data = {}
+             return
+         self.data = yaml.load(yfile)
+         yfile.close()
+
+     def set(self, job, md5):
+         self.data[job] = md5
+         yfile = file(self.cachefilename, 'w')
+         yaml.dump(self.data, yfile)
+         yfile.close()
+
+     def is_cached(self, job):
+         if self.data.has_key(job):
+            return True
+         return False
+
+     def has_changed(self, job, md5):
+         if self.data.has_key(job) and self.data[job] == md5:
+            return False
+         return True
+
+class Jenkins(object):
+     def __init__(self, url, user, password):
+         self.jenkins = jenkins.Jenkins(url, user, password)
+
+     def update_job(self, job_name, xml):
+         if self.is_job(job_name):
+             self.jenkins.reconfig_job(job_name, xml)
+         else:
+             self.jenkins.create_job(job_name, xml)
+
+     def is_job(self, job_name):
+         return self.jenkins.job_exists(job_name)
+
+     def get_job_md5(self, job_name):
+         xml = self.jenkins.get_job_config(job_name)
+         return hashlib.md5(xml).hexdigest()
+
+     def delete_job(self, job_name):
+         if self.is_job(job_name):
+             self.jenkins.delete_job(job_name)
+
+class Builder(object):
+    def __init__(self, jenkins_url, jenkins_user, jenkins_password):
+        self.jenkins = Jenkins(jenkins_url, jenkins_user, jenkins_password)
+        self.cache = CacheStorage()
+
+    def delete_job(self):
+        self.jenkins.delete_job(options.name)
+
+    def update_job(self, fn, name=None, output_dir=None):
+        if os.path.isdir(fn):
+            files_to_process = [os.path.join(fn, f)
+                                for f in os.listdir(fn)
+                                if (f.endswith('.yml') or f.endswith('.yaml'))]
+        else:
+            files_to_process = [fn]
+        parser = YamlParser()
+        for in_file in files_to_process:
+            parser.parse(in_file)
+        parser.generateXML()
+
+        parser.jobs.sort(lambda a,b: cmp(a.name, b.name))
+        for job in parser.jobs:
+            if name and job.name != name:
+                continue
+            if output_dir:
+                #print '='*70
+                #print job.name
+                #print '-'*70
+                if name:
+                    print job.output()
+                    continue
+                fn = os.path.join(output_dir, job.name)
+                f = open(fn, 'w')
+                f.write(job.output())
+                f.close()
+                continue
+            md5 = job.md5()
+            if (remote_jenkins.is_job(job.nam)
+                and not self.cache.is_cached(job.name)):
+                old_md5 = remote_jenkins.get_job_md5(job.name)
+                self.cache.set(job.name, old_md5)
+
+            if self.cache.has_changed(job.name, md5):
+                remote_jenkins.update_job(job.name, xml.output())
+                self.cache.set(job.name, md5)
+
+
+
diff --git a/jenkins_jobs/modules/__init__.py b/jenkins_jobs/modules/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/modules/assignednode.py b/jenkins_jobs/modules/assignednode.py
similarity index 65%
rename from modules/assignednode.py
rename to jenkins_jobs/modules/assignednode.py
index b40f22226..6ce935f86 100644
--- a/modules/assignednode.py
+++ b/jenkins_jobs/modules/assignednode.py
@@ -1,5 +1,4 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -19,18 +18,14 @@
 #   - node: 'oneiric'
 
 import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
 
 
-def register(registry):
-    mod = AssignedNode()
-    registry.registerModule(mod)
-
-
-class AssignedNode(object):
+class AssignedNode(jenkins_jobs.modules.base.Base):
     sequence = 40
 
-    def gen_xml(self, xml_parent, data):
-        node = data['assignednode']['node']
-        XML.SubElement(xml_parent, 'assignedNode').text = node
-        XML.SubElement(xml_parent, 'canRoam').text = 'false' 
-
+    def gen_xml(self, parser, xml_parent, data):
+        node = data.get('node', None)
+        if node:
+            XML.SubElement(xml_parent, 'assignedNode').text = node
+            XML.SubElement(xml_parent, 'canRoam').text = 'false'
diff --git a/jenkins_jobs/modules/base.py b/jenkins_jobs/modules/base.py
new file mode 100644
index 000000000..ca377051e
--- /dev/null
+++ b/jenkins_jobs/modules/base.py
@@ -0,0 +1,58 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Base class for a jenkins_jobs module
+
+import pkg_resources
+import yaml
+
+
+class Base(object):
+    sequence = 10
+
+    def __init__(self, registry):
+        self.registry = registry
+
+    def _dispatch(self, component_type, component_list_type,
+                  parser, xml_parent,
+                  component, template_data={}):
+        if isinstance(component, dict):
+            # The component is a sigleton dictionary of name: dict(args)
+            name, component_data = component.items()[0]
+            if template_data:
+                # Template data contains values that should be interpolated
+                # into the component definition
+                s = yaml.dump(component_data, default_flow_style=False)
+                s = s.format(**template_data)
+                component_data = yaml.load(s)
+        else:
+            # The component is a simple string name, eg "run-tests"
+            name = component
+            component_data = {}
+
+        # Look for a component function defined in an entry point
+        for ep in pkg_resources.iter_entry_points(
+            group='jenkins_jobs.{0}'.format(component_list_type), name=name):
+            func = ep.load()
+            func(parser, xml_parent, component_data)
+        else:
+            # Otherwise, see if it's defined as a macro
+            component = parser.data.get(component_type, {}).get(name)
+            if component:
+                for b in component[component_list_type]:
+                    # Pass component_data in as template data to this function
+                    # so that if the macro is invoked with arguments,
+                    # the arguments are interpolated into the real defn.
+                    self._dispatch(component_type, component_list_type,
+                                   parser, xml_parent, b, component_data)
diff --git a/jenkins_jobs/modules/builders.py b/jenkins_jobs/modules/builders.py
new file mode 100644
index 000000000..5697cff84
--- /dev/null
+++ b/jenkins_jobs/modules/builders.py
@@ -0,0 +1,63 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for builders
+# To use add the folowing into your YAML:
+# builders:
+#   - 'gerrit_git_prep'
+#   - 'python26'
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+import pkg_resources
+import yaml
+
+def shell(parser, xml_parent, data):
+    shell = XML.SubElement(xml_parent, 'hudson.tasks.Shell')
+    XML.SubElement(shell, 'command').text = data
+
+def trigger_builds(parser, xml_parent, data):
+    tbuilder = XML.SubElement(xml_parent,
+                              'hudson.plugins.parameterizedtrigger.TriggerBuilder')
+    configs = XML.SubElement(tbuilder, 'configs')
+    for project_def in data:
+        tconfig = XML.SubElement(configs,
+            'hudson.plugins.parameterizedtrigger.BlockableBuildTriggerConfig')
+        tconfigs = XML.SubElement(tconfig, 'configs')
+        if project_def.has_key('predefined_parameters'):
+            params = XML.SubElement(tconfigs,
+                'hudson.plugins.parameterizedtrigger.PredefinedBuildParameters')
+            properties = XML.SubElement(params, 'properties')
+            properties.text = project_def['predefined_parameters']
+        else:
+            tconfigs.set('class', 'java.util.Collections$EmptyList')
+        projects = XML.SubElement(tconfig, 'projects')
+        projects.text = project_def['project']
+        condition = XML.SubElement(tconfig, 'condition')
+        condition.text = 'ALWAYS'
+        trigger_with_no_params = XML.SubElement(tconfig, 'triggerWithNoParameters')
+        trigger_with_no_params.text = 'false'
+        build_all_nodes_with_label = XML.SubElement(tconfig, 'buildAllNodesWithLabel')
+        build_all_nodes_with_label.text = 'false'
+
+class Builders(jenkins_jobs.modules.base.Base):
+    sequence = 60
+
+    def gen_xml(self, parser, xml_parent, data):
+        for alias in ['prebuilders', 'builders', 'postbuilders']:
+            if alias in data:
+                builders = XML.SubElement(xml_parent, alias)
+                for builder in data[alias]:
+                    self._dispatch('builder', 'builders',
+                                   parser, builders, builder)
diff --git a/modules/logrotate.py b/jenkins_jobs/modules/logrotate.py
similarity index 78%
rename from modules/logrotate.py
rename to jenkins_jobs/modules/logrotate.py
index 9721b7a79..46c9a7b62 100644
--- a/modules/logrotate.py
+++ b/jenkins_jobs/modules/logrotate.py
@@ -1,5 +1,4 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -22,23 +21,16 @@
 #  artifactNumToKeep: -1
 
 import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
 
 
-def register(registry):
-    mod = LogRotate()
-    registry.registerModule(mod)
-
-
-class LogRotate(object):
+class LogRotate(jenkins_jobs.modules.base.Base):
     sequence = 10
 
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        if self.data.has_key('logrotate'):
+    def gen_xml(self, parser, xml_parent, data):
+        if data.has_key('logrotate'):
             lr_xml = XML.SubElement(xml_parent, 'logRotator')
-            logrotate = self.data['logrotate']
+            logrotate = data['logrotate']
             lr_days = XML.SubElement(lr_xml, 'daysToKeep')
             lr_days.text = str(logrotate['daysToKeep'])
             lr_num = XML.SubElement(lr_xml, 'numToKeep')
diff --git a/modules/project_freestyle.py b/jenkins_jobs/modules/project_freestyle.py
similarity index 80%
rename from modules/project_freestyle.py
rename to jenkins_jobs/modules/project_freestyle.py
index 72ce41214..b4c3b4697 100644
--- a/modules/project_freestyle.py
+++ b/jenkins_jobs/modules/project_freestyle.py
@@ -1,5 +1,4 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -22,18 +21,12 @@
 #   goals: 'test'
 
 import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
 
 
-def register(registry):
-    mod = Freestyle()
-    registry.registerModule(mod)
-
-
-class Freestyle(object):
+class Freestyle(jenkins_jobs.modules.base.Base):
     sequence = 0
 
     def root_xml(self, data):
-        if 'maven' in data:
-            return None
         xml_parent = XML.Element('project')
         return xml_parent
diff --git a/modules/project_maven.py b/jenkins_jobs/modules/project_maven.py
similarity index 93%
rename from modules/project_maven.py
rename to jenkins_jobs/modules/project_maven.py
index 0131c7d01..0b775c0d7 100644
--- a/modules/project_maven.py
+++ b/jenkins_jobs/modules/project_maven.py
@@ -1,5 +1,4 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -22,14 +21,9 @@
 #   goals: 'test'
 
 import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
 
-
-def register(registry):
-    mod = Maven()
-    registry.registerModule(mod)
-
-
-class Maven(object):
+class Maven(jenkins_jobs.modules.base.Base):
     sequence = 0
 
     def root_xml(self, data):
diff --git a/jenkins_jobs/modules/properties.py b/jenkins_jobs/modules/properties.py
new file mode 100644
index 000000000..f12e21d0f
--- /dev/null
+++ b/jenkins_jobs/modules/properties.py
@@ -0,0 +1,117 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for job properties
+# No additional YAML needed
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+
+def github(parser, xml_parent, data):
+    github = XML.SubElement(xml_parent,
+               'com.coravy.hudson.plugins.github.GithubProjectProperty')
+    github_url = XML.SubElement(github, 'projectUrl')
+    github_url.text = data['url']
+
+def throttle(parser, xml_parent, data):
+    throttle = XML.SubElement(xml_parent,
+                 'hudson.plugins.throttleconcurrents.ThrottleJobProperty')
+    XML.SubElement(throttle, 'maxConcurrentPerNode').text = str(
+        data.get('max-per-node'))
+    XML.SubElement(throttle, 'maxConcurrentTotal').text = str(
+        data.get('max-total'))
+    # TODO: What's "categories"?
+    #XML.SubElement(throttle, 'categories')
+    if data.get('enabled', True):
+        XML.SubElement(throttle, 'throttleEnabled').text = 'true'
+    else:
+        XML.SubElement(throttle, 'throttleEnabled').text = 'false'
+    XML.SubElement(throttle, 'throttleOption').text = data.get('option')
+    XML.SubElement(throttle, 'configVersion').text = '1'
+
+def authenticated_build(parser, xml_parent, data):
+    # TODO: generalize this
+    if data:
+        security = XML.SubElement(xml_parent,
+                        'hudson.security.AuthorizationMatrixProperty')
+        XML.SubElement(security, 'permission').text = \
+        'hudson.model.Item.Build:authenticated'
+
+def base_param(parser, xml_parent, data, do_default, ptype):
+    pdef = XML.SubElement(xml_parent, ptype)
+    XML.SubElement(pdef, 'name').text = data['name']
+    XML.SubElement(pdef, 'description').text = data['description']
+    if do_default:
+        default = data.get('default', None)
+        if default:
+            XML.SubElement(pdef, 'defaultValue').text = default
+        else:
+            XML.SubElement(pdef, 'defaultValue')
+
+def string_param(parser, xml_parent, data):
+    base_param(parser, xml_parent, data, True,
+               'hudson.model.StringParameterDefinition')
+
+def bool_param(parser, xml_parent, data):
+    base_param(parser, xml_parent, data, True,
+               'hudson.model.BooleanParameterDefinition')
+
+def file_param(parser, xml_parent, data):
+    base_param(parser, xml_parent, data, False,
+               'hudson.model.FileParameterDefinition')
+
+def text_param(parser, xml_parent, data):
+    base_param(parser, xml_parent, data, True,
+               'hudson.model.TextParameterDefinition')
+
+def label_param(parser, xml_parent, data):
+    base_param(parser, xml_parent, data, True,
+      'org.jvnet.jenkins.plugins.nodelabelparameter.LabelParameterDefinition')
+
+def http_endpoint(parser, xml_parent, data):
+    endpoint_element = XML.SubElement(xml_parent,
+                'com.tikal.hudson.plugins.notification.Endpoint')
+    XML.SubElement(endpoint_element, 'protocol').text = 'HTTP'
+    XML.SubElement(endpoint_element, 'url').text = data['url']
+
+
+class Properties(jenkins_jobs.modules.base.Base):
+    sequence = 20
+
+    def gen_xml(self, parser, xml_parent, data):
+        properties = XML.SubElement(xml_parent, 'properties')
+
+        for prop in data.get('properties', []):
+            self._dispatch('property', 'properties',
+                           parser, properties, prop)
+
+        parameters = data.get('parameters', [])
+        if parameters:
+            pdefp = XML.SubElement(properties,
+                                   'hudson.model.ParametersDefinitionProperty')
+            pdefs = XML.SubElement(pdefp, 'parameterDefinitions')
+            for param in parameters:
+                self._dispatch('parameter', 'parameters',
+                               parser, pdefs, param)
+
+        notifications = data.get('notifications', [])
+        if notifications:
+            notify_element = XML.SubElement(properties,
+            'com.tikal.hudson.plugins.notification.HudsonNotificationProperty')
+            endpoints_element = XML.SubElement(notify_element, 'endpoints')
+
+            for endpoint in notifications:
+                self._dispatch('notification', 'notifications',
+                               parser, endpoints_element, endpoint)
+
diff --git a/jenkins_jobs/modules/publishers.py b/jenkins_jobs/modules/publishers.py
new file mode 100644
index 000000000..eaf8104cb
--- /dev/null
+++ b/jenkins_jobs/modules/publishers.py
@@ -0,0 +1,290 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for coverage publishers
+# No additional YAML needed
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+
+def archive(parser, xml_parent, data):
+    archiver = XML.SubElement(xml_parent, 'hudson.tasks.ArtifactArchiver')
+    artifacts = XML.SubElement(archiver, 'artifacts')
+    artifacts.text = data['artifacts']
+    if 'excludes' in data:
+        excludes = XML.SubElement(archiver, 'excludes')
+        excludes.text = data['excludes']
+    latest = XML.SubElement(archiver, 'latestOnly')
+    latest_only = data.get('latest_only', False)
+    if latest_only:
+        latest.text = 'true'
+    else:
+        latest.text = 'false'
+
+def trigger_parameterized_builds(parser, xml_parent, data):
+    tbuilder = XML.SubElement(xml_parent,
+        'hudson.plugins.parameterizedtrigger.BuildTrigger')
+    configs = XML.SubElement(tbuilder, 'configs')
+    for project_def in data:
+        tconfig = XML.SubElement(configs,
+            'hudson.plugins.parameterizedtrigger.BuildTriggerConfig')
+        tconfigs = XML.SubElement(tconfig, 'configs')
+        if project_def.has_key('predefined_parameters'):
+            params = XML.SubElement(tconfigs,
+                'hudson.plugins.parameterizedtrigger.PredefinedBuildParameters')
+            properties = XML.SubElement(params, 'properties')
+            properties.text = project_def['predefined_parameters']
+        else:
+            tconfigs.set('class', 'java.util.Collections$EmptyList')
+        projects = XML.SubElement(tconfig, 'projects')
+        projects.text = project_def['project']
+        condition = XML.SubElement(tconfig, 'condition')
+        condition.text = project_def.get('condition', 'ALWAYS')
+        trigger_with_no_params = XML.SubElement(tconfig,
+                                                'triggerWithNoParameters')
+        trigger_with_no_params.text = 'false'
+
+def coverage(parser, xml_parent, data):
+    cobertura = XML.SubElement(xml_parent,
+                               'hudson.plugins.cobertura.CoberturaPublisher')
+    XML.SubElement(cobertura, 'coberturaReportFile').text = '**/coverage.xml'
+    XML.SubElement(cobertura, 'onlyStable').text = 'false'
+    healthy = XML.SubElement(cobertura, 'healthyTarget')
+    targets = XML.SubElement(healthy, 'targets', {
+            'class':'enum-map',
+            'enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'CONDITIONAL'
+    XML.SubElement(entry, 'int').text = '70'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'LINE'
+    XML.SubElement(entry, 'int').text = '80'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'METHOD'
+    XML.SubElement(entry, 'int').text = '80'
+    unhealthy = XML.SubElement(cobertura, 'unhealthyTarget')
+    targets = XML.SubElement(unhealthy, 'targets', {
+            'class':'enum-map',
+            'enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'CONDITIONAL'
+    XML.SubElement(entry, 'int').text = '0'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'LINE'
+    XML.SubElement(entry, 'int').text = '0'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'METHOD'
+    XML.SubElement(entry, 'int').text = '0'
+    failing = XML.SubElement(cobertura, 'failingTarget')
+    targets = XML.SubElement(failing, 'targets', {
+            'class':'enum-map',
+            'enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'CONDITIONAL'
+    XML.SubElement(entry, 'int').text = '0'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'LINE'
+    XML.SubElement(entry, 'int').text = '0'
+    entry = XML.SubElement(targets, 'entry')
+    XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric'
+                   ).text = 'METHOD'
+    XML.SubElement(entry, 'int').text = '0'
+    XML.SubElement(cobertura, 'sourceEncoding').text = 'ASCII'
+
+
+# Jenkins Job module for publishing via ftp
+# publish:
+#   site: 'docs.openstack.org'
+#   remote_dir: 'dest/dir'
+#   source_files: 'base/source/dir/**'
+#   remove_prefix: 'base/source/dir'
+#   excludes: '**/*.exludedfiletype'
+#
+# This will upload everything under $workspace/base/source/dir to
+# docs.openstack.org $ftpdir/dest/dir exluding the excluded file type.
+
+def ftp(parser, xml_parent, data):
+    """
+    Example XML:
+    <publishers>
+      <jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin>
+        <consolePrefix>FTP: </consolePrefix>
+        <delegate>
+          <publishers>
+            <jenkins.plugins.publish__over__ftp.BapFtpPublisher>
+              <configName>docs.openstack.org</configName>
+              <verbose>true</verbose>
+              <transfers>
+                <jenkins.plugins.publish__over__ftp.BapFtpTransfer>
+                  <remoteDirectory></remoteDirectory>
+                  <sourceFiles>openstack-identity-api/target/docbkx/webhelp/api/openstack-identity-service/2.0/**</sourceFiles>
+                  <excludes>**/*.xml,**/null*</excludes>
+                  <removePrefix>openstack-identity-api/target/docbkx/webhelp</removePrefix>
+                  <remoteDirectorySDF>false</remoteDirectorySDF>
+                  <flatten>false</flatten>
+                  <cleanRemote>false</cleanRemote>
+                  <asciiMode>false</asciiMode>
+                </jenkins.plugins.publish__over__ftp.BapFtpTransfer>
+              </transfers>
+              <useWorkspaceInPromotion>false</useWorkspaceInPromotion>
+              <usePromotionTimestamp>false</usePromotionTimestamp>
+            </jenkins.plugins.publish__over__ftp.BapFtpPublisher>
+          </publishers>
+          <continueOnError>false</continueOnError>
+          <failOnError>false</failOnError>
+          <alwaysPublishFromMaster>false</alwaysPublishFromMaster>
+          <hostConfigurationAccess class="jenkins.plugins.publish_over_ftp.BapFtpPublisherPlugin" reference="../.."/>
+        </delegate>
+      </jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin>
+    </publishers>
+    """
+    outer_ftp = XML.SubElement(xml_parent,
+        'jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin')
+    XML.SubElement(outer_ftp, 'consolePrefix').text = 'FTP: '
+    delegate = XML.SubElement(outer_ftp, 'delegate')
+    publishers = XML.SubElement(delegate, 'publishers')
+    ftp = XML.SubElement(publishers,
+                         'jenkins.plugins.publish__over__ftp.BapFtpPublisher')
+    XML.SubElement(ftp, 'configName').text = data['site']
+    XML.SubElement(ftp, 'verbose').text = 'true'
+
+    transfers = XML.SubElement(ftp, 'transfers')
+    ftp_transfers = XML.SubElement(transfers, 'jenkins.plugins.publish__over__ftp.BapFtpTransfer')
+    XML.SubElement(ftp_transfers, 'remoteDirectory').text = data['target']
+    XML.SubElement(ftp_transfers, 'sourceFiles').text = data['source']
+    XML.SubElement(ftp_transfers, 'excludes').text = data['excludes']
+    XML.SubElement(ftp_transfers, 'removePrefix').text = data['remove-prefix']
+    XML.SubElement(ftp_transfers, 'remoteDirectorySDF').text = 'false'
+    XML.SubElement(ftp_transfers, 'flatten').text = 'false'
+    XML.SubElement(ftp_transfers, 'cleanRemote').text = 'false'
+    XML.SubElement(ftp_transfers, 'asciiMode').text = 'false'
+
+    XML.SubElement(ftp, 'useWorkspaceInPromotion').text = 'false'
+    XML.SubElement(ftp, 'usePromotionTimestamp').text = 'false'
+    XML.SubElement(delegate, 'continueOnError').text = 'false'
+    XML.SubElement(delegate, 'failOnError').text = 'false'
+    XML.SubElement(delegate, 'alwaysPublishFromMaster').text = 'false'
+    XML.SubElement(delegate, 'hostConfigurationAccess',
+        {'class': 'jenkins.plugins.publish_over_ftp.BapFtpPublisherPlugin',
+         'reference': '../..'})
+
+# Jenkins Job module for coverage publishers
+# To use you add the following into your YAML:
+# publisher:
+#   results: 'nosetests.xml'
+
+def junit(parser, xml_parent, data):
+    junitresult = XML.SubElement(xml_parent,
+                                 'hudson.tasks.junit.JUnitResultArchiver')
+    XML.SubElement(junitresult, 'testResults').text = data['results']
+    XML.SubElement(junitresult, 'keepLongStdio').text = "true"
+    XML.SubElement(junitresult, 'testDataPublishers')
+
+
+def _pep8_add_entry(xml_parent, name):
+    entry = XML.SubElement(xml_parent, 'entry')
+    XML.SubElement(entry, 'string').text = name
+    tconfig = XML.SubElement(entry, 'hudson.plugins.violations.TypeConfig')
+    XML.SubElement(tconfig, 'type').text = name
+    XML.SubElement(tconfig, 'min').text = '10'
+    XML.SubElement(tconfig, 'max').text = '999'
+    XML.SubElement(tconfig, 'unstable').text = '999'
+    XML.SubElement(tconfig, 'usePattern').text = 'false'
+    XML.SubElement(tconfig, 'pattern')
+
+# Jenkins Job module for pep8 publishers
+# No additional YAML needed
+
+def pep8(parser, xml_parent, data):
+    violations = XML.SubElement(xml_parent,
+                                'hudson.plugins.violations.ViolationsPublisher')
+    config = XML.SubElement(violations, 'config')
+    suppressions = XML.SubElement(config, 'suppressions', {'class':'tree-set'})
+    XML.SubElement(suppressions, 'no-comparator')
+    configs = XML.SubElement(config, 'typeConfigs')
+    XML.SubElement(configs, 'no-comparator')
+
+    _pep8_add_entry(configs, 'checkstyle')
+    _pep8_add_entry(configs, 'codenarc')
+    _pep8_add_entry(configs, 'cpd')
+    _pep8_add_entry(configs, 'cpplint')
+    _pep8_add_entry(configs, 'csslint')
+    _pep8_add_entry(configs, 'findbugs')
+    _pep8_add_entry(configs, 'fxcop')
+    _pep8_add_entry(configs, 'gendarme')
+    _pep8_add_entry(configs, 'jcreport')
+    _pep8_add_entry(configs, 'jslint')
+
+    entry = XML.SubElement(configs, 'entry')
+    XML.SubElement(entry, 'string').text = 'pep8'
+    tconfig = XML.SubElement(entry, 'hudson.plugins.violations.TypeConfig')
+    XML.SubElement(tconfig, 'type').text = 'pep8'
+    XML.SubElement(tconfig, 'min').text = '0'
+    XML.SubElement(tconfig, 'max').text = '1'
+    XML.SubElement(tconfig, 'unstable').text = '1'
+    XML.SubElement(tconfig, 'usePattern').text = 'false'
+    XML.SubElement(tconfig, 'pattern').text = '**/pep8.txt'
+
+    _pep8_add_entry(configs, 'pmd')
+    _pep8_add_entry(configs, 'pylint')
+    _pep8_add_entry(configs, 'simian')
+    _pep8_add_entry(configs, 'stylecop')
+
+    XML.SubElement(config, 'limit').text = '100'
+    XML.SubElement(config, 'sourcePathPattern')
+    XML.SubElement(config, 'fauxProjectPath')
+    XML.SubElement(config, 'encoding').text = 'default'
+
+# Jenkins Job module for generic scp publishing
+# To use you add the following into your YAML:
+# publish:
+#   site: 'openstack-ci.openstack.org'
+#   source: 'doc/build/html/**/*'
+#   target_path: 'ci/zuul'
+#   keep_heirarchy: 'true'
+
+def scp(parser, xml_parent, data):
+    site = data['site']
+    scp = XML.SubElement(xml_parent,
+                         'be.certipost.hudson.plugin.SCPRepositoryPublisher')
+    XML.SubElement(scp, 'siteName').text = site
+    entries = XML.SubElement(scp, 'entries')
+    entry = XML.SubElement(entries, 'be.certipost.hudson.plugin.Entry')
+    XML.SubElement(entry, 'filePath').text = data['target']
+    XML.SubElement(entry, 'sourceFile').text = data['source']
+    if data.get('keep-hierarchy', False):
+        XML.SubElement(entry, 'keepHierarchy').text = 'true'
+    else:
+        XML.SubElement(entry, 'keepHierarchy').text = 'false'
+
+class Publishers(jenkins_jobs.modules.base.Base):
+    sequence = 70
+
+    def gen_xml(self, parser, xml_parent, data):
+        publishers = XML.SubElement(xml_parent, 'publishers')
+
+        for action in data.get('publishers', []):
+            self._dispatch('publisher', 'publishers',
+                           parser, publishers, action)
+
+
+
diff --git a/jenkins_jobs/modules/scm.py b/jenkins_jobs/modules/scm.py
new file mode 100644
index 000000000..470ce7de0
--- /dev/null
+++ b/jenkins_jobs/modules/scm.py
@@ -0,0 +1,72 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for scm
+# To use add the folowing into your YAML:
+# scm:
+#  scm: 'true'
+# or
+#  scm: 'false'
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+
+def git(self, xml_parent, data):
+    scm = XML.SubElement(xml_parent,
+                         'scm',{'class':'hudson.plugins.git.GitSCM'})
+    XML.SubElement(scm, 'configVersion').text = '2'
+    user = XML.SubElement(scm, 'userRemoteConfigs')
+    huser = XML.SubElement(user, 'hudson.plugins.git.UserRemoteConfig')
+    XML.SubElement(huser, 'name').text = 'origin'
+    XML.SubElement(huser, 'refspec').text = \
+        '+refs/heads/*:refs/remotes/origin/*'
+    XML.SubElement(huser, 'url').text = data['url']
+    xml_branches = XML.SubElement(scm, 'branches')
+    branches = data.get('branches', ['**'])
+    for branch in branches:
+        bspec = XML.SubElement(xml_branches, 'hudson.plugins.git.BranchSpec')
+        XML.SubElement(bspec, 'name').text = branch
+    XML.SubElement(scm, 'disableSubmodules').text = 'false'
+    XML.SubElement(scm, 'recursiveSubmodules').text = 'false'
+    XML.SubElement(scm, 'doGenerateSubmoduleConfigurations').text = 'false'
+    XML.SubElement(scm, 'authorOrCommitter').text = 'false'
+    XML.SubElement(scm, 'clean').text = 'false'
+    XML.SubElement(scm, 'wipeOutWorkspace').text = 'true'
+    XML.SubElement(scm, 'pruneBranches').text = 'false'
+    XML.SubElement(scm, 'remotePoll').text = 'false'
+    XML.SubElement(scm, 'buildChooser',
+                   {'class':'hudson.plugins.git.util.DefaultBuildChooser'})
+    XML.SubElement(scm, 'gitTool').text = 'Default'
+    XML.SubElement(scm, 'submoduleCfg', {'class':'list'})
+    XML.SubElement(scm, 'relativeTargetDir')
+    XML.SubElement(scm, 'reference')
+    XML.SubElement(scm, 'excludedRegions')
+    XML.SubElement(scm, 'excludedUsers')
+    XML.SubElement(scm, 'gitConfigName')
+    XML.SubElement(scm, 'gitConfigEmail')
+    XML.SubElement(scm, 'skipTag').text = 'false'
+    XML.SubElement(scm, 'scmName')
+
+class SCM(jenkins_jobs.modules.base.Base):
+    sequence = 30
+
+    def gen_xml(self, parser, xml_parent, data):
+        scms = data.get('scm', [])
+        if scms:
+            for scm in data.get('scm', []):
+                self._dispatch('scm', 'scm',
+                               parser, xml_parent, scm)
+        else:
+            XML.SubElement(xml_parent, 'scm', {'class':'hudson.scm.NullSCM'})
+
diff --git a/jenkins_jobs/modules/triggers.py b/jenkins_jobs/modules/triggers.py
new file mode 100644
index 000000000..abaa2620a
--- /dev/null
+++ b/jenkins_jobs/modules/triggers.py
@@ -0,0 +1,124 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for gerrit triggers
+# To use add the following into your YAML:
+# trigger:
+#  triggerOnPatchsetUploadedEvent: 'false'
+#  triggerOnChangeMergedEvent: 'false'
+#  triggerOnCommentAddedEvent: 'true'
+#  triggerOnRefUpdatedEvent: 'false'
+#  triggerApprovalCategory: 'APRV'
+#  triggerApprovalValue: 1
+#  overrideVotes: 'true'
+#  gerritBuildSuccessfulVerifiedValue: 1
+#  gerritBuildFailedVerifiedValue: -1
+#  failureMessage: 'This change was unable to be automatically merged with the current state of the repository. Please rebase your change and upload a new patchset.'
+#   projects:
+#     - projectCompareType: 'PLAIN'
+#       projectPattern: 'openstack/nova'
+#       branchCompareType: 'ANT'
+#       branchPattern: '**'
+#     - projectCompareType: 'PLAIN'
+#       projectPattern: 'openstack/glance'
+#       branchCompareType: 'ANT'
+#       branchPattern: '**'
+#     ...
+#
+# triggerApprovalCategory and triggerApprovalValue only required if triggerOnCommentAddedEvent: 'true'
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+
+def gerrit(parser, xml_parent, data):
+    projects = data['projects']
+    gtrig = XML.SubElement(xml_parent,
+                           'com.sonyericsson.hudson.plugins.gerrit.trigger.'
+                           'hudsontrigger.GerritTrigger')
+    XML.SubElement(gtrig, 'spec')
+    gprojects = XML.SubElement(gtrig, 'gerritProjects')
+    for project in projects:
+        gproj = XML.SubElement(gprojects,
+                               'com.sonyericsson.hudson.plugins.gerrit.'
+                               'trigger.hudsontrigger.data.GerritProject')
+        XML.SubElement(gproj, 'compareType').text = \
+            project['projectCompareType']
+        XML.SubElement(gproj, 'pattern').text = project['projectPattern']
+        branches = XML.SubElement(gproj, 'branches')
+        gbranch = XML.SubElement(branches, 'com.sonyericsson.hudson.plugins.'
+                                 'gerrit.trigger.hudsontrigger.data.Branch')
+        XML.SubElement(gbranch, 'compareType').text = \
+            project['branchCompareType']
+        XML.SubElement(gbranch, 'pattern').text = project['branchPattern']
+    XML.SubElement(gtrig, 'silentMode').text = 'false'
+    XML.SubElement(gtrig, 'escapeQuotes').text = 'true'
+    XML.SubElement(gtrig, 'triggerOnPatchsetUploadedEvent').text = \
+        data['triggerOnPatchsetUploadedEvent']
+    XML.SubElement(gtrig, 'triggerOnChangeMergedEvent').text = \
+        data['triggerOnChangeMergedEvent']
+    XML.SubElement(gtrig, 'triggerOnCommentAddedEvent').text = \
+        data['triggerOnCommentAddedEvent']
+    XML.SubElement(gtrig, 'triggerOnRefUpdatedEvent').text = \
+        data['triggerOnRefUpdatedEvent']
+    if data.has_key('overrideVotes') and data['overrideVotes'] == 'true':
+        XML.SubElement(gtrig, 'gerritBuildSuccessfulVerifiedValue').text = \
+            str(data['gerritBuildSuccessfulVerifiedValue'])
+        XML.SubElement(gtrig, 'gerritBuildFailedVerifiedValue').text = \
+            str(data['gerritBuildFailedVerifiedValue'])
+    if data['triggerOnCommentAddedEvent'] == 'true':
+        XML.SubElement(gtrig, 'commentAddedTriggerApprovalCategory').text = \
+            data['triggerApprovalCategory']
+        XML.SubElement(gtrig, 'commentAddedTriggerApprovalValue').text = \
+            str(data['triggerApprovalValue'])
+    XML.SubElement(gtrig, 'buildStartMessage')
+    XML.SubElement(gtrig, 'buildFailureMessage').text = data['failureMessage']
+    XML.SubElement(gtrig, 'buildSuccessfulMessage')
+    XML.SubElement(gtrig, 'buildUnstableMessage')
+    XML.SubElement(gtrig, 'customUrl')
+
+# Jenkins Job module for scm polling triggers
+# To use add the following into your YAML:
+# trigger:
+#   pollscm: '@midnight'
+# or
+#   pollscm: '*/15 * * * *'
+
+def pollscm(parser, xml_parent, data):
+    scmtrig = XML.SubElement(xml_parent, 'hudson.triggers.SCMTrigger')
+    XML.SubElement(scmtrig, 'spec').text = data
+
+# Jenkins Job module for timed triggers
+# To use add the following into your YAML:
+# trigger:
+#   timed: '@midnight'
+# or
+#   timed: '*/15 * * * *'
+
+def timed(parser, xml_parent, data):
+    scmtrig = XML.SubElement(xml_parent, 'hudson.triggers.TimerTrigger')
+    XML.SubElement(scmtrig, 'spec').text = data
+
+class Triggers(jenkins_jobs.modules.base.Base):
+    sequence = 50
+
+    def gen_xml(self, parser, xml_parent, data):
+        triggers = data.get('triggers', [])
+        if not triggers:
+            return
+
+        trig_e = XML.SubElement(xml_parent, 'triggers', {'class':'vector'})
+        for trigger in triggers:
+            self._dispatch('trigger', 'triggers',
+                           parser, trig_e, trigger)
+
diff --git a/jenkins_jobs/modules/wrappers.py b/jenkins_jobs/modules/wrappers.py
new file mode 100644
index 000000000..e15513edd
--- /dev/null
+++ b/jenkins_jobs/modules/wrappers.py
@@ -0,0 +1,51 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for wrappers
+
+import xml.etree.ElementTree as XML
+import jenkins_jobs.modules.base
+
+
+def timeout(parser, xml_parent, data):
+    twrapper = XML.SubElement(xml_parent,
+        'hudson.plugins.build__timeout.BuildTimeoutWrapper')
+    tminutes = XML.SubElement(twrapper, 'timeoutMinutes')
+    tminutes.text = str(data['timeout'])
+    failbuild = XML.SubElement(twrapper, 'failBuild')
+    fail = data.get('fail', False)
+    if fail:
+        failbuild.text = 'true'
+    else:
+        failbuild.text = 'false'
+
+def timestamps(parser, xml_parent, data):
+    XML.SubElement(xml_parent,
+                   'hudson.plugins.timestamper.TimestamperBuildWrapper')
+
+def ansicolor(parser, xml_parent, data):
+    XML.SubElement(xml_parent,
+                   'hudson.plugins.ansicolor.AnsiColorBuildWrapper')
+
+
+class Wrappers(jenkins_jobs.modules.base.Base):
+    sequence = 80
+
+    def gen_xml(self, parser, xml_parent, data):
+        wrappers = XML.SubElement(xml_parent, 'buildWrappers')
+
+        for wrap in data.get('wrappers', []):
+            self._dispatch('wrapper', 'wrappers',
+                           parser, wrappers, wrap)
+
diff --git a/jenkins_jobs/modules/zuul.py b/jenkins_jobs/modules/zuul.py
new file mode 100644
index 000000000..338a50f07
--- /dev/null
+++ b/jenkins_jobs/modules/zuul.py
@@ -0,0 +1,85 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+# Jenkins Job module for Zuul
+
+import jenkins_jobs.modules.base
+
+
+ZUUL_PARAMETERS = [
+    {'string':
+         {'description': 'Zuul provided key to link builds with Gerrit events',
+          'name': 'UUID'}},
+    {'string':
+         {'description': 'Zuul provided project name',
+          'name': 'GERRIT_PROJECT'}},
+    {'string':
+         {'description': 'Zuul provided branch name',
+          'name': 'GERRIT_BRANCH'}},
+    {'string':
+         {'description': 'Zuul provided list of dependent changes to merge',
+          'name': 'GERRIT_CHANGES'}},
+    ]
+
+ZUUL_POST_PARAMETERS = [
+    {'string':
+         {'description': 'Zuul provided key to link builds with Gerrit events',
+          'name': 'UUID'}},
+    {'string':
+         {'description': 'Zuul provided project name',
+          'name': 'GERRIT_PROJECT'}},
+    {'string':
+         {'description': 'Zuul provided ref name',
+          'name': 'GERRIT_REFNAME'}},
+    {'string':
+         {'description': 'Zuul provided old reference for ref-updated',
+          'name': 'GERRIT_OLDREV'}},
+    {'string':
+         {'description': 'Zuul provided new reference for ref-updated',
+          'name': 'GERRIT_NEWREV'}},
+    ]
+
+ZUUL_NOTIFICATIONS = [
+    {'http':
+         {'url': 'http://127.0.0.1:8001/jenkins_endpoint'}}
+    ]
+
+class Zuul(jenkins_jobs.modules.base.Base):
+    sequence = 0
+
+    def handle_data(self, parser):
+        changed = False
+        jobs = (parser.data.get('job', {}).values() +
+                parser.data.get('job-template', {}).values())
+        for job in jobs:
+            triggers = job.get('triggers')
+            if not triggers:
+                continue
+
+            if ('zuul' not in job.get('triggers', []) and
+                'zuul_post' not in job.get('triggers', [])):
+                continue
+            if 'parameters' not in job:
+                job['parameters'] = []
+            if 'notifications' not in job:
+                job['notifications'] = []
+            job['notifications'].extend(ZUUL_NOTIFICATIONS)
+            if 'zuul' in job.get('triggers', []):
+                job['parameters'].extend(ZUUL_PARAMETERS)
+                job['triggers'].remove('zuul')
+            if 'zuul_post' in job.get('triggers', []):
+                job['parameters'].extend(ZUUL_POST_PARAMETERS)
+                job['triggers'].remove('zuul_post')
+            changed = True
+        return changed
diff --git a/modules/builders.py b/modules/builders.py
deleted file mode 100644
index 61110df6d..000000000
--- a/modules/builders.py
+++ /dev/null
@@ -1,161 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for builders
-# To use add the folowing into your YAML:
-# builders:
-#   - 'gerrit_git_prep'
-#   - 'python26'
-
-import xml.etree.ElementTree as XML
-
-
-def register(registry):
-    mod = Builders(registry)
-    registry.registerModule(mod)
-
-
-class Builders(object):
-    sequence = 60
-    
-    def __init__(self, registry):
-        self.registry = registry
-        for f in dir(self):
-            if not f.startswith('_builder_'):
-                continue
-            self.registry.registerHandler('builder', f[len('_builder_'):],
-                                          getattr(self, f))
-
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        for alias in ['prebuilders', 'builders', 'postbuilders']:
-            if alias in data:
-                builders = XML.SubElement(xml_parent, alias)
-                for builder in data[alias]:
-                    if isinstance(builder, dict):
-                        for key, value in builder.items():
-                            func = self.registry.getHandler('builder', key)
-                            func(builders, value)
-                    else:
-                        func = self.registry.getHandler('builder', builder)
-                        func(builders)
-
-    def _add_script(self, xml_parent, script):
-        shell = XML.SubElement(xml_parent, 'hudson.tasks.Shell')
-        XML.SubElement(shell, 'command').text = script
-
-    def _builder_coverage(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-cover.sh')
-
-    def _builder_docs(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-docs.sh')
-
-    def _builder_gerrit_git_prep(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/gerrit-git-prep.sh {site}'.format(site=self.data['main']['review_site']))
-
-    def _builder_maven_test(self, xml_parent):
-        self._add_script(xml_parent, 'mvn test')
-
-    def _builder_maven_package(self, xml_parent):
-        self._add_script(xml_parent, 'mvn package')
-
-    def _builder_gerrit_package(self, xml_parent):
-        self._add_script(xml_parent,
-            '/usr/local/jenkins/slave_scripts/package-gerrit.sh')
-
-    def _builder_gerrit_preclean(self, xml_parent):
-        self._add_script(xml_parent, "#!/bin/bash -xe\n\
-rm -fr ~/.m2\n\
-rm -fr ~/.java\n\
-./tools/version.sh --release")
-
-    def _builder_gerrit_postrun(self, xml_parent):
-        self._add_script(xml_parent, "./tools/version.sh --reset")
-
-    def _builder_pep8(self, xml_parent):
-        self._add_script(xml_parent, 'set -o pipefail ; tox -v -epep8 | tee pep8.txt ; set +o pipefail')
-
-    def _builder_pyflakes(self, xml_parent):
-        self._add_script(xml_parent, 'tox -v -epyflakes')
-
-    def _builder_puppet_syntax(self, xml_parent):
-        self._add_script(xml_parent, """
-find . -iname *.pp | xargs puppet parser validate --modulepath=`pwd`/modules
-for f in `find . -iname *.erb` ; do
-  erb -x -T '-' $f | ruby -c
-done
-""")
-
-    def _builder_selenium(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-selenium.sh')
-
-    def _builder_shell(self, xml_parent, data):
-        self._add_script(xml_parent, data)
-
-    def _builder_trigger_builds(self, xml_parent, data):
-        tbuilder = XML.SubElement(xml_parent, 'hudson.plugins.parameterizedtrigger.TriggerBuilder')
-        configs = XML.SubElement(tbuilder, 'configs')
-        for project_def in data:
-            tconfig = XML.SubElement(configs, 'hudson.plugins.parameterizedtrigger.BlockableBuildTriggerConfig')
-            tconfigs = XML.SubElement(tconfig, 'configs')
-            if project_def.has_key('predefined_parameters'):
-                params = XML.SubElement(tconfigs,
-                                        'hudson.plugins.parameterizedtrigger.PredefinedBuildParameters')
-                properties = XML.SubElement(params, 'properties')
-                properties.text = project_def['predefined_parameters']
-            else:
-                tconfigs.set('class', 'java.util.Collections$EmptyList')
-            projects = XML.SubElement(tconfig, 'projects')
-            projects.text = project_def['project']
-            condition = XML.SubElement(tconfig, 'condition')
-            condition.text = 'ALWAYS'
-            trigger_with_no_params = XML.SubElement(tconfig, 'triggerWithNoParameters')
-            trigger_with_no_params.text = 'false'
-            build_all_nodes_with_label = XML.SubElement(tconfig, 'buildAllNodesWithLabel')
-            build_all_nodes_with_label.text = 'false'
-            
-    def _builder_python26(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-tox.sh 26')
-
-    def _builder_python27(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-tox.sh 27')
-
-    def _builder_python26_essex(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-tox.sh 26-essex')
-
-    def _builder_python27_essex(self, xml_parent):
-        self._add_script(xml_parent, '/usr/local/jenkins/slave_scripts/run-tox.sh 27-essex')
-
-    def _builder_tarball(self, xml_parent):
-        self._add_script(xml_parent,
-          '/usr/local/jenkins/slave_scripts/create-tarball.sh %s' % self.data['main']['project'])
-
-    def _builder_ppa(self, xml_parent):
-        self._add_script(xml_parent, 'rm -rf build dist.zip\n\
-mkdir build')
-        copy = XML.SubElement(xml_parent, 'hudson.plugins.copyartifact.CopyArtifact')
-        XML.SubElement(copy, 'projectName').text = '%s-tarball' % self.data['main']['project']
-        XML.SubElement(copy, 'filter').text = 'dist/*.tar.gz'
-        XML.SubElement(copy, 'target').text = 'build'
-        selector = XML.SubElement(copy, 'selector', {'class':'hudson.plugins.copyartifact.StatusBuildSelector'})
-        XML.SubElement(selector, 'parameterName').text = 'BUILD_SELECTOR'
-        self._add_script(xml_parent, '#!/bin/bash\n\
-\n\
-#export DO_UPLOAD=&quot;no&quot;\n\
-export PROJECT=&quot;<%= project %>&quot;\n\
-export GERRIT_REFNAME=$BRANCH\n\
-/usr/local/jenkins/slave_scripts/create-ppa-package.sh')
diff --git a/modules/properties.py b/modules/properties.py
deleted file mode 100644
index 964e17fbb..000000000
--- a/modules/properties.py
+++ /dev/null
@@ -1,90 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for job properties
-# No additional YAML needed
-
-import xml.etree.ElementTree as XML
-
-
-def register(registry):
-    mod = Properties()
-    registry.registerModule(mod)
-
-
-class Properties(object):
-    sequence = 20
-
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        main = self.data['main']
-        properties = XML.SubElement(xml_parent, 'properties')
-        if main.get('project'):
-            github = XML.SubElement(properties, 'com.coravy.hudson.plugins.github.GithubProjectProperty')
-            github_url = XML.SubElement(github, 'projectUrl')
-            github_url.text = "https://github.com/{org}/{project}".format(
-                org=main['github_org'], project=main['project'])
-        throttle = XML.SubElement(properties, 'hudson.plugins.throttleconcurrents.ThrottleJobProperty')
-        XML.SubElement(throttle, 'maxConcurrentPerNode').text = '0'
-        XML.SubElement(throttle, 'maxConcurrentTotal').text = '0'
-        #XML.SubElement(throttle, 'categories')
-        XML.SubElement(throttle, 'throttleEnabled').text = 'false'
-        XML.SubElement(throttle, 'throttleOption').text = 'project'
-        XML.SubElement(throttle, 'configVersion').text = '1'
-        if main.has_key('authenticatedBuild') and main['authenticatedBuild'] == 'true':
-            security = XML.SubElement(properties, 'hudson.security.AuthorizationMatrixProperty')
-            XML.SubElement(security, 'permission').text = 'hudson.model.Item.Build:authenticated'
-        self.do_parameters(properties)
-        self.do_notifications(properties)
-
-    parameter_types = {
-        'string': 'hudson.model.StringParameterDefinition',
-        'bool': 'hudson.model.BooleanParameterDefinition',
-        'file': 'hudson.model.FileParameterDefinition',
-        'text': 'hudson.model.TextParameterDefinition',
-        'label': 'org.jvnet.jenkins.plugins.nodelabelparameter.LabelParameterDefinition',
-        # Others require more work
-        }
-
-    def do_parameters(self, xml_parent):
-        params = self.data.get('parameters', None)
-        if not params:
-            return
-        pdefp = XML.SubElement(xml_parent, 'hudson.model.ParametersDefinitionProperty')
-        pdefs = XML.SubElement(pdefp, 'parameterDefinitions')
-        for param in params:
-            ptype = self.parameter_types.get(param['type'])
-            pdef = XML.SubElement(pdefs, ptype)
-            XML.SubElement(pdef, 'name').text = param['name']
-            XML.SubElement(pdef, 'description').text = param['description']
-            if param['type'] != 'file':
-                default = param.get('default', None)
-                if default:
-                    XML.SubElement(pdef, 'defaultValue').text = default
-                else:
-                    XML.SubElement(pdef, 'defaultValue')
-
-    def do_notifications(self, xml_parent):
-        endpoints = self.data.get('notification_endpoints', None)
-        if not endpoints:
-            return
-        notify_element = XML.SubElement(xml_parent, 'com.tikal.hudson.plugins.notification.HudsonNotificationProperty')
-        endpoints_element = XML.SubElement(notify_element, 'endpoints')
-        for ep in endpoints:
-            endpoint_element = XML.SubElement(endpoints_element, 'com.tikal.hudson.plugins.notification.Endpoint')
-            XML.SubElement(endpoint_element, 'protocol').text = ep['protocol']
-            XML.SubElement(endpoint_element, 'url').text = ep['URL']
diff --git a/modules/publishers.py b/modules/publishers.py
deleted file mode 100644
index a2022d75b..000000000
--- a/modules/publishers.py
+++ /dev/null
@@ -1,329 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for coverage publishers
-# No additional YAML needed
-
-import xml.etree.ElementTree as XML
-
-
-def register(registry):
-    mod = Publishers(registry)
-    registry.registerModule(mod)
-
-
-class Publishers(object):
-    sequence = 70
-
-    def __init__(self, registry):
-        self.registry = registry
-        for f in dir(self):
-            if not f.startswith('_publisher_'):
-                continue
-            self.registry.registerHandler('publisher', f[len('_publisher_'):],
-                                          getattr(self, f))
-
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        publishers = XML.SubElement(xml_parent, 'publishers')
-        actions = self.data.get('post_build_actions', [])
-        for action in actions:
-            if isinstance(action, dict):
-                for key, value in action.items():
-                    func = self.registry.getHandler('publisher', key)
-                    func(publishers, value)
-            else:
-                func = self.registry.getHandler('publisher', action)
-                func(publishers)
-
-    def _publisher_archive(self, xml_parent, data):
-        archiver = XML.SubElement(xml_parent, 'hudson.tasks.ArtifactArchiver')
-        artifacts = XML.SubElement(archiver, 'artifacts')
-        artifacts.text = data['artifacts']
-        if 'excludes' in data:
-            excludes = XML.SubElement(archiver, 'excludes')
-            excludes.text = data['excludes']
-        latest = XML.SubElement(archiver, 'latestOnly')
-        latest_only = data.get('latest_only', False)
-        if latest_only:
-            latest.text = 'true'
-        else:
-            latest.text = 'false'
-
-    def _publisher_trigger_parameterized_builds(self, xml_parent, data):
-        tbuilder = XML.SubElement(xml_parent, 'hudson.plugins.parameterizedtrigger.BuildTrigger')
-        configs = XML.SubElement(tbuilder, 'configs')
-        for project_def in data:
-            tconfig = XML.SubElement(configs, 'hudson.plugins.parameterizedtrigger.BuildTriggerConfig')
-            tconfigs = XML.SubElement(tconfig, 'configs')
-            if project_def.has_key('predefined_parameters'):
-                params = XML.SubElement(tconfigs,
-                                        'hudson.plugins.parameterizedtrigger.PredefinedBuildParameters')
-                properties = XML.SubElement(params, 'properties')
-                properties.text = project_def['predefined_parameters']
-            else:
-                tconfigs.set('class', 'java.util.Collections$EmptyList')
-            projects = XML.SubElement(tconfig, 'projects')
-            projects.text = project_def['project']
-            condition = XML.SubElement(tconfig, 'condition')
-            condition.text = project_def.get('condition', 'ALWAYS')
-            trigger_with_no_params = XML.SubElement(tconfig, 'triggerWithNoParameters')
-            trigger_with_no_params.text = 'false'
-
-    def _publisher_coverage(self, xml_parent):
-        cobertura = XML.SubElement(xml_parent, 'hudson.plugins.cobertura.CoberturaPublisher')
-        XML.SubElement(cobertura, 'coberturaReportFile').text = '**/coverage.xml'
-        XML.SubElement(cobertura, 'onlyStable').text = 'false'
-        healthy = XML.SubElement(cobertura, 'healthyTarget')
-        targets = XML.SubElement(healthy, 'targets', {'class':'enum-map','enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'CONDITIONAL'
-        XML.SubElement(entry, 'int').text = '70'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'LINE'
-        XML.SubElement(entry, 'int').text = '80'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'METHOD'
-        XML.SubElement(entry, 'int').text = '80'
-        unhealthy = XML.SubElement(cobertura, 'unhealthyTarget')
-        targets = XML.SubElement(unhealthy, 'targets', {'class':'enum-map','enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'CONDITIONAL'
-        XML.SubElement(entry, 'int').text = '0'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'LINE'
-        XML.SubElement(entry, 'int').text = '0'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'METHOD'
-        XML.SubElement(entry, 'int').text = '0'
-        failing = XML.SubElement(cobertura, 'failingTarget')
-        targets = XML.SubElement(failing, 'targets', {'class':'enum-map','enum-type':'hudson.plugins.cobertura.targets.CoverageMetric'})
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'CONDITIONAL'
-        XML.SubElement(entry, 'int').text = '0'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'LINE'
-        XML.SubElement(entry, 'int').text = '0'
-        entry = XML.SubElement(targets, 'entry')
-        XML.SubElement(entry, 'hudson.plugins.cobertura.targets.CoverageMetric').text = 'METHOD'
-        XML.SubElement(entry, 'int').text = '0'
-        XML.SubElement(cobertura, 'sourceEncoding').text = 'ASCII'
-
-    # Jenkins Job module for publishing via ftp
-    # publish:
-    #   site: 'docs.openstack.org'
-    #   remote_dir: 'dest/dir'
-    #   source_files: 'base/source/dir/**'
-    #   remove_prefix: 'base/source/dir'
-    #   excludes: '**/*.exludedfiletype'
-    #
-    # This will upload everything under $workspace/base/source/dir to
-    # docs.openstack.org $ftpdir/dest/dir exluding the excluded file type.
-
-    def _publisher_ftp(self, xml_parent, data):
-        """
-        Example XML:
-        <publishers>
-          <jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin>
-            <consolePrefix>FTP: </consolePrefix>
-            <delegate>
-              <publishers>
-                <jenkins.plugins.publish__over__ftp.BapFtpPublisher>
-                  <configName>docs.openstack.org</configName>
-                  <verbose>true</verbose>
-                  <transfers>
-                    <jenkins.plugins.publish__over__ftp.BapFtpTransfer>
-                      <remoteDirectory></remoteDirectory>
-                      <sourceFiles>openstack-identity-api/target/docbkx/webhelp/api/openstack-identity-service/2.0/**</sourceFiles>
-                      <excludes>**/*.xml,**/null*</excludes>
-                      <removePrefix>openstack-identity-api/target/docbkx/webhelp</removePrefix>
-                      <remoteDirectorySDF>false</remoteDirectorySDF>
-                      <flatten>false</flatten>
-                      <cleanRemote>false</cleanRemote>
-                      <asciiMode>false</asciiMode>
-                    </jenkins.plugins.publish__over__ftp.BapFtpTransfer>
-                  </transfers>
-                  <useWorkspaceInPromotion>false</useWorkspaceInPromotion>
-                  <usePromotionTimestamp>false</usePromotionTimestamp>
-                </jenkins.plugins.publish__over__ftp.BapFtpPublisher>
-              </publishers>
-              <continueOnError>false</continueOnError>
-              <failOnError>false</failOnError>
-              <alwaysPublishFromMaster>false</alwaysPublishFromMaster>
-              <hostConfigurationAccess class="jenkins.plugins.publish_over_ftp.BapFtpPublisherPlugin" reference="../.."/>
-            </delegate>
-          </jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin>
-        </publishers>
-        """
-        outer_ftp = XML.SubElement(xml_parent,
-                                   'jenkins.plugins.publish__over__ftp.BapFtpPublisherPlugin')
-        XML.SubElement(outer_ftp, 'consolePrefix').text = 'FTP: '
-        delegate = XML.SubElement(outer_ftp, 'delegate')
-        publishers = XML.SubElement(delegate, 'publishers')
-        ftp = XML.SubElement(publishers, 'jenkins.plugins.publish__over__ftp.BapFtpPublisher')
-        XML.SubElement(ftp, 'configName').text = data['site']
-        XML.SubElement(ftp, 'verbose').text = 'true'
-
-        transfers = XML.SubElement(ftp, 'transfers')
-        ftp_transfers = XML.SubElement(transfers, 'jenkins.plugins.publish__over__ftp.BapFtpTransfer')
-        XML.SubElement(ftp_transfers, 'remoteDirectory').text = data['remote_dir']
-        XML.SubElement(ftp_transfers, 'sourceFiles').text = data['source_files']
-        XML.SubElement(ftp_transfers, 'excludes').text = data['excludes']
-        XML.SubElement(ftp_transfers, 'removePrefix').text = data['remove_prefix']
-        XML.SubElement(ftp_transfers, 'remoteDirectorySDF').text = 'false'
-        XML.SubElement(ftp_transfers, 'flatten').text = 'false'
-        XML.SubElement(ftp_transfers, 'cleanRemote').text = 'false'
-        XML.SubElement(ftp_transfers, 'asciiMode').text = 'false'
-
-        XML.SubElement(ftp, 'useWorkspaceInPromotion').text = 'false'
-        XML.SubElement(ftp, 'usePromotionTimestamp').text = 'false'
-        XML.SubElement(delegate, 'continueOnError').text = 'false'
-        XML.SubElement(delegate, 'failOnError').text = 'false'
-        XML.SubElement(delegate, 'alwaysPublishFromMaster').text = 'false'
-        XML.SubElement(delegate, 'hostConfigurationAccess',
-                {'class': 'jenkins.plugins.publish_over_ftp.BapFtpPublisherPlugin',
-                 'reference': '../..'})
-
-    # Jenkins Job module for coverage publishers
-    # To use you add the following into your YAML:
-    # publisher:
-    #   results: 'nosetests.xml'
-
-    def _publisher_junit(self, xml_parent, data):
-        junitresult = XML.SubElement(xml_parent,
-            'hudson.tasks.junit.JUnitResultArchiver')
-        XML.SubElement(junitresult, 'testResults').text = data['results']
-        XML.SubElement(junitresult, 'keepLongStdio').text = "true"
-        XML.SubElement(junitresult, 'testDataPublishers')
-
-    # Jenkins Job module for pep8 publishers
-    # No additional YAML needed
-
-    def _pep8_add_entry(self, xml_parent, name):
-        entry = XML.SubElement(xml_parent, 'entry')
-        XML.SubElement(entry, 'string').text = name
-        tconfig = XML.SubElement(entry, 'hudson.plugins.violations.TypeConfig')
-        XML.SubElement(tconfig, 'type').text = name
-        XML.SubElement(tconfig, 'min').text = '10'
-        XML.SubElement(tconfig, 'max').text = '999'
-        XML.SubElement(tconfig, 'unstable').text = '999'
-        XML.SubElement(tconfig, 'usePattern').text = 'false'
-        XML.SubElement(tconfig, 'pattern')
-
-    def _publisher_pep8(self, xml_parent):
-        violations = XML.SubElement(xml_parent, 'hudson.plugins.violations.ViolationsPublisher')
-        config = XML.SubElement(violations, 'config')
-        suppressions = XML.SubElement(config, 'suppressions', {'class':'tree-set'})
-        XML.SubElement(suppressions, 'no-comparator')
-        configs = XML.SubElement(config, 'typeConfigs')
-        XML.SubElement(configs, 'no-comparator')
-
-        self._pep8_add_entry(configs, 'checkstyle')
-        self._pep8_add_entry(configs, 'codenarc')
-        self._pep8_add_entry(configs, 'cpd')
-        self._pep8_add_entry(configs, 'cpplint')
-        self._pep8_add_entry(configs, 'csslint')
-        self._pep8_add_entry(configs, 'findbugs')
-        self._pep8_add_entry(configs, 'fxcop')
-        self._pep8_add_entry(configs, 'gendarme')
-        self._pep8_add_entry(configs, 'jcreport')
-        self._pep8_add_entry(configs, 'jslint')
-
-        entry = XML.SubElement(configs, 'entry')
-        XML.SubElement(entry, 'string').text = 'pep8'
-        tconfig = XML.SubElement(entry, 'hudson.plugins.violations.TypeConfig')
-        XML.SubElement(tconfig, 'type').text = 'pep8'
-        XML.SubElement(tconfig, 'min').text = '0'
-        XML.SubElement(tconfig, 'max').text = '1'
-        XML.SubElement(tconfig, 'unstable').text = '1'
-        XML.SubElement(tconfig, 'usePattern').text = 'false'
-        XML.SubElement(tconfig, 'pattern').text = '**/pep8.txt'
-
-        self._pep8_add_entry(configs, 'pmd')
-        self._pep8_add_entry(configs, 'pylint')
-        self._pep8_add_entry(configs, 'simian')
-        self._pep8_add_entry(configs, 'stylecop')
-
-        XML.SubElement(config, 'limit').text = '100'
-        XML.SubElement(config, 'sourcePathPattern')
-        XML.SubElement(config, 'fauxProjectPath')
-        XML.SubElement(config, 'encoding').text = 'default'
-
-    # Jenkins Job module for PPA publishers
-    # No additional YAML needed
-
-    def _publisher_ppa(self, xml_parent):
-        archiver = XML.SubElement(xml_parent, 'hudson.tasks.ArtifactArchiver')
-        XML.SubElement(archiver, 'artifacts').text = 'build/*.dsc,build/*.tar.gz,build/*.changes'
-        XML.SubElement(archiver, 'latestOnly').text = 'false'
-
-    # Jenkins Job module for tarball publishers
-    # To use you add the following into your YAML:
-    # publish:
-    #   site: 'glance.openstack.org'
-    #    dir: 'glance'
-
-    def _publisher_tarball(self, xml_parent, data):
-        site = data['site']
-        archiver = XML.SubElement(xml_parent, 'hudson.tasks.ArtifactArchiver')
-        XML.SubElement(archiver, 'artifacts').text = 'dist/*.tar.gz'
-        XML.SubElement(archiver, 'latestOnly').text = 'false'
-        scp = XML.SubElement(xml_parent, 'be.certipost.hudson.plugin.SCPRepositoryPublisher')
-        XML.SubElement(scp, 'siteName').text = site
-        entries = XML.SubElement(scp, 'entries')
-        entry = XML.SubElement(entries, 'be.certipost.hudson.plugin.Entry')
-        XML.SubElement(entry, 'filePath').text = 'tarballs/{proj}/'.format(proj=data['project'])
-        XML.SubElement(entry, 'sourceFile').text = 'dist/*.tar.gz'
-        XML.SubElement(entry, 'keepHierarchy').text = 'false'
-
-    # Jenkins Job module for war publishers
-    # To use you add the following into your YAML:
-    # publish:
-    #   site: 'nova.openstack.org'
-    #   warfile: 'gerrit-war/target/gerrit*.war'
-    #   target_path: 'tarballs/ci/'
-
-    def _publisher_war(self, xml_parent, data):
-        site = data['site']
-        archiver = XML.SubElement(xml_parent, 'hudson.tasks.ArtifactArchiver')
-        XML.SubElement(archiver, 'artifacts').text = data['warfile']
-        XML.SubElement(archiver, 'latestOnly').text = 'false'
-        scp = XML.SubElement(xml_parent, 'be.certipost.hudson.plugin.SCPRepositoryPublisher')
-        XML.SubElement(scp, 'siteName').text = site
-        entries = XML.SubElement(scp, 'entries')
-        entry = XML.SubElement(entries, 'be.certipost.hudson.plugin.Entry')
-        XML.SubElement(entry, 'filePath').text = data['target_path']
-        XML.SubElement(entry, 'sourceFile').text = data['warfile']
-        XML.SubElement(entry, 'keepHierarchy').text = 'false'
-
-    # Jenkins Job module for generic scp publishing
-    # To use you add the following into your YAML:
-    # publish:
-    #   site: 'openstack-ci.openstack.org'
-    #   source: 'doc/build/html/**/*'
-    #   target_path: 'ci/zuul'
-    #   keep_heirarchy: 'true'
-
-    def _publisher_scp(self, xml_parent, data):
-        site = data['site']
-        scp = XML.SubElement(xml_parent, 'be.certipost.hudson.plugin.SCPRepositoryPublisher')
-        XML.SubElement(scp, 'siteName').text = site
-        entries = XML.SubElement(scp, 'entries')
-        entry = XML.SubElement(entries, 'be.certipost.hudson.plugin.Entry')
-        XML.SubElement(entry, 'filePath').text = data['target_path']
-        XML.SubElement(entry, 'sourceFile').text = data['source']
-        XML.SubElement(entry, 'keepHierarchy').text = data['keep_heirarchy']
diff --git a/modules/scm.py b/modules/scm.py
deleted file mode 100644
index 1b8ffbabc..000000000
--- a/modules/scm.py
+++ /dev/null
@@ -1,74 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for scm
-# To use add the folowing into your YAML:
-# scm:
-#  scm: 'true'
-# or
-#  scm: 'false'
-
-import xml.etree.ElementTree as XML
-
-
-def register(registry):
-    mod = SCM()
-    registry.registerModule(mod)
-
-
-class SCM(object):
-    sequence = 30
-
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        main = self.data['main']
-        scm_enabled = self.data['scm']['scm']
-        if scm_enabled == 'true':
-          scm = XML.SubElement(xml_parent, 'scm', {'class':'hudson.plugins.git.GitSCM'})
-          XML.SubElement(scm, 'configVersion').text = '2'
-          user = XML.SubElement(scm, 'userRemoteConfigs')
-          huser = XML.SubElement(user, 'hudson.plugins.git.UserRemoteConfig')
-          XML.SubElement(huser, 'name').text = 'origin'
-          XML.SubElement(huser, 'refspec').text = '+refs/heads/*:refs/remotes/origin/*'
-          XML.SubElement(huser, 'url').text = 'git://github.com/{org}/{project}.git'.format(org=main['github_org'], project=main['project'])
-          xml_branches = XML.SubElement(scm, 'branches')
-          branches = self.data['scm'].get('branches', ['**'])
-          for branch in branches:
-              bspec = XML.SubElement(xml_branches, 'hudson.plugins.git.BranchSpec')
-              XML.SubElement(bspec, 'name').text = branch
-          XML.SubElement(scm, 'disableSubmodules').text = 'false'
-          XML.SubElement(scm, 'recursiveSubmodules').text = 'false'
-          XML.SubElement(scm, 'doGenerateSubmoduleConfigurations').text = 'false'
-          XML.SubElement(scm, 'authorOrCommitter').text = 'false'
-          XML.SubElement(scm, 'clean').text = 'false'
-          XML.SubElement(scm, 'wipeOutWorkspace').text = 'true'
-          XML.SubElement(scm, 'pruneBranches').text = 'false'
-          XML.SubElement(scm, 'remotePoll').text = 'false'
-          XML.SubElement(scm, 'buildChooser', {'class':'hudson.plugins.git.util.DefaultBuildChooser'})
-          XML.SubElement(scm, 'gitTool').text = 'Default'
-          XML.SubElement(scm, 'submoduleCfg', {'class':'list'})
-          XML.SubElement(scm, 'relativeTargetDir')
-          XML.SubElement(scm, 'reference')
-          XML.SubElement(scm, 'excludedRegions')
-          XML.SubElement(scm, 'excludedUsers')
-          XML.SubElement(scm, 'gitConfigName')
-          XML.SubElement(scm, 'gitConfigEmail')
-          XML.SubElement(scm, 'skipTag').text = 'false'
-          XML.SubElement(scm, 'scmName')
-        else:
-          XML.SubElement(xml_parent, 'scm', {'class':'hudson.scm.NullSCM'})
-
diff --git a/modules/triggers.py b/modules/triggers.py
deleted file mode 100644
index 0e77dbc08..000000000
--- a/modules/triggers.py
+++ /dev/null
@@ -1,129 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for gerrit triggers
-# To use add the following into your YAML:
-# trigger:
-#  triggerOnPatchsetUploadedEvent: 'false'
-#  triggerOnChangeMergedEvent: 'false'
-#  triggerOnCommentAddedEvent: 'true'
-#  triggerOnRefUpdatedEvent: 'false'
-#  triggerApprovalCategory: 'APRV'
-#  triggerApprovalValue: 1
-#  overrideVotes: 'true'
-#  gerritBuildSuccessfulVerifiedValue: 1
-#  gerritBuildFailedVerifiedValue: -1
-#  failureMessage: 'This change was unable to be automatically merged with the current state of the repository. Please rebase your change and upload a new patchset.'
-#   projects:
-#     - projectCompareType: 'PLAIN'
-#       projectPattern: 'openstack/nova'
-#       branchCompareType: 'ANT'
-#       branchPattern: '**'
-#     - projectCompareType: 'PLAIN'
-#       projectPattern: 'openstack/glance'
-#       branchCompareType: 'ANT'
-#       branchPattern: '**'
-#     ...
-#
-# triggerApprovalCategory and triggerApprovalValue only required if triggerOnCommentAddedEvent: 'true'
-
-import xml.etree.ElementTree as XML
-
-
-def register(registry):
-    mod = Triggers(registry)
-    registry.registerModule(mod)
-
-
-class Triggers(object):
-    sequence = 50
-
-    def __init__(self, registry):
-        self.registry = registry
-        for f in dir(self):
-            if not f.startswith('_trigger_'):
-                continue
-            self.registry.registerHandler('trigger', f[len('_trigger_'):],
-                                          getattr(self, f))
-
-    def handle_data(self, data):
-        self.data = data
-
-    def gen_xml(self, xml_parent, data):
-        actions = self.data.get('triggers', [])
-        if not actions:
-            return
-        triggers = XML.SubElement(xml_parent, 'triggers', {'class':'vector'})
-        for action in actions:
-            if isinstance(action, dict):
-                for key, value in action.items():
-                    func = self.registry.getHandler('trigger', key)
-                    func(triggers, value)
-            else:
-                func = self.registry.getHandler('trigger', action)
-                func(triggers)
-
-    def _trigger_gerrit(self, xml_parent, data):
-        projects = data['projects']
-        gtrig = XML.SubElement(xml_parent, 'com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTrigger')
-        XML.SubElement(gtrig, 'spec')
-        gprojects = XML.SubElement(gtrig, 'gerritProjects')
-        for project in projects:
-            gproj = XML.SubElement(gprojects, 'com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.GerritProject')
-            XML.SubElement(gproj, 'compareType').text = project['projectCompareType']
-            XML.SubElement(gproj, 'pattern').text = project['projectPattern']
-            branches = XML.SubElement(gproj, 'branches')
-            gbranch = XML.SubElement(branches, 'com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch')
-            XML.SubElement(gbranch, 'compareType').text = project['branchCompareType']
-            XML.SubElement(gbranch, 'pattern').text = project['branchPattern']
-        XML.SubElement(gtrig, 'silentMode').text = 'false'
-        XML.SubElement(gtrig, 'escapeQuotes').text = 'true'
-        XML.SubElement(gtrig, 'triggerOnPatchsetUploadedEvent').text = data['triggerOnPatchsetUploadedEvent']
-        XML.SubElement(gtrig, 'triggerOnChangeMergedEvent').text = data['triggerOnChangeMergedEvent']
-        XML.SubElement(gtrig, 'triggerOnCommentAddedEvent').text = data['triggerOnCommentAddedEvent']
-        XML.SubElement(gtrig, 'triggerOnRefUpdatedEvent').text = data['triggerOnRefUpdatedEvent']
-        if data.has_key('overrideVotes') and data['overrideVotes'] == 'true':
-            XML.SubElement(gtrig, 'gerritBuildSuccessfulVerifiedValue').text = str(data['gerritBuildSuccessfulVerifiedValue'])
-            XML.SubElement(gtrig, 'gerritBuildFailedVerifiedValue').text = str(data['gerritBuildFailedVerifiedValue'])
-        if data['triggerOnCommentAddedEvent'] == 'true':
-            XML.SubElement(gtrig, 'commentAddedTriggerApprovalCategory').text = data['triggerApprovalCategory']
-            XML.SubElement(gtrig, 'commentAddedTriggerApprovalValue').text = str(data['triggerApprovalValue'])
-        XML.SubElement(gtrig, 'buildStartMessage')
-        XML.SubElement(gtrig, 'buildFailureMessage').text = data['failureMessage']
-        XML.SubElement(gtrig, 'buildSuccessfulMessage')
-        XML.SubElement(gtrig, 'buildUnstableMessage')
-        XML.SubElement(gtrig, 'customUrl')
-
-    # Jenkins Job module for scm polling triggers
-    # To use add the following into your YAML:
-    # trigger:
-    #   pollscm: '@midnight'
-    # or
-    #   pollscm: '*/15 * * * *'
-
-    def _trigger_pollscm(self, xml_parent, data):
-        scmtrig = XML.SubElement(xml_parent, 'hudson.triggers.SCMTrigger')
-        XML.SubElement(scmtrig, 'spec').text = data
-
-    # Jenkins Job module for timed triggers
-    # To use add the following into your YAML:
-    # trigger:
-    #   timed: '@midnight'
-    # or
-    #   timed: '*/15 * * * *'
-
-    def _trigger_timed(self, xml_parent, data):
-        scmtrig = XML.SubElement(xml_parent, 'hudson.triggers.TimerTrigger')
-        XML.SubElement(scmtrig, 'spec').text = data
diff --git a/modules/wrappers.py b/modules/wrappers.py
deleted file mode 100644
index bab009107..000000000
--- a/modules/wrappers.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for wrappers
-
-import xml.etree.ElementTree as XML
-
-def register(registry):
-    mod = Wrappers()
-    registry.registerModule(mod)
-
-
-class Wrappers(object):
-    sequence = 80
-
-    def gen_xml(self, xml_parent, data):
-        wrappers = XML.SubElement(xml_parent, 'buildWrappers')
-
-        if 'timeout' in data['main']:
-            self._timeout(wrappers, data)
-        if 'ansicolor' in data['main']:
-            self._ansicolor(wrappers, data)
-        if 'timestamps' in data['main']:
-            self._timestamps(wrappers, data)
-
-    def _timeout(self, xml_parent, data):
-        twrapper = XML.SubElement(xml_parent, 'hudson.plugins.build__timeout.BuildTimeoutWrapper')
-        tminutes = XML.SubElement(twrapper, 'timeoutMinutes')
-        tminutes.text = str(data['main']['timeout'])
-        failbuild = XML.SubElement(twrapper, 'failBuild')
-        fail = data['main'].get('timeout_fail', False)
-        if fail:
-            failbuild.text = 'true'
-        else:
-            failbuild.text = 'false'
-
-    def _timestamps(self, xml_parent, data):
-        XML.SubElement(xml_parent, 'hudson.plugins.timestamper.TimestamperBuildWrapper')
-
-    def _ansicolor(self, xml_parent, data):
-        XML.SubElement(xml_parent, 'hudson.plugins.ansicolor.AnsiColorBuildWrapper')
diff --git a/modules/zuul.py b/modules/zuul.py
deleted file mode 100644
index 54b59c373..000000000
--- a/modules/zuul.py
+++ /dev/null
@@ -1,79 +0,0 @@
-#! /usr/bin/env python
-# Copyright (C) 2012 OpenStack, LLC.
-#
-# 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.
-
-# Jenkins Job module for Zuul
-
-ZUUL_PARAMETERS = [
-    {'description': 'Zuul provided key to link builds with Gerrit events',
-     'name': 'UUID',
-     'type': 'string'},
-    {'description': 'Zuul provided project name',
-     'name': 'GERRIT_PROJECT',
-     'type': 'string'},
-    {'description': 'Zuul provided branch name',
-     'name': 'GERRIT_BRANCH',
-     'type': 'string'},
-    {'description': 'Zuul provided list of dependent changes to merge',
-     'name': 'GERRIT_CHANGES',
-     'type': 'string'}
-    ]
-
-ZUUL_POST_PARAMETERS = [
-    {'description': 'Zuul provided key to link builds with Gerrit events',
-     'name': 'UUID',
-     'type': 'string'},
-    {'description': 'Zuul provided project name',
-     'name': 'GERRIT_PROJECT',
-     'type': 'string'},
-    {'description': 'Zuul provided ref name',
-     'name': 'GERRIT_REFNAME',
-     'type': 'string'},
-    {'description': 'Zuul provided old reference for ref-updated',
-     'name': 'GERRIT_OLDREV',
-     'type': 'string'},
-    {'description': 'Zuul provided new reference for ref-updated',
-     'name': 'GERRIT_NEWREV',
-     'type': 'string'}
-    ]
-
-ZUUL_NOTIFICATIONS = [
-    {'URL': 'http://127.0.0.1:8001/jenkins_endpoint',
-     'protocol': 'HTTP'}
-    ]
-
-
-def register(registry):
-    mod = Zuul()
-    registry.registerModule(mod)
-
-
-class Zuul(object):
-    sequence = 0
-
-    def handle_data(self, data):
-        if ('zuul' not in data.get('triggers', []) and
-            'zuul_post' not in data.get('triggers', [])):
-            return
-        if 'parameters' not in data:
-            data['parameters'] = []
-        if 'notification_endpoints' not in data:
-            data['notification_endpoints'] = []
-        data['notification_endpoints'].extend(ZUUL_NOTIFICATIONS)
-        if 'zuul' in data.get('triggers', []):
-            data['parameters'].extend(ZUUL_PARAMETERS)
-            data['triggers'].remove('zuul')
-        if 'zuul_post' in data.get('triggers', []):
-            data['parameters'].extend(ZUUL_POST_PARAMETERS)
-            data['triggers'].remove('zuul_post')
diff --git a/projects/openstack/zuul.yml b/projects/openstack/zuul.yml
index f7fc79f1d..2e04748bc 100644
--- a/projects/openstack/zuul.yml
+++ b/projects/openstack/zuul.yml
@@ -1,71 +1,62 @@
-project:
-  template: 'python_jobs'
+#project:
+#  template: 'python_jobs'
+#
+#values:
+#  name: 'zuul'
+#  disabled: 'false'
+#  github_org: 'openstack-ci'
+#  review_site: 'review.openstack.org'
+#  node: 'precise'
+#
+#---
 
-values:
-  name: 'zuul'
-  disabled: 'false'
-  github_org: 'openstack-ci'
-  review_site: 'review.openstack.org'
-  node: 'precise'
+- job:
+    name: gate-zuul-pyflakes
+    project-type: freestyle
+    concurrent: true
+    timeout: 20
+    timeout_fail: true
 
----
-# pyflakes-gate
-main:
-  name: 'gate-zuul-pyflakes'
-  review_site: 'review.openstack.org'
-  github_org: 'openstack-ci'
-  project: 'zuul'
-  concurrent: 'true'
-  timeout: 20
-  timeout_fail: true
+    triggers:
+      - zuul
 
-triggers:
-  - zuul
+    builders:
+      - gerrit_git_prep
+      - pyflakes
 
-builders:
-  - gerrit_git_prep
-  - pyflakes
+    assignednode:
+      node: 'precise'
 
-scm:
-  scm: 'false'
+- job:
+    name: zuul-docs
+    project-type: freestyle
+    #review_site: 'review.openstack.org'
+    #github_org: 'openstack-ci'
+    #project: 'zuul'
+    #authenticatedBuild: 'false'
+    concurrent: true
+    timeout: 20
+    timeout_fail: true
 
-assignednode:
-  node: 'precise'
+    triggers:
+      - zuul_post
 
----
-# zuul-docs
-main:
-  name: 'zuul-docs'
-  review_site: 'review.openstack.org'
-  github_org: 'openstack-ci'
-  project: 'zuul'
-  authenticatedBuild: 'false'
-  concurrent: 'true'
-  timeout: 20
-  timeout_fail: true
+    logrotate:
+      daysToKeep: 28
+      numToKeep: -1
+      artifactDaysToKeep: -1
+      artifactNumToKeep: -1
 
-triggers:
-  - zuul_post
+    builders:
+      - gerrit_git_prep
+      - docs
 
-logrotate:
-  daysToKeep: 28
-  numToKeep: -1
-  artifactDaysToKeep: -1
-  artifactNumToKeep: -1
+    post_build_actions:
+      - scp:
+          site: '173.203.107.207'
+          source: 'doc/build/html/**/*'
+          target_path: 'ci/zuul'
+          keep_heirarchy: 'true'
 
-builders:
-  - gerrit_git_prep
-  - docs
-
-post_build_actions:
-  - scp:
-      site: '173.203.107.207'
-      source: 'doc/build/html/**/*'
-      target_path: 'ci/zuul'
-      keep_heirarchy: 'true'
-
-scm:
-  scm: 'false'
-
-assignednode:
-  node: 'precise'
+    assignednode:
+      node: 'precise'
diff --git a/setup.py b/setup.py
new file mode 100644
index 000000000..94876b7c3
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,90 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+#
+# 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 setuptools import find_packages
+from setuptools import setup
+
+setup(name='jenkins_job_builder',
+      version='0.1',
+      description="Manage Jenkins jobs with YAML",
+      license='Apache License (2.0)',
+      author='Hewlett-Packard Development Company, L.P.',
+      author_email='openstack@lists.launchpad.net',
+      scripts=['jenkins-jobs'],
+      include_package_data=True,
+      zip_safe=False,
+      packages=find_packages(),
+
+      entry_points = {
+        'jenkins_jobs.projects': [
+            'freestyle=jenkins_jobs.modules.project_freestyle:Freestyle',
+            'maven=jenkins_jobs.modules.project_maven:Maven',
+            ],
+        'jenkins_jobs.builders': [
+            'shell=jenkins_jobs.modules.builders:shell',
+            'trigger-builds=jenkins_jobs.modules.builders:trigger_builds',
+            ],
+        'jenkins_jobs.properties': [
+            'github=jenkins_jobs.modules.properties:github',
+            'throttle=jenkins_jobs.modules.properties:throttle',
+            'authenticated-build=jenkins_jobs.modules.properties:'
+              'authenticated_build',
+            ],
+        'jenkins_jobs.parameters': [
+            'string=jenkins_jobs.modules.properties:string_param',
+            'bool=jenkins_jobs.modules.properties:bool_param',
+            'file=jenkins_jobs.modules.properties:file_param',
+            'text=jenkins_jobs.modules.properties:text_param',
+            'label=jenkins_jobs.modules.properties:label_param',
+            ],
+        'jenkins_jobs.notifications': [
+            'http=jenkins_jobs.modules.properties:http_endpoint',
+            ],
+        'jenkins_jobs.publishers': [
+            'archive=jenkins_jobs.modules.publishers:archive',
+            'trigger-parameterized-builds='
+                'jenkins_jobs.modules.publishers:trigger_parameterized_builds',
+            'coverage=jenkins_jobs.modules.publishers:coverage',
+            'ftp=jenkins_jobs.modules.publishers:ftp',
+            'junit=jenkins_jobs.modules.publishers:junit',
+            'pep8=jenkins_jobs.modules.publishers:pep8',
+            'scp=jenkins_jobs.modules.publishers:scp',
+            ],
+        'jenkins_jobs.scm': [
+            'git=jenkins_jobs.modules.scm:git',
+            ],
+        'jenkins_jobs.triggers': [
+            'gerrit=jenkins_jobs.modules.triggers:gerrit',
+            'pollscm=jenkins_jobs.modules.triggers:pollscm',
+            'timed=jenkins_jobs.modules.triggers:timed',
+            ],
+        'jenkins_jobs.wrappers': [
+            'timeout=jenkins_jobs.modules.wrappers:timeout',
+            'timestamps=jenkins_jobs.modules.wrappers:timestamps',
+            'ansicolor=jenkins_jobs.modules.wrappers:ansicolor',
+            ],
+        'jenkins_jobs.modules': [
+            'assignednode=jenkins_jobs.modules.assignednode:AssignedNode',
+            'builders=jenkins_jobs.modules.builders:Builders',
+            'logrotate=jenkins_jobs.modules.logrotate:LogRotate',
+            'properties=jenkins_jobs.modules.properties:Properties',
+            'publishers=jenkins_jobs.modules.publishers:Publishers',
+            'scm=jenkins_jobs.modules.scm:SCM',
+            'triggers=jenkins_jobs.modules.triggers:Triggers',
+            'wrappers=jenkins_jobs.modules.wrappers:Wrappers',
+            'zuul=jenkins_jobs.modules.zuul:Zuul',
+            ]
+        }
+
+      )
diff --git a/test.sh b/test.sh
index ce2bcb5d0..b5bec2a67 100755
--- a/test.sh
+++ b/test.sh
@@ -9,18 +9,19 @@ mkdir -p /tmp/jenkins_jobs_test/test
 
 if [ "$1" == "save" ]
 then
-    for x in `find projects/ -name *.yml`
-    do
-	echo $x
-	BASENAME=`basename $x`
-	python jenkins_jobs.py test $x > /tmp/jenkins_jobs_test/saved/$BASENAME.xml
-    done
+    rm -f /tmp/jenkins_jobs_test/saved/*
+    jenkins-jobs test -o /tmp/jenkins_jobs_test/saved/ example 
 else
-    for x in `find projects/ -name *.yml`
+    rm -f /tmp/jenkins_jobs_test/test/*
+    jenkins-jobs test -o /tmp/jenkins_jobs_test/test/ example 
+    for x in `(cd /tmp/jenkins_jobs_test/saved && find -type f)`
     do
-	echo $x
-	BASENAME=`basename $x`
-	python jenkins_jobs.py test $x > /tmp/jenkins_jobs_test/test/$BASENAME.xml
+	if ! diff -u /tmp/jenkins_jobs_test/saved/$x /tmp/jenkins_jobs_test/test/$x >/dev/null 2>&1
+	then
+	    echo "============================================================"
+	    echo $x
+	    echo "------------------------------------------------------------"
+	fi
+	diff -u /tmp/jenkins_jobs_test/saved/$x /tmp/jenkins_jobs_test/test/$x
     done
-    diff -r /tmp/jenkins_jobs_test/saved /tmp/jenkins_jobs_test/test
 fi