Repository: jenkins-x/lighthouse
Pull request #850 (2020-07-16 20:57:09)

feat: Decouple jx interaction into a separate controller
This will allow us to eventually split out the jx controller into a separate repository and chart, and more importantly, it'll let us add additional controllers for other agents - most notably Tekton Pipeline and Jenkins.

This won't require any changes in jx, since the new controller is deployed and used by default.

Signed-off-by: Andrew Bayer <andrew.bayer@gmail.com>

jenkins-x-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **abayer**
You can assign the PR to them by writing `/assign @abayer` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=jenkins-x%2Flighthouse).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/jenkins-x/lighthouse/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["abayer"]} -->

Patch:
diff --git a/Dockerfile.jxcontroller b/Dockerfile.jxcontroller
new file mode 100644
index 00000000..e99a71cd
--- /dev/null
+++ b/Dockerfile.jxcontroller
@@ -0,0 +1,6 @@
+FROM alpine:3.10
+RUN apk add --update --no-cache ca-certificates git
+COPY ./bin/lighthouse-jx-controller /lighthouse-jx-controller
+RUN mkdir /jxhome
+ENV JX_HOME /jxhome
+ENTRYPOINT ["/lighthouse-jx-controller"]
diff --git a/Makefile b/Makefile
index 1c2a123e..70078e29 100644
--- a/Makefile
+++ b/Makefile
@@ -7,12 +7,14 @@ WEBHOOKS_EXECUTABLE := lighthouse
 KEEPER_EXECUTABLE := keeper
 FOGHORN_EXECUTABLE := foghorn
 GCJOBS_EXECUTABLE := gc-jobs
+JXCONTROLLER_EXECUTABLE := lighthouse-jx-controller
 DOCKER_REGISTRY := jenkinsxio
 DOCKER_IMAGE_NAME := lighthouse
 WEBHOOKS_MAIN_SRC_FILE=cmd/webhooks/main.go
 KEEPER_MAIN_SRC_FILE=cmd/keeper/main.go
 FOGHORN_MAIN_SRC_FILE=cmd/foghorn/main.go
 GCJOBS_MAIN_SRC_FILE=cmd/gc/main.go
+JXCONTROLLER_MAIN_SRC_FILE=cmd/jxcontroller/main.go
 GO := GO111MODULE=on go
 GO_NOMOD := GO111MODULE=off go
 VERSION ?= $(shell echo "$$(git describe --abbrev=0 --tags 2>/dev/null)-dev+$(REV)" | sed 's/^v//')
@@ -71,7 +73,7 @@ clean:
 	rm -rf bin build release
 
 .PHONY: build
-build: webhooks keeper foghorn gc-jobs
+build: webhooks keeper foghorn jx-controller gc-jobs
 
 .PHONY: webhooks
 webhooks:
@@ -89,13 +91,17 @@ foghorn:
 gc-jobs:
 	$(GO) build -i -ldflags "$(GO_LDFLAGS)" -o bin/$(GCJOBS_EXECUTABLE) $(GCJOBS_MAIN_SRC_FILE)
 
+.PHONY: jx-controller
+jx-controller:
+	$(GO) build -i -ldflags "$(GO_LDFLAGS)" -o bin/$(JXCONTROLLER_EXECUTABLE) $(JXCONTROLLER_MAIN_SRC_FILE)
+
 .PHONY: mod
 mod: build
 	echo "tidying the go module"
 	$(GO) mod tidy
 
 .PHONY: build-linux
-build-linux: build-webhooks-linux build-foghorn-linux build-gc-jobs-linux build-keeper-linux
+build-linux: build-webhooks-linux build-foghorn-linux build-gc-jobs-linux build-keeper-linux build-jx-controller-linux
 
 .PHONY: build-webhooks-linux
 build-webhooks-linux:
@@ -113,6 +119,10 @@ build-foghorn-linux:
 build-gc-jobs-linux:
 	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(GO_LDFLAGS)" -o bin/$(GCJOBS_EXECUTABLE) $(GCJOBS_MAIN_SRC_FILE)
 
+.PHONY: build-jx-controller-linux
+build-jx-controller-linux:
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(GO_LDFLAGS)" -o bin/$(JXCONTROLLER_EXECUTABLE) $(JXCONTROLLER_MAIN_SRC_FILE)
+
 .PHONY: container
 container: 
 	docker-compose build $(DOCKER_IMAGE_NAME)
diff --git a/bdd/bbs/ci.sh b/bdd/bbs/ci.sh
index 424467d4..3af7cd60 100755
--- a/bdd/bbs/ci.sh
+++ b/bdd/bbs/ci.sh
@@ -92,23 +92,12 @@ jx step bdd \
     --no-delete-app \
     --no-delete-repo \
     --tests install \
-    --tests test-create-spring \
     --tests test-lighthouse
 
 bdd_result=$?
 if [[ $bdd_result != 0 ]]; then
   pushd ..
-  mkdir -p extra-logs
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=controllerbuild -o jsonpath='{.items[*].metadata.name}')" > extra-logs/controllerbuild.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-keeper -o jsonpath='{.items[*].metadata.name}')" > extra-logs/keeper.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-foghorn -o jsonpath='{.items[*].metadata.name}')" > extra-logs/foghorn.log
-  lh_cnt=0
-  for lh_pod in $(kubectl get pod -l app=lighthouse-webhooks -o jsonpath='{.items[*].metadata.name}'); do
-    ((lh_cnt=lh_cnt+1))
-    kubectl logs --tail=-1 "${lh_pod}" > extra-logs/lh.${lh_cnt}.log
-  done
-
-  jx step stash -c lighthouse-tests -p "extra-logs/*.log" --bucket-url gs://jx-prod-logs
+  bash bdd/capture-failed-pod-logs.sh
   popd
 fi
 cd ../charts/lighthouse
diff --git a/bdd/capture-failed-pod-logs.sh b/bdd/capture-failed-pod-logs.sh
new file mode 100644
index 00000000..abd7f8d5
--- /dev/null
+++ b/bdd/capture-failed-pod-logs.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -e
+set -x
+
+mkdir -p extra-logs
+kubectl logs --tail=-1 "$(kubectl get pod -l app=controllerbuild -o jsonpath='{.items[*].metadata.name}')" > extra-logs/controllerbuild.log
+kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-keeper -o jsonpath='{.items[*].metadata.name}')" > extra-logs/keeper.log
+kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-foghorn -o jsonpath='{.items[*].metadata.name}')" > extra-logs/foghorn.log
+kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-jx-controller -o jsonpath='{.items[*].metadata.name}')" > extra-logs/jx-controller.log
+lh_cnt=0
+for lh_pod in $(kubectl get pod -l app=lighthouse-webhooks -o jsonpath='{.items[*].metadata.name}'); do
+  ((lh_cnt=lh_cnt+1))
+  kubectl logs --tail=-1 "${lh_pod}" > extra-logs/lh.${lh_cnt}.log
+done
+
+jx step stash -c lighthouse-tests -p "extra-logs/*.log" --bucket-url gs://jx-prod-logs
diff --git a/bdd/ghe/ci.sh b/bdd/ghe/ci.sh
index 56e8ed6d..75f9a568 100755
--- a/bdd/ghe/ci.sh
+++ b/bdd/ghe/ci.sh
@@ -90,23 +90,12 @@ jx step bdd \
     --no-delete-app \
     --no-delete-repo \
     --tests install \
-    --tests test-create-spring \
     --tests test-lighthouse
 
 bdd_result=$?
 if [[ $bdd_result != 0 ]]; then
   pushd ..
-  mkdir -p extra-logs
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=controllerbuild -o jsonpath='{.items[*].metadata.name}')" > extra-logs/controllerbuild.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-keeper -o jsonpath='{.items[*].metadata.name}')" > extra-logs/keeper.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-foghorn -o jsonpath='{.items[*].metadata.name}')" > extra-logs/foghorn.log
-  lh_cnt=0
-  for lh_pod in $(kubectl get pod -l app=lighthouse-webhooks -o jsonpath='{.items[*].metadata.name}'); do
-    ((lh_cnt=lh_cnt+1))
-    kubectl logs --tail=-1 "${lh_pod}" > extra-logs/lh.${lh_cnt}.log
-  done
-
-  jx step stash -c lighthouse-tests -p "extra-logs/*.log" --bucket-url gs://jx-prod-logs
+  bash bdd/capture-failed-pod-logs.sh
   popd
 fi
 cd ../charts/lighthouse
diff --git a/bdd/github/ci.sh b/bdd/github/ci.sh
index 00079679..24827c4c 100755
--- a/bdd/github/ci.sh
+++ b/bdd/github/ci.sh
@@ -89,23 +89,12 @@ jx step bdd \
     --default-admin-password $JENKINS_PASSWORD \
     --no-delete-app \
     --no-delete-repo \
-    --tests test-create-spring \
     --tests test-lighthouse
 
 bdd_result=$?
 if [[ $bdd_result != 0 ]]; then
   pushd ..
-  mkdir -p extra-logs
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=controllerbuild -o jsonpath='{.items[*].metadata.name}')" > extra-logs/controllerbuild.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-keeper -o jsonpath='{.items[*].metadata.name}')" > extra-logs/keeper.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-foghorn -o jsonpath='{.items[*].metadata.name}')" > extra-logs/foghorn.log
-  lh_cnt=0
-  for lh_pod in $(kubectl get pod -l app=lighthouse-webhooks -o jsonpath='{.items[*].metadata.name}'); do
-    ((lh_cnt=lh_cnt+1))
-    kubectl logs --tail=-1 "${lh_pod}" > extra-logs/lh.${lh_cnt}.log
-  done
-
-  jx step stash -c lighthouse-tests -p "extra-logs/*.log" --bucket-url gs://jx-prod-logs
+  bash bdd/capture-failed-pod-logs.sh
   popd
 fi
 cd ../charts/lighthouse
diff --git a/bdd/gitlab/ci.sh b/bdd/gitlab/ci.sh
index b479b84b..cee58d96 100755
--- a/bdd/gitlab/ci.sh
+++ b/bdd/gitlab/ci.sh
@@ -91,25 +91,12 @@ jx step bdd \
     --no-delete-app \
     --no-delete-repo \
     --tests install \
-    --tests test-create-spring \
     --tests test-lighthouse
 
-# Gitlab labels on pull requests aren't properly implemented yet on our side, so no quickstart tests - they depend on them.
-
 bdd_result=$?
 if [[ $bdd_result != 0 ]]; then
   pushd ..
-  mkdir -p extra-logs
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=controllerbuild -o jsonpath='{.items[*].metadata.name}')" > extra-logs/controllerbuild.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-keeper -o jsonpath='{.items[*].metadata.name}')" > extra-logs/keeper.log
-  kubectl logs --tail=-1 "$(kubectl get pod -l app=lighthouse-foghorn -o jsonpath='{.items[*].metadata.name}')" > extra-logs/foghorn.log
-  lh_cnt=0
-  for lh_pod in $(kubectl get pod -l app=lighthouse-webhooks -o jsonpath='{.items[*].metadata.name}'); do
-    ((lh_cnt=lh_cnt+1))
-    kubectl logs --tail=-1 "${lh_pod}" > extra-logs/lh.${lh_cnt}.log
-  done
-
-  jx step stash -c lighthouse-tests -p "extra-logs/*.log" --bucket-url gs://jx-prod-logs
+  bash bdd/capture-failed-pod-logs.sh
   popd
 fi
 cd ../charts/lighthouse
diff --git a/charts/lighthouse/templates/_helpers.tpl b/charts/lighthouse/templates/_helpers.tpl
index 4eb96a98..59a14c02 100644
--- a/charts/lighthouse/templates/_helpers.tpl
+++ b/charts/lighthouse/templates/_helpers.tpl
@@ -30,3 +30,8 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this
 {{- $name := default "gc-jobs" .Values.gcJobs.nameOverride -}}
 {{- printf "%s-%s" .Chart.Name $name | trunc 63 | trimSuffix "-" -}}
 {{- end -}}
+
+{{- define "jxcontroller.name" -}}
+{{- $name := default "jx-controller" .Values.jxcontroller.nameOverride -}}
+{{- printf "%s-%s" .Chart.Name $name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
diff --git a/charts/lighthouse/templates/jx-controller-deployment.yaml b/charts/lighthouse/templates/jx-controller-deployment.yaml
new file mode 100644
index 00000000..66318096
--- /dev/null
+++ b/charts/lighthouse/templates/jx-controller-deployment.yaml
@@ -0,0 +1,85 @@
+{{- if .Values.engines.jx }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ template "jxcontroller.name" . }}
+  labels:
+    draft: {{ default "draft-app" .Values.draft }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}"
+    app: {{ template "jxcontroller.name" . }}
+spec:
+  replicas: {{ .Values.jxcontroller.replicaCount }}
+  selector:
+    matchLabels:
+      draft: {{ default "draft-app" .Values.draft }}
+      app: {{ template "jxcontroller.name" . }}
+  template:
+    metadata:
+      labels:
+        draft: {{ default "draft-app" .Values.draft }}
+        app: {{ template "jxcontroller.name" . }}
+{{- if .Values.podAnnotations }}
+      annotations:
+{{ toYaml .Values.podAnnotations | indent 8 }}
+{{- end }}
+    spec:
+      serviceAccountName: {{ template "jxcontroller.name" . }}
+      containers:
+      - name: {{ template "jxcontroller.name" . }}
+        image: {{ tpl .Values.jxcontroller.image.repository . }}:{{ tpl .Values.jxcontroller.image.tag . }}
+        imagePullPolicy: {{ tpl .Values.jxcontroller.image.pullPolicy . }}
+        args:
+          - "--namespace={{ .Release.Namespace }}"
+        env:
+          - name: "GIT_KIND"
+            value: "{{ .Values.git.kind }}"
+          - name: "GIT_SERVER"
+            value: "{{ .Values.git.server }}"
+{{- if .Values.githubApp.enabled }}
+          - name: "GITHUB_APP_SECRET_DIR"
+            value: "/secrets/githubapp/tokens"
+{{- else }}
+          - name: "GIT_USER"
+            value: {{ .Values.user }}
+          - name: "GIT_TOKEN"
+            valueFrom:
+              secretKeyRef:
+                name: lighthouse-oauth-token
+                key: oauth
+{{- end }}
+          - name: "JX_LOG_FORMAT"
+            value: "{{ .Values.logFormat }}"
+          - name: "LOGRUS_FORMAT"
+            value: "{{ .Values.logFormat }}"
+{{- if hasKey .Values "env" }}
+{{- range $pkey, $pval := .Values.env }}
+          - name: {{ $pkey }}
+            value: {{ quote $pval }}
+{{- end }}
+{{- end }}
+        resources:
+{{ toYaml .Values.jxcontroller.resources | indent 12 }}
+{{- if .Values.githubApp.enabled }}
+        volumeMounts:
+          - name: githubapp-tokens
+            mountPath: /secrets/githubapp/tokens
+            readOnly: true
+      volumes:
+        - name: githubapp-tokens
+          secret:
+            secretName: tide-githubapp-tokens
+{{- end }}
+      terminationGracePeriodSeconds: {{ .Values.jxcontroller.terminationGracePeriodSeconds }}
+{{- with .Values.jxcontroller.nodeSelector }}
+      nodeSelector:
+{{ toYaml . | indent 8 }}
+{{- end }}
+{{- with .Values.jxcontroller.affinity }}
+      affinity:
+{{ toYaml . | indent 8 }}
+{{- end }}
+{{- with .Values.jxcontroller.tolerations }}
+      tolerations:
+{{ toYaml . | indent 8 }}
+{{- end }}
+{{- end }}
diff --git a/charts/lighthouse/templates/jx-controller-rb.yaml b/charts/lighthouse/templates/jx-controller-rb.yaml
new file mode 100644
index 00000000..587e3e99
--- /dev/null
+++ b/charts/lighthouse/templates/jx-controller-rb.yaml
@@ -0,0 +1,13 @@
+{{- if .Values.engines.jx }}
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: {{ template "jxcontroller.name" . }}
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: {{ template "jxcontroller.name" . }}
+subjects:
+- kind: ServiceAccount
+  name: {{ template "jxcontroller.name" . }}
+{{- end }}
diff --git a/charts/lighthouse/templates/jx-controller-role.yaml b/charts/lighthouse/templates/jx-controller-role.yaml
new file mode 100644
index 00000000..d9409156
--- /dev/null
+++ b/charts/lighthouse/templates/jx-controller-role.yaml
@@ -0,0 +1,71 @@
+{{- if .Values.engines.jx }}
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: {{ template "jxcontroller.name" . }}
+rules:
+- apiGroups:
+  - jenkins.io
+  resources:
+  - pipelineactivities
+  - environments
+  - sourcerepositories
+  - pipelinestructures
+  - apps
+  verbs:
+  - create
+  - update
+  - patch
+  - list
+  - get
+  - watch
+- apiGroups:
+  - ""
+  resources:
+  - namespaces
+  - configmaps
+  - secrets
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - tekton.dev
+  resources:
+  - pipelineresources
+  - tasks
+  - pipelines
+  - pipelineruns
+  verbs:
+  - create
+  - list
+  - get
+  - update
+  - watch
+  - patch
+  - delete
+- apiGroups:
+  - lighthouse.jenkins.io
+  resources:
+  - lighthousejobs
+  verbs:
+  - create
+  - delete
+  - list
+  - update
+  - get
+  - watch
+  - patch
+- apiGroups:
+  - lighthouse.jenkins.io
+  resources:
+  - lighthousejobs/status
+  verbs:
+  - create
+  - delete
+  - list
+  - update
+  - get
+  - watch
+  - patch
+{{- end }}
diff --git a/charts/lighthouse/templates/jx-controller-sa.yaml b/charts/lighthouse/templates/jx-controller-sa.yaml
new file mode 100644
index 00000000..c20ce7aa
--- /dev/null
+++ b/charts/lighthouse/templates/jx-controller-sa.yaml
@@ -0,0 +1,6 @@
+{{- if .Values.engines.jx }}
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: {{ template "jxcontroller.name" . }}
+{{- end }}
diff --git a/charts/lighthouse/templates/keeper-deployment.yaml b/charts/lighthouse/templates/keeper-deployment.yaml
index a022d7ae..35cd4fad 100644
--- a/charts/lighthouse/templates/keeper-deployment.yaml
+++ b/charts/lighthouse/templates/keeper-deployment.yaml
@@ -33,10 +33,8 @@ spec:
       - name: {{ template "keeper.name" . }}
         image: {{ tpl .Values.keeper.image.repository . }}:{{ tpl .Values.keeper.image.tag . }}
         imagePullPolicy: {{ .Values.keeper.imagePullPolicy }}
-{{ if .Values.keeper.args }}
         args:
-{{ toYaml .Values.keeper.args | indent 10 }}
-{{- end }}
+          - "--namespace={{ .Release.Namespace }}"
         ports:
           - name: http
             containerPort: {{ .Values.keeper.service.internalPort }}
diff --git a/charts/lighthouse/templates/webhooks-deployment.yaml b/charts/lighthouse/templates/webhooks-deployment.yaml
index f93053c4..4009cc1c 100644
--- a/charts/lighthouse/templates/webhooks-deployment.yaml
+++ b/charts/lighthouse/templates/webhooks-deployment.yaml
@@ -29,6 +29,8 @@ spec:
       - name: {{ template "webhooks.name" . }}
         image: {{ tpl .Values.webhooks.image.repository . }}:{{ tpl .Values.webhooks.image.tag . }}
         imagePullPolicy: {{ tpl .Values.webhooks.image.pullPolicy . }}
+        args:
+          - "--namespace={{ .Release.Namespace }}"
         env:
           - name: "GIT_KIND"
             value: "{{ .Values.git.kind }}"
diff --git a/charts/lighthouse/values.yaml b/charts/lighthouse/values.yaml
index eca57014..3e809156 100644
--- a/charts/lighthouse/values.yaml
+++ b/charts/lighthouse/values.yaml
@@ -80,6 +80,21 @@ foghorn:
   terminationGracePeriodSeconds: 180
   reportURLBase: ""
 
+jxcontroller:
+  replicaCount: 1
+  image:
+    repository: "{{ .Values.image.parentRepository }}/lighthouse-jx-controller"
+    tag: "{{ .Values.image.tag }}"
+    pullPolicy: "{{ .Values.image.pullPolicy }}"
+  resources:
+    limits:
+      cpu: 100m
+      memory: 256Mi
+    requests:
+      cpu: 80m
+      memory: 128Mi
+  terminationGracePeriodSeconds: 180
+
 keeper:
   statusContextLabel: "Lighthouse Merge Status"
   replicaCount: 1
@@ -99,10 +114,6 @@ keeper:
     tag: "{{ .Values.image.tag }}"
   imagePullPolicy: IfNotPresent
   terminationGracePeriodSeconds: 30
-  args:
-    - --dry-run=false
-    #- --github-endpoint=http://ghproxy
-    # - --github-endpoint=https://api.github.com
   resources:
     limits:
      cpu: 400m
@@ -129,3 +140,6 @@ logFormat: "json"
 cluster:
   crds:
     create: true
+
+engines:
+  jx: true
diff --git a/cmd/foghorn/main.go b/cmd/foghorn/main.go
index f240dfc4..45ad9713 100644
--- a/cmd/foghorn/main.go
+++ b/cmd/foghorn/main.go
@@ -7,11 +7,9 @@ import (
 	"syscall"
 	"time"
 
-	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
-	jxinformers "github.com/jenkins-x/jx-api/pkg/client/informers/externalversions"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
 	lhinformers "github.com/jenkins-x/lighthouse/pkg/client/informers/externalversions"
+	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/foghorn"
 	"github.com/jenkins-x/lighthouse/pkg/interrupts"
 	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
@@ -70,15 +68,11 @@ func main() {
 		logrus.WithError(err).Fatal("Invalid options")
 	}
 
-	cfg, err := jxfactory.NewFactory().CreateKubeConfig()
+	cfg, err := clients.GetConfig("", "")
 	if err != nil {
 		logrus.WithError(err).Fatal("Could not create kubeconfig")
 	}
 
-	jxClient, err := jxclient.NewForConfig(cfg)
-	if err != nil {
-		logrus.WithError(err).Fatal("Could not create Jenkins X API client")
-	}
 	lhClient, err := clientset.NewForConfig(cfg)
 	if err != nil {
 		logrus.WithError(err).Fatal("Could not create Lighthouse API client")
@@ -87,18 +81,17 @@ func main() {
 	if err != nil {
 		logrus.WithError(err).Fatal("Could not create Kubernetes API client")
 	}
-	jxInformerFactory := jxinformers.NewSharedInformerFactoryWithOptions(jxClient, time.Minute*30, jxinformers.WithNamespace(o.namespace))
 	lhInformerFactory := lhinformers.NewSharedInformerFactoryWithOptions(lhClient, time.Minute*30, lhinformers.WithNamespace(o.namespace))
 
 	controller, err := foghorn.NewController(kubeClient,
-		jxClient,
 		lhClient,
-		jxInformerFactory.Jenkins().V1().PipelineActivities(),
 		lhInformerFactory.Lighthouse().V1alpha1().LighthouseJobs(),
 		o.namespace,
 		nil)
 
-	jxInformerFactory.Start(stopCh)
+	if err != nil {
+		logrus.WithError(err).Fatal("Error creating controller")
+	}
 	lhInformerFactory.Start(stopCh)
 
 	if err = controller.Run(2, stopCh); err != nil {
diff --git a/cmd/gc/main.go b/cmd/gc/main.go
index bf7316ab..f6dadcef 100644
--- a/cmd/gc/main.go
+++ b/cmd/gc/main.go
@@ -6,10 +6,10 @@ import (
 	"os"
 	"time"
 
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
 	lhclient "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned/typed/lighthouse/v1alpha1"
+	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
 	"github.com/sirupsen/logrus"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -48,7 +48,7 @@ func main() {
 		logrus.WithError(err).Fatal("Invalid options")
 	}
 
-	cfg, err := jxfactory.NewFactory().CreateKubeConfig()
+	cfg, err := clients.GetConfig("", "")
 	if err != nil {
 		logrus.WithError(err).Fatal("Could not create kubeconfig")
 	}
diff --git a/cmd/jxcontroller/main.go b/cmd/jxcontroller/main.go
new file mode 100644
index 00000000..1ac2392b
--- /dev/null
+++ b/cmd/jxcontroller/main.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+	"flag"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
+	jxinformers "github.com/jenkins-x/jx-api/pkg/client/informers/externalversions"
+	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
+	lhinformers "github.com/jenkins-x/lighthouse/pkg/client/informers/externalversions"
+	"github.com/jenkins-x/lighthouse/pkg/clients"
+	"github.com/jenkins-x/lighthouse/pkg/interrupts"
+	"github.com/jenkins-x/lighthouse/pkg/jx"
+	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
+	"github.com/sirupsen/logrus"
+	"k8s.io/client-go/kubernetes"
+)
+
+type options struct {
+	namespace string
+
+	dryRun bool
+}
+
+func (o *options) Validate() error {
+	return nil
+}
+
+func gatherOptions(fs *flag.FlagSet, args ...string) options {
+	var o options
+	fs.BoolVar(&o.dryRun, "dry-run", true, "Whether to mutate any real-world state.")
+	fs.StringVar(&o.namespace, "namespace", "", "The namespace to listen in")
+
+	err := fs.Parse(args)
+	if err != nil {
+		logrus.WithError(err).Fatal("Invalid options")
+	}
+
+	return o
+}
+
+// stopper returns a channel that remains open until an interrupt is received.
+func stopper() chan struct{} {
+	stop := make(chan struct{})
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		logrus.Warn("Interrupt received, attempting clean shutdown...")
+		close(stop)
+		<-c
+		logrus.Error("Second interrupt received, force exiting...")
+		os.Exit(1)
+	}()
+	return stop
+}
+
+func main() {
+	logrusutil.ComponentInit("lighthouse-jx-controller")
+
+	defer interrupts.WaitForGracefulShutdown()
+
+	stopCh := stopper()
+
+	o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...)
+	if err := o.Validate(); err != nil {
+		logrus.WithError(err).Fatal("Invalid options")
+	}
+
+	cfg, err := clients.GetConfig("", "")
+	if err != nil {
+		logrus.WithError(err).Fatal("Could not create kubeconfig")
+	}
+
+	lhClient, err := clientset.NewForConfig(cfg)
+	if err != nil {
+		logrus.WithError(err).Fatal("Could not create Lighthouse API client")
+	}
+	kubeClient, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		logrus.WithError(err).Fatal("Could not create Kubernetes API client")
+	}
+	lhInformerFactory := lhinformers.NewSharedInformerFactoryWithOptions(lhClient, time.Minute*30, lhinformers.WithNamespace(o.namespace))
+
+	jxClient, err := jxclient.NewForConfig(cfg)
+	if err != nil {
+		logrus.WithError(err).Fatal("Could not create Jenkins X API client")
+	}
+	jxInformerFactory := jxinformers.NewSharedInformerFactoryWithOptions(jxClient, time.Minute*30, jxinformers.WithNamespace(o.namespace))
+	paInformer := jxInformerFactory.Jenkins().V1().PipelineActivities()
+
+	controller, err := jx.NewController(kubeClient,
+		jxClient,
+		lhClient,
+		paInformer,
+		lhInformerFactory.Lighthouse().V1alpha1().LighthouseJobs(),
+		o.namespace,
+		nil)
+
+	if err != nil {
+		logrus.WithError(err).Fatal("Error creating controller")
+	}
+	jxInformerFactory.Start(stopCh)
+	lhInformerFactory.Start(stopCh)
+
+	if err = controller.Run(2, stopCh); err != nil {
+		logrus.WithError(err).Fatal("Error running controller")
+	}
+}
diff --git a/cmd/keeper/main.go b/cmd/keeper/main.go
index b95f83f3..e759ad16 100644
--- a/cmd/keeper/main.go
+++ b/cmd/keeper/main.go
@@ -42,11 +42,8 @@ type options struct {
 	botName       string
 	gitServerURL  string
 	gitKind       string
+	namespace     string
 
-	syncThrottle   int
-	statusThrottle int
-
-	dryRun  bool
 	runOnce bool
 
 	maxRecordsPerPool int
@@ -77,14 +74,12 @@ func gatherOptions(fs *flag.FlagSet, args ...string) options {
 	fs.StringVar(&o.botName, "bot-name", "", "The bot name")
 	fs.StringVar(&o.gitServerURL, "git-url", "", "The git provider URL")
 	fs.StringVar(&o.gitKind, "git-kind", "", "The git provider kind (e.g. github, gitlab, bitbucketserver")
-	fs.BoolVar(&o.dryRun, "dry-run", true, "Whether to mutate any real-world state.")
 	fs.BoolVar(&o.runOnce, "run-once", false, "If true, run only once then quit.")
-	fs.IntVar(&o.syncThrottle, "sync-hourly-tokens", 800, "The maximum number of tokens per hour to be used by the sync controller.")
-	fs.IntVar(&o.statusThrottle, "status-hourly-tokens", 400, "The maximum number of tokens per hour to be used by the status controller.")
 
 	fs.IntVar(&o.maxRecordsPerPool, "max-records-per-pool", 1000, "The maximum number of history records stored for an individual Keeper pool.")
 	fs.StringVar(&o.historyURI, "history-uri", "", "The /local/path or gs://path/to/object to store keeper action history. GCS writes will use the default object ACL for the bucket")
 	fs.StringVar(&o.statusURI, "status-path", "", "The /local/path or gs://path/to/object to store status controller state. GCS writes will use the default object ACL for the bucket.")
+	fs.StringVar(&o.namespace, "namespace", "", "The namespace to listen in")
 
 	err := fs.Parse(args)
 	if err != nil {
@@ -142,7 +137,7 @@ func main() {
 	gitToken := os.Getenv("GIT_TOKEN")
 
 	cfg := configAgent.Config
-	c, err := githubapp.NewKeeperController(configAgent, botName, gitKind, gitToken, serverURL, o.maxRecordsPerPool, o.historyURI, o.statusURI)
+	c, err := githubapp.NewKeeperController(configAgent, botName, gitKind, gitToken, serverURL, o.maxRecordsPerPool, o.historyURI, o.statusURI, o.namespace)
 	if err != nil {
 		logrus.WithError(err).Fatal("Error creating Keeper controller.")
 	}
diff --git a/go.mod b/go.mod
index cac86dd3..809b5706 100644
--- a/go.mod
+++ b/go.mod
@@ -6,10 +6,11 @@ require (
 	github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
 	github.com/google/go-cmp v0.4.0
 	github.com/gorilla/sessions v1.1.3
+	github.com/hashicorp/go-multierror v1.0.0
 	github.com/jenkins-x/go-scm v1.5.147
 	github.com/jenkins-x/jx-api v0.0.13
 	github.com/jenkins-x/jx/v2 v2.1.101
-	github.com/jenkins-x/lighthouse-config v0.0.7
+	github.com/jenkins-x/lighthouse-config v0.0.8
 	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.1.0
 	github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 // indirect
diff --git a/go.sum b/go.sum
index 4896f10e..90cb84e0 100644
--- a/go.sum
+++ b/go.sum
@@ -759,6 +759,8 @@ github.com/jenkins-x/lighthouse-config v0.0.6 h1:s73GxzEaqsqn/9w+VnkPKLx4lSs450P
 github.com/jenkins-x/lighthouse-config v0.0.6/go.mod h1:5ax5UF79SGM+Xc3HrWR/9LsPQBWDxE+TEJMwPAikst8=
 github.com/jenkins-x/lighthouse-config v0.0.7 h1:v2yBD4Jup9XUmFi2dXfmPrkjXTNPuLZBj+rZlxi05fQ=
 github.com/jenkins-x/lighthouse-config v0.0.7/go.mod h1:yG86b4t5H/Auif/mIiUf4C35wuI8LnjivjWTVUUbYIs=
+github.com/jenkins-x/lighthouse-config v0.0.8 h1:Y+ApzE98QDiEOoncKk+uBWU3zu/cqC+0L2XKGJqZRIo=
+github.com/jenkins-x/lighthouse-config v0.0.8/go.mod h1:yG86b4t5H/Auif/mIiUf4C35wuI8LnjivjWTVUUbYIs=
 github.com/jenkins-x/logrus-stackdriver-formatter v0.1.1-0.20200408213659-1dcf20c371bb h1:woC0LbYpL9rgwZn13Z0KQBEGAmi9ugpWgWtQezHqsBM=
 github.com/jenkins-x/logrus-stackdriver-formatter v0.1.1-0.20200408213659-1dcf20c371bb/go.mod h1:Erk5rrGYkvFlFAoVbz/7obMDEIUFCEbhK1odoR0Msqo=
 github.com/jenkins-x/logrus-stackdriver-formatter v0.2.3 h1:NuRWKUPCEX1wKlXA8ZYSG28qGKd41R7BK11YDQkPwqo=
diff --git a/jenkins-x-bbs.yml b/jenkins-x-bbs.yml
index 352213c7..10d36615 100644
--- a/jenkins-x-bbs.yml
+++ b/jenkins-x-bbs.yml
@@ -118,6 +118,16 @@ pipelineConfig:
                   - --cache-dir=/workspace
                   - --build-arg=VERSION=$(inputs.params.version)
 
+              - name: build-and-push-jx-controller
+                image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
+                command: /kaniko/executor
+                args:
+                  - --dockerfile=/workspace/source/Dockerfile.jxcontroller
+                  - --destination=gcr.io/jenkinsxio/lighthouse-jx-controller:${inputs.params.version}
+                  - --context=/workspace/source
+                  - --cache-dir=/workspace
+                  - --build-arg=VERSION=${inputs.params.version}
+
               - name: build-and-push-gc-jobs
                 image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
                 command: /kaniko/executor
diff --git a/jenkins-x-ghe.yml b/jenkins-x-ghe.yml
index bb0b59bf..e933a086 100644
--- a/jenkins-x-ghe.yml
+++ b/jenkins-x-ghe.yml
@@ -118,6 +118,16 @@ pipelineConfig:
                   - --cache-dir=/workspace
                   - --build-arg=VERSION=$(inputs.params.version)
 
+              - name: build-and-push-jx-controller
+                image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
+                command: /kaniko/executor
+                args:
+                  - --dockerfile=/workspace/source/Dockerfile.jxcontroller
+                  - --destination=gcr.io/jenkinsxio/lighthouse-jx-controller:${inputs.params.version}
+                  - --context=/workspace/source
+                  - --cache-dir=/workspace
+                  - --build-arg=VERSION=${inputs.params.version}
+
               - name: build-and-push-gc-jobs
                 image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
                 command: /kaniko/executor
diff --git a/jenkins-x-github.yml b/jenkins-x-github.yml
index 2b129706..9176a874 100644
--- a/jenkins-x-github.yml
+++ b/jenkins-x-github.yml
@@ -118,6 +118,16 @@ pipelineConfig:
                   - --cache-dir=/workspace
                   - --build-arg=VERSION=$(inputs.params.version)
 
+              - name: build-and-push-jx-controller
+                image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
+                command: /kaniko/executor
+                args:
+                  - --dockerfile=/workspace/source/Dockerfile.jxcontroller
+                  - --destination=gcr.io/jenkinsxio/lighthouse-jx-controller:${inputs.params.version}
+                  - --context=/workspace/source
+                  - --cache-dir=/workspace
+                  - --build-arg=VERSION=${inputs.params.version}
+
               - name: build-and-push-gc-jobs
                 image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
                 command: /kaniko/executor
diff --git a/jenkins-x-gitlab.yml b/jenkins-x-gitlab.yml
index 6a49d948..4f6f97d9 100644
--- a/jenkins-x-gitlab.yml
+++ b/jenkins-x-gitlab.yml
@@ -118,6 +118,16 @@ pipelineConfig:
                   - --cache-dir=/workspace
                   - --build-arg=VERSION=$(inputs.params.version)
 
+              - name: build-and-push-jx-controller
+                image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
+                command: /kaniko/executor
+                args:
+                  - --dockerfile=/workspace/source/Dockerfile.jxcontroller
+                  - --destination=gcr.io/jenkinsxio/lighthouse-jx-controller:${inputs.params.version}
+                  - --context=/workspace/source
+                  - --cache-dir=/workspace
+                  - --build-arg=VERSION=${inputs.params.version}
+
               - name: build-and-push-gc-jobs
                 image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
                 command: /kaniko/executor
diff --git a/jenkins-x.yml b/jenkins-x.yml
index 49b4ba20..6eb54f4b 100644
--- a/jenkins-x.yml
+++ b/jenkins-x.yml
@@ -173,6 +173,16 @@ pipelineConfig:
                   - --cache-dir=/workspace
                   - --build-arg=VERSION=$(inputs.params.version)
 
+              - name: build-and-push-jx-controller
+                image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
+                command: /kaniko/executor
+                args:
+                  - --dockerfile=/workspace/source/Dockerfile.jxcontroller
+                  - --destination=gcr.io/jenkinsxio/lighthouse-jx-controller:${inputs.params.version}
+                  - --context=/workspace/source
+                  - --cache-dir=/workspace
+                  - --build-arg=VERSION=${inputs.params.version}
+
               - name: build-and-push-gc-jobs
                 image: gcr.io/kaniko-project/executor:9912ccbf8d22bbafbf971124600fbb0b13b9cbd6
                 command: /kaniko/executor
diff --git a/pkg/apis/lighthouse/v1alpha1/types.go b/pkg/apis/lighthouse/v1alpha1/types.go
index 31602af2..56b7fcc4 100644
--- a/pkg/apis/lighthouse/v1alpha1/types.go
+++ b/pkg/apis/lighthouse/v1alpha1/types.go
@@ -9,6 +9,8 @@ import (
 	"time"
 
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
+	pipelinev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -90,6 +92,8 @@ type LighthouseJobStatus struct {
 	LastReportState string `json:"lastReportState,omitempty"`
 	// LastCommitSHA is the commit that will be/has been reported to on the SCM provider
 	LastCommitSHA string `json:"lastCommitSHA,omitempty"`
+	// Activity is the most recent activity recorded for the pipeline associated with this job.
+	Activity *ActivityRecord `json:"activity,omitempty"`
 }
 
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -107,6 +111,8 @@ type LighthouseJobSpec struct {
 	// Type is the type of job and informs how
 	// the jobs is triggered
 	Type config.PipelineKind `json:"type,omitempty"`
+	// Agent is what should run this job, if anything.
+	Agent string `json:"agent,omitempty"`
 	// Namespace defines where to create pods/resources.
 	Namespace string `json:"namespace,omitempty"`
 	// Job is the name of the job
@@ -123,6 +129,11 @@ type LighthouseJobSpec struct {
 	// MaxConcurrency restricts the total number of instances
 	// of this job that can run in parallel at once
 	MaxConcurrency int `json:"max_concurrency,omitempty"`
+	// PipelineRunSpec provides the basis for running the test as a Tekton Pipeline
+	// https://github.com/tektoncd/pipeline
+	PipelineRunSpec *pipelinev1alpha1.PipelineRunSpec `json:"pipeline_run_spec,omitempty"`
+	// PodSpec provides the basis for running the test under a Kubernetes agent
+	PodSpec *corev1.PodSpec `json:"pod_spec,omitempty"`
 }
 
 // GetBranch returns the branch name corresponding to the refs on this spec.
@@ -331,3 +342,45 @@ type ByNum []Pull
 func (prs ByNum) Len() int           { return len(prs) }
 func (prs ByNum) Swap(i, j int)      { prs[i], prs[j] = prs[j], prs[i] }
 func (prs ByNum) Less(i, j int) bool { return prs[i].Number < prs[j].Number }
+
+// ActivityRecord is a struct for reporting information on a pipeline, build, or other activity triggered by Lighthouse
+type ActivityRecord struct {
+	Name            string                 `json:"name"`
+	Owner           string                 `json:"owner,omitempty"`
+	Repo            string                 `json:"repo,omitempty"`
+	Branch          string                 `json:"branch,omitempty"`
+	BuildIdentifier string                 `json:"buildId,omitempty"`
+	Context         string                 `json:"context,omitempty"`
+	GitURL          string                 `json:"gitURL,omitempty"`
+	LogURL          string                 `json:"logURL,omitempty"`
+	LinkURL         string                 `json:"linkURL,omitempty"`
+	Status          PipelineState          `json:"status,omitempty"`
+	BaseSHA         string                 `json:"baseSHA,omitempty"`
+	LastCommitSHA   string                 `json:"lastCommitSHA,omitempty"`
+	StartTime       *metav1.Time           `json:"startTime,omitempty"`
+	CompletionTime  *metav1.Time           `json:"completionTime,omitempty"`
+	Stages          []*ActivityStageOrStep `json:"stages,omitempty"`
+	Steps           []*ActivityStageOrStep `json:"steps,omitEmpty"`
+}
+
+// ActivityStageOrStep represents a stage of an activity
+type ActivityStageOrStep struct {
+	Name           string                 `json:"name"`
+	Status         PipelineState          `json:"status"`
+	StartTime      *metav1.Time           `json:"startTime,omitempty"`
+	CompletionTime *metav1.Time           `json:"completionTime,omitempty"`
+	Stages         []*ActivityStageOrStep `json:"stages,omitempty"`
+	Steps          []*ActivityStageOrStep `json:"steps,omitempty"`
+}
+
+// RunningStages returns the list of stages currently running
+func (a *ActivityRecord) RunningStages() []string {
+	var running []string
+
+	for _, stage := range a.Stages {
+		if stage.Status == RunningState {
+			running = append(running, stage.Name)
+		}
+	}
+	return running
+}
diff --git a/pkg/apis/lighthouse/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/lighthouse/v1alpha1/zz_generated.deepcopy.go
index 25363d5f..d8833477 100644
--- a/pkg/apis/lighthouse/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/lighthouse/v1alpha1/zz_generated.deepcopy.go
@@ -5,9 +5,103 @@
 package v1alpha1
 
 import (
+	pipelinev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
+	v1 "k8s.io/api/core/v1"
 	runtime "k8s.io/apimachinery/pkg/runtime"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ActivityRecord) DeepCopyInto(out *ActivityRecord) {
+	*out = *in
+	if in.StartTime != nil {
+		in, out := &in.StartTime, &out.StartTime
+		*out = (*in).DeepCopy()
+	}
+	if in.CompletionTime != nil {
+		in, out := &in.CompletionTime, &out.CompletionTime
+		*out = (*in).DeepCopy()
+	}
+	if in.Stages != nil {
+		in, out := &in.Stages, &out.Stages
+		*out = make([]*ActivityStageOrStep, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(ActivityStageOrStep)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+	if in.Steps != nil {
+		in, out := &in.Steps, &out.Steps
+		*out = make([]*ActivityStageOrStep, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(ActivityStageOrStep)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActivityRecord.
+func (in *ActivityRecord) DeepCopy() *ActivityRecord {
+	if in == nil {
+		return nil
+	}
+	out := new(ActivityRecord)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ActivityStageOrStep) DeepCopyInto(out *ActivityStageOrStep) {
+	*out = *in
+	if in.StartTime != nil {
+		in, out := &in.StartTime, &out.StartTime
+		*out = (*in).DeepCopy()
+	}
+	if in.CompletionTime != nil {
+		in, out := &in.CompletionTime, &out.CompletionTime
+		*out = (*in).DeepCopy()
+	}
+	if in.Stages != nil {
+		in, out := &in.Stages, &out.Stages
+		*out = make([]*ActivityStageOrStep, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(ActivityStageOrStep)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+	if in.Steps != nil {
+		in, out := &in.Steps, &out.Steps
+		*out = make([]*ActivityStageOrStep, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(ActivityStageOrStep)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActivityStageOrStep.
+func (in *ActivityStageOrStep) DeepCopy() *ActivityStageOrStep {
+	if in == nil {
+		return nil
+	}
+	out := new(ActivityStageOrStep)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in ByNum) DeepCopyInto(out *ByNum) {
 	{
@@ -154,6 +248,16 @@ func (in *LighthouseJobSpec) DeepCopyInto(out *LighthouseJobSpec) {
 		*out = new(Refs)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.PipelineRunSpec != nil {
+		in, out := &in.PipelineRunSpec, &out.PipelineRunSpec
+		*out = new(pipelinev1alpha1.PipelineRunSpec)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.PodSpec != nil {
+		in, out := &in.PodSpec, &out.PodSpec
+		*out = new(v1.PodSpec)
+		(*in).DeepCopyInto(*out)
+	}
 	return
 }
 
@@ -175,6 +279,11 @@ func (in *LighthouseJobStatus) DeepCopyInto(out *LighthouseJobStatus) {
 		in, out := &in.CompletionTime, &out.CompletionTime
 		*out = (*in).DeepCopy()
 	}
+	if in.Activity != nil {
+		in, out := &in.Activity, &out.Activity
+		*out = new(ActivityRecord)
+		(*in).DeepCopyInto(*out)
+	}
 	return
 }
 
diff --git a/pkg/clients/clients.go b/pkg/clients/clients.go
index b56c093b..90a079eb 100644
--- a/pkg/clients/clients.go
+++ b/pkg/clients/clients.go
@@ -1,49 +1,69 @@
 package clients
 
 import (
+	"fmt"
+	"os"
+	"os/user"
+	"path/filepath"
+
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
 	"github.com/pkg/errors"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
 
-	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
-	"github.com/jenkins-x/jx/v2/pkg/kube"
 	tektonclient "github.com/tektoncd/pipeline/pkg/client/clientset/versioned"
 	kubeclient "k8s.io/client-go/kubernetes"
 )
 
-// GetClientsAndNamespace returns the tekton, jx, kube, and Lighthouse clients and the dev namespace
-func GetClientsAndNamespace(factory jxfactory.Factory) (tektonclient.Interface, jxclient.Interface, kubeclient.Interface, clientset.Interface, string, error) {
-	if factory == nil {
-		factory = jxfactory.NewFactory()
-	}
-
-	tektonClient, _, err := factory.CreateTektonClient()
+// GetAPIClients returns the tekton, kube, and Lighthouse clients and the kubeconfig used to create them
+func GetAPIClients() (tektonclient.Interface, kubeclient.Interface, clientset.Interface, *rest.Config, error) {
+	kubeCfg, err := GetConfig("", "")
 	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to create Tekton client")
+		return nil, nil, nil, nil, errors.Wrap(err, "unable to get kubeconfig")
 	}
 
-	jxClient, _, err := factory.CreateJXClient()
+	lhClient, err := clientset.NewForConfig(kubeCfg)
 	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to create JX client")
+		return nil, nil, nil, nil, errors.Wrap(err, "unable to create Lighthouse client")
 	}
 
-	kubeClient, ns, err := factory.CreateKubeClient()
+	tektonClient, err := tektonclient.NewForConfig(kubeCfg)
 	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to create Kube client")
+		return nil, nil, nil, nil, errors.Wrap(err, "unable to create Tekton client")
 	}
-	ns, _, err = kube.GetDevNamespace(kubeClient, ns)
+
+	kubeClient, err := kubeclient.NewForConfig(kubeCfg)
 	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to find the dev namespace")
+		return nil, nil, nil, nil, errors.Wrap(err, "unable to create Kubernetes client")
 	}
 
-	config, err := factory.CreateKubeConfig()
-	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to create kubeconfig for Lighthouse client")
+	return tektonClient, kubeClient, lhClient, kubeCfg, nil
+}
+
+// GetConfig returns a rest.Config to be used for kubernetes client creation.
+// It does so in the following order:
+//   1. Use the passed kubeconfig/masterURL.
+//   2. Fallback to the KUBECONFIG environment variable.
+//   3. Fallback to in-cluster config.
+//   4. Fallback to the ~/.kube/config.
+func GetConfig(masterURL, kubeconfig string) (*rest.Config, error) {
+	if kubeconfig == "" {
+		kubeconfig = os.Getenv("KUBECONFIG")
 	}
-	lhClient, err := clientset.NewForConfig(config)
-	if err != nil {
-		return nil, nil, nil, nil, "", errors.Wrap(err, "unable to create Lighthouse client")
+	// If we have an explicit indication of where the kubernetes config lives, read that.
+	if kubeconfig != "" {
+		return clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
+	}
+	// If not, try the in-cluster config.
+	if c, err := rest.InClusterConfig(); err == nil {
+		return c, nil
+	}
+	// If no in-cluster config, try the default location in the user's home directory.
+	if usr, err := user.Current(); err == nil {
+		if c, err := clientcmd.BuildConfigFromFlags("", filepath.Join(usr.HomeDir, ".kube", "config")); err == nil {
+			return c, nil
+		}
 	}
 
-	return tektonClient, jxClient, kubeClient, lhClient, ns, nil
+	return nil, fmt.Errorf("could not create a valid kubeconfig")
 }
diff --git a/pkg/foghorn/controller.go b/pkg/foghorn/controller.go
index 6010f82e..24718e3c 100644
--- a/pkg/foghorn/controller.go
+++ b/pkg/foghorn/controller.go
@@ -13,18 +13,12 @@ import (
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/go-scm/scm/factory"
-	jxv1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
-	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
-	jxinformers "github.com/jenkins-x/jx-api/pkg/client/informers/externalversions/jenkins.io/v1"
-	jxlisters "github.com/jenkins-x/jx-api/pkg/client/listers/jenkins.io/v1"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
 	lhinformers "github.com/jenkins-x/lighthouse/pkg/client/informers/externalversions/lighthouse/v1alpha1"
 	lhlisters "github.com/jenkins-x/lighthouse/pkg/client/listers/lighthouse/v1alpha1"
-	"github.com/jenkins-x/lighthouse/pkg/jx"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
-	"github.com/jenkins-x/lighthouse/pkg/record"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider/reporter"
 	"github.com/jenkins-x/lighthouse/pkg/util"
@@ -34,7 +28,6 @@ import (
 	"golang.org/x/time/rate"
 	kubeerrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/labels"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/kubernetes"
@@ -50,13 +43,9 @@ const (
 
 // Controller listens for changes to PipelineActivitys and updates the corresponding LighthouseJobs and provider commit statuses.
 type Controller struct {
-	jxClient   jxclient.Interface
 	lhClient   clientset.Interface
 	kubeClient kubernetes.Interface
 
-	activityLister jxlisters.PipelineActivityLister
-	activitySynced cache.InformerSynced
-
 	lhLister lhlisters.LighthouseJobLister
 	lhSynced cache.InformerSynced
 	// queue is a rate limited work queue. This is used to queue work to be
@@ -76,9 +65,8 @@ type Controller struct {
 	ns     string
 }
 
-// NewController returns a new controller for syncing PipelineActivity updates to LighthouseJobs and commit statuses
-func NewController(kubeClient kubernetes.Interface, jxClient jxclient.Interface, lhClient clientset.Interface, activityInformer jxinformers.PipelineActivityInformer,
-	lhInformer lhinformers.LighthouseJobInformer, ns string, logger *logrus.Entry) (*Controller, error) {
+// NewController returns a new controller for syncing LighthouseJobs and commit statuses
+func NewController(kubeClient kubernetes.Interface, lhClient clientset.Interface, lhInformer lhinformers.LighthouseJobInformer, ns string, logger *logrus.Entry) (*Controller, error) {
 	if logger == nil {
 		logger = logrus.NewEntry(logrus.StandardLogger()).WithField("controller", controllerName)
 	}
@@ -128,10 +116,7 @@ func NewController(kubeClient kubernetes.Interface, jxClient jxclient.Interface,
 	}
 
 	controller := &Controller{
-		jxClient:         jxClient,
 		lhClient:         lhClient,
-		activityLister:   activityInformer.Lister(),
-		activitySynced:   activityInformer.Informer().HasSynced,
 		lhLister:         lhInformer.Lister(),
 		lhSynced:         lhInformer.Informer().HasSynced,
 		logger:           logger,
@@ -143,13 +128,12 @@ func NewController(kubeClient kubernetes.Interface, jxClient jxclient.Interface,
 		kubeClient:       kubeClient,
 	}
 
-	activityInformer.Informer()
 	logger.Info("Setting up event handlers")
-	activityInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+	lhInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
 		AddFunc: func(obj interface{}) {},
 		UpdateFunc: func(oldObj, newObj interface{}) {
-			newAct := newObj.(*jxv1.PipelineActivity)
-			oldAct := oldObj.(*jxv1.PipelineActivity)
+			newAct := newObj.(*v1alpha1.LighthouseJob)
+			oldAct := oldObj.(*v1alpha1.LighthouseJob)
 			// Skip updates solely triggered by resyncs. We only care if they're actually different.
 			if oldAct.ResourceVersion == newAct.ResourceVersion {
 				return
@@ -184,7 +168,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
 
 	// Wait for the caches to be synced before starting workers
 	c.logger.Info("Waiting for informer caches to sync")
-	if ok := cache.WaitForCacheSync(stopCh, c.activitySynced, c.lhSynced); !ok {
+	if ok := cache.WaitForCacheSync(stopCh, c.lhSynced); !ok {
 		return fmt.Errorf("failed to wait for caches to sync")
 	}
 
@@ -274,11 +258,9 @@ func (c *Controller) syncHandler(key string) error {
 		return nil
 	}
 
-	// Get the PipelineActivity resource with this namespace/name
-	jxActivity, err := c.activityLister.PipelineActivities(namespace).Get(name)
+	job, err := c.lhLister.LighthouseJobs(namespace).Get(name)
 	if err != nil {
-		// The PipelineActivity resource may no longer exist, in which case we delete the associated LH job
-		// TODO: Actually delete.
+		// The LighthouseJob resource may no longer exist
 		if kubeerrors.IsNotFound(err) {
 			c.logger.Warnf("activity '%s' in work queue no longer exists", key)
 			return nil
@@ -288,33 +270,14 @@ func (c *Controller) syncHandler(key string) error {
 		return err
 	}
 
-	activityRecord, err := jx.ConvertPipelineActivity(jxActivity)
-	if err != nil {
-		return err
-	}
-
-	var job *v1alpha1.LighthouseJob
-
-	// Get all LighthouseJobs with the same owner/repo/branch/build/context
-	labelSelector, err := createLabelSelectorFromActivity(activityRecord)
-	possibleJobs, err := c.lhLister.LighthouseJobs(namespace).List(labelSelector)
-	if err != nil {
-		return err
-	}
-	if len(possibleJobs) == 0 {
-		// TODO: Something to handle jx start pipeline cases - my previous approach resulted in infinite creations of new jobs, which was...wrong. (apb)
-		c.logger.Warnf("no LighthouseJobs found matching label selector %s", labelSelector.String())
+	if job == nil {
 		return nil
 	}
 
-	// To be safe, find the job with the activity's name in its status.
-	for _, j := range possibleJobs {
-		if j.Status.ActivityName == jxActivity.Name {
-			job = j
-		}
-	}
+	activityRecord := job.Status.Activity
 
-	if job == nil {
+	if activityRecord == nil {
+		// There's no activity on the job, so there's nothing for us to do.
 		return nil
 	}
 
@@ -341,7 +304,7 @@ func (c *Controller) syncHandler(key string) error {
 	return nil
 }
 
-func (c *Controller) updateJobStatusForActivity(activity *record.ActivityRecord, job *v1alpha1.LighthouseJob) {
+func (c *Controller) updateJobStatusForActivity(activity *v1alpha1.ActivityRecord, job *v1alpha1.LighthouseJob) {
 	if activity.Status != job.Status.State {
 		job.Status.State = activity.Status
 	}
@@ -362,29 +325,7 @@ func RateLimiter() workqueue.RateLimitingInterface {
 	return workqueue.NewNamedRateLimitingQueue(rl, controllerName)
 }
 
-func createLabelSelectorFromActivity(activity *record.ActivityRecord) (labels.Selector, error) {
-	var selectors []string
-
-	if activity.Owner != "" {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", util.OrgLabel, strings.ToLower(activity.Owner)))
-	}
-	if activity.Repo != "" {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", util.RepoLabel, activity.Repo))
-	}
-	if activity.Branch != "" {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", util.BranchLabel, activity.Branch))
-	}
-	if activity.BuildIdentifier != "" {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", util.BuildNumLabel, activity.BuildIdentifier))
-	}
-	if activity.Context != "" {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", util.ContextLabel, activity.Context))
-	}
-
-	return labels.Parse(strings.Join(selectors, ","))
-}
-
-func (c *Controller) reportStatus(ns string, activity *record.ActivityRecord, job *v1alpha1.LighthouseJob) {
+func (c *Controller) reportStatus(ns string, activity *v1alpha1.ActivityRecord, job *v1alpha1.LighthouseJob) {
 	sha := activity.LastCommitSHA
 
 	owner := activity.Owner
@@ -551,7 +492,7 @@ type reportStatusInfo struct {
 	runningStages string
 }
 
-func toScmStatusDescriptionRunningStages(activity *record.ActivityRecord, gitKind string) reportStatusInfo {
+func toScmStatusDescriptionRunningStages(activity *v1alpha1.ActivityRecord, gitKind string) reportStatusInfo {
 	info := reportStatusInfo{
 		description:   "",
 		runningStages: "",
diff --git a/pkg/jobutil/jobutil.go b/pkg/jobutil/jobutil.go
index 02bb5975..41032104 100644
--- a/pkg/jobutil/jobutil.go
+++ b/pkg/jobutil/jobutil.go
@@ -59,14 +59,16 @@ func createRefs(pr *scm.PullRequest, baseSHA string) v1alpha1.Refs {
 	repo := pr.Base.Repo.Name
 	number := pr.Number
 	repoLink := pr.Base.Repo.Link
+	cloneURL := pr.Base.Repo.Clone
 	return v1alpha1.Refs{
 		Org:      org,
 		Repo:     repo,
 		RepoLink: repoLink,
 		BaseLink: fmt.Sprintf("%s/commit/%s", repoLink, baseSHA),
 
-		BaseRef: pr.Base.Ref,
-		BaseSHA: baseSHA,
+		BaseRef:  pr.Base.Ref,
+		BaseSHA:  baseSHA,
+		CloneURI: cloneURL,
 		Pulls: []v1alpha1.Pull{
 			{
 				Number:     number,
@@ -142,9 +144,12 @@ func specFromJobBase(jb config.JobBase) v1alpha1.LighthouseJobSpec {
 		namespace = *jb.Namespace
 	}
 	return v1alpha1.LighthouseJobSpec{
-		Job:            jb.Name,
-		Namespace:      namespace,
-		MaxConcurrency: jb.MaxConcurrency,
+		Agent:           jb.Agent,
+		Job:             jb.Name,
+		Namespace:       namespace,
+		MaxConcurrency:  jb.MaxConcurrency,
+		PodSpec:         jb.Spec,
+		PipelineRunSpec: jb.PipelineRunSpec,
 	}
 }
 
diff --git a/pkg/jx/activity.go b/pkg/jx/activity.go
index 84e653af..26873349 100644
--- a/pkg/jx/activity.go
+++ b/pkg/jx/activity.go
@@ -5,7 +5,6 @@ import (
 
 	v1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
-	"github.com/jenkins-x/lighthouse/pkg/record"
 )
 
 // ToPipelineState converts the PipelineActivity state to LighthouseJob state
@@ -27,7 +26,7 @@ func ToPipelineState(status v1.ActivityStatusType) v1alpha1.PipelineState {
 }
 
 // ConvertPipelineActivity converts a PipelineActivity from jx to an ActivityRecord
-func ConvertPipelineActivity(pa *v1.PipelineActivity) (*record.ActivityRecord, error) {
+func ConvertPipelineActivity(pa *v1.PipelineActivity) (*v1alpha1.ActivityRecord, error) {
 	if pa == nil {
 		return nil, errors.New("pipeline activity is nil")
 	}
@@ -37,7 +36,7 @@ func ConvertPipelineActivity(pa *v1.PipelineActivity) (*record.ActivityRecord, e
 		sha = pa.Labels[v1.LabelLastCommitSha]
 	}
 
-	ar := &record.ActivityRecord{
+	ar := &v1alpha1.ActivityRecord{
 		Name:            pa.Name,
 		Owner:           pa.Spec.GitOwner,
 		Repo:            pa.Spec.GitRepository,
@@ -51,7 +50,7 @@ func ConvertPipelineActivity(pa *v1.PipelineActivity) (*record.ActivityRecord, e
 		Status:          ToPipelineState(pa.Spec.Status),
 		StartTime:       pa.Spec.StartedTimestamp,
 		CompletionTime:  pa.Spec.CompletedTimestamp,
-		Stages:          []*record.ActivityStageOrStep{},
+		Stages:          []*v1alpha1.ActivityStageOrStep{},
 	}
 
 	for _, step := range pa.Spec.Steps {
@@ -63,13 +62,13 @@ func ConvertPipelineActivity(pa *v1.PipelineActivity) (*record.ActivityRecord, e
 	return ar, nil
 }
 
-func convertStage(paStage *v1.StageActivityStep) *record.ActivityStageOrStep {
-	stage := &record.ActivityStageOrStep{
+func convertStage(paStage *v1.StageActivityStep) *v1alpha1.ActivityStageOrStep {
+	stage := &v1alpha1.ActivityStageOrStep{
 		Name:           paStage.Name,
 		Status:         ToPipelineState(paStage.Status),
 		StartTime:      paStage.StartedTimestamp,
 		CompletionTime: paStage.CompletedTimestamp,
-		Steps:          []*record.ActivityStageOrStep{},
+		Steps:          []*v1alpha1.ActivityStageOrStep{},
 	}
 
 	for _, child := range paStage.Steps {
@@ -79,8 +78,8 @@ func convertStage(paStage *v1.StageActivityStep) *record.ActivityStageOrStep {
 	return stage
 }
 
-func convertStep(paStep v1.CoreActivityStep) *record.ActivityStageOrStep {
-	return &record.ActivityStageOrStep{
+func convertStep(paStep v1.CoreActivityStep) *v1alpha1.ActivityStageOrStep {
+	return &v1alpha1.ActivityStageOrStep{
 		Name:           paStep.Name,
 		Status:         ToPipelineState(paStep.Status),
 		StartTime:      paStep.StartedTimestamp,
diff --git a/pkg/jx/controller.go b/pkg/jx/controller.go
new file mode 100644
index 00000000..5f0caca2
--- /dev/null
+++ b/pkg/jx/controller.go
@@ -0,0 +1,561 @@
+package jx
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/signal"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	jsonpatch "github.com/evanphx/json-patch"
+	jxv1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
+	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
+	jxinformers "github.com/jenkins-x/jx-api/pkg/client/informers/externalversions/jenkins.io/v1"
+	jxlisters "github.com/jenkins-x/jx-api/pkg/client/listers/jenkins.io/v1"
+	"github.com/jenkins-x/jx/v2/pkg/tekton/metapipeline"
+	"github.com/jenkins-x/lighthouse-config/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
+	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
+	lhinformers "github.com/jenkins-x/lighthouse/pkg/client/informers/externalversions/lighthouse/v1alpha1"
+	lhlisters "github.com/jenkins-x/lighthouse/pkg/client/listers/lighthouse/v1alpha1"
+	"github.com/jenkins-x/lighthouse/pkg/util"
+	"github.com/jenkins-x/lighthouse/pkg/watcher"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"golang.org/x/time/rate"
+	kubeerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/util/workqueue"
+)
+
+const (
+	controllerName    = "jx-controller"
+	activityKeyPrefix = "activity"
+	jobKeyPrefix      = "job"
+)
+
+// Controller listens for changes to PipelineActivitys and updates the corresponding LighthouseJobs with their activity
+type Controller struct {
+	jxClient   jxclient.Interface
+	lhClient   clientset.Interface
+	kubeClient kubernetes.Interface
+
+	mpClient metapipeline.Client
+
+	activityLister jxlisters.PipelineActivityLister
+	activitySynced cache.InformerSynced
+
+	lhLister lhlisters.LighthouseJobLister
+	lhSynced cache.InformerSynced
+	// queue is a rate limited work queue. This is used to queue work to be
+	// processed instead of performing it as soon as a change happens. This
+	// means we can ensure we only process a fixed amount of resources at a
+	// time, and makes it easy to ensure we are never processing the same item
+	// simultaneously in two different workers.
+	queue workqueue.RateLimitingInterface
+
+	configMapWatcher *watcher.ConfigMapWatcher
+
+	jobConfig *config.Agent
+
+	wg     *sync.WaitGroup
+	logger *logrus.Entry
+	ns     string
+}
+
+// NewController returns a new controller for syncing PipelineActivity updates to LighthouseJobs and commit statuses
+func NewController(kubeClient kubernetes.Interface, jxClient jxclient.Interface, lhClient clientset.Interface, activityInformer jxinformers.PipelineActivityInformer,
+	lhInformer lhinformers.LighthouseJobInformer, ns string, logger *logrus.Entry) (*Controller, error) {
+	if logger == nil {
+		logger = logrus.NewEntry(logrus.StandardLogger()).WithField("controller", controllerName)
+	}
+
+	configAgent := &config.Agent{}
+
+	onConfigYamlChange := func(text string) {
+		if text != "" {
+			cfg, err := config.LoadYAMLConfig([]byte(text))
+			if err != nil {
+				logrus.WithError(err).Error("Error processing the prow Config YAML")
+			} else {
+				logrus.Info("updating the prow core configuration")
+				configAgent.Set(cfg)
+			}
+		}
+	}
+
+	callbacks := []watcher.ConfigMapCallback{
+		&watcher.ConfigMapEntryCallback{
+			Name:     util.ProwConfigMapName,
+			Key:      util.ProwConfigFilename,
+			Callback: onConfigYamlChange,
+		},
+	}
+	configMapWatcher, err := watcher.NewConfigMapWatcher(kubeClient, ns, callbacks, stopper())
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to create ConfigMap watcher")
+	}
+
+	activityLister := activityInformer.Lister()
+	activitySynced := activityInformer.Informer().HasSynced
+
+	mpClient, _, _, err := NewMetaPipelineClient(ns)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to create metapipeline client")
+	}
+
+	controller := &Controller{
+		jxClient:         jxClient,
+		lhClient:         lhClient,
+		mpClient:         mpClient,
+		activityLister:   activityLister,
+		activitySynced:   activitySynced,
+		lhLister:         lhInformer.Lister(),
+		lhSynced:         lhInformer.Informer().HasSynced,
+		logger:           logger,
+		ns:               ns,
+		queue:            RateLimiter(),
+		jobConfig:        configAgent,
+		configMapWatcher: configMapWatcher,
+		kubeClient:       kubeClient,
+	}
+
+	logger.Info("Setting up event handlers")
+	lhInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		AddFunc: func(obj interface{}) {
+			key, err := toKey(obj)
+			if err == nil {
+				controller.queue.AddRateLimited(key)
+			}
+		},
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			newJob := newObj.(*v1alpha1.LighthouseJob)
+			oldJob := oldObj.(*v1alpha1.LighthouseJob)
+			if oldJob.ResourceVersion == newJob.ResourceVersion {
+				return
+			}
+			// Don't queue any job that isn't in the triggered state
+			if newJob.Status.State != v1alpha1.TriggeredState {
+				return
+			}
+			key, err := toKey(newObj)
+			if err == nil {
+				controller.queue.AddRateLimited(key)
+			}
+		},
+	})
+	activityInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		AddFunc: func(obj interface{}) {
+			key, err := toKey(obj)
+			if err == nil {
+				controller.queue.AddRateLimited(key)
+			}
+		},
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			newAct := newObj.(*jxv1.PipelineActivity)
+			oldAct := oldObj.(*jxv1.PipelineActivity)
+			// Skip updates solely triggered by resyncs. We only care if they're actually different.
+			if oldAct.ResourceVersion == newAct.ResourceVersion {
+				return
+			}
+			key, err := toKey(newObj)
+			if err == nil {
+				controller.queue.AddRateLimited(key)
+			}
+		},
+	})
+
+	controller.wg = &sync.WaitGroup{}
+
+	return controller, nil
+}
+
+func toKey(obj interface{}) (string, error) {
+	baseKey, err := cache.MetaNamespaceKeyFunc(obj)
+	if err != nil {
+		return "", err
+	}
+	switch obj.(type) {
+	case *v1alpha1.LighthouseJob:
+		return jobKeyPrefix + ":::" + baseKey, nil
+	case *jxv1.PipelineActivity:
+		return activityKeyPrefix + ":::" + baseKey, nil
+	default:
+		return "", errors.New("unknown type, cannot enqueue")
+	}
+}
+
+// Run actually runs the controller
+func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
+	defer utilruntime.HandleCrash()
+	defer c.queue.ShutDown()
+
+	// Start the informer factories to begin populating the informer caches
+	c.logger.Info("Starting controller")
+
+	defer c.configMapWatcher.Stop()
+
+	// Wait for the caches to be synced before starting workers
+	c.logger.Info("Waiting for informer caches to sync")
+	if ok := cache.WaitForCacheSync(stopCh, c.activitySynced, c.lhSynced); !ok {
+		return fmt.Errorf("failed to wait for caches to sync")
+	}
+
+	c.logger.Info("Starting workers")
+	// Launch the appropriate number of workers to process PipelineActivity resources
+	for i := 0; i < threadiness; i++ {
+		go wait.Until(c.runWorker, time.Second, stopCh)
+	}
+
+	c.logger.Info("Started workers")
+	<-stopCh
+	c.logger.Info("Shutting down workers")
+
+	return nil
+}
+
+// runWorker is a long-running function that will continually call the
+// processNextWorkItem function in order to read and process a message on the
+// workqueue.
+func (c *Controller) runWorker() {
+	for c.processNextWorkItem() {
+	}
+}
+
+// processNextWorkItem will read a single work item off the workqueue and
+// attempt to process it, by calling the syncHandler.
+func (c *Controller) processNextWorkItem() bool {
+	obj, shutdown := c.queue.Get()
+
+	if shutdown {
+		return false
+	}
+
+	// We wrap this block in a func so we can defer c.workqueue.Done.
+	err := func(obj interface{}) error {
+		// We call Done here so the workqueue knows we have finished
+		// processing this item. We also must remember to call Forget if we
+		// do not want this work item being re-queued. For example, we do
+		// not call Forget if a transient error occurs, instead the item is
+		// put back on the workqueue and attempted again after a back-off
+		// period.
+		defer c.queue.Done(obj)
+		var key string
+		var ok bool
+		// We expect strings to come off the workqueue. These are of the
+		// form namespace/name. We do this as the delayed nature of the
+		// workqueue means the items in the informer cache may actually be
+		// more up to date that when the item was initially put onto the
+		// workqueue.
+		if key, ok = obj.(string); !ok {
+			// As the item in the workqueue is actually invalid, we call
+			// Forget here else we'd go into a loop of attempting to
+			// process a work item that is invalid.
+			c.queue.Forget(obj)
+			c.logger.Warnf("expected string in workqueue but got %#v", obj)
+			return nil
+		}
+		// Run the syncHandler, passing it the namespace/name string of the
+		// PipelineActivity resource to be synced.
+		if err := c.syncHandler(key); err != nil {
+			// Put the item back on the workqueue to handle any transient errors.
+			c.queue.AddRateLimited(key)
+			return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error())
+		}
+		// Finally, if no error occurs we Forget this item so it does not
+		// get queued again until another change happens.
+		c.queue.Forget(obj)
+		c.logger.Infof("Successfully synced '%s'", key)
+		return nil
+	}(obj)
+
+	if err != nil {
+		c.logger.WithError(err).Error("failure reconciling")
+		return true
+	}
+
+	return true
+}
+
+// syncHandler compares the actual state with the desired, and attempts to
+// converge the two.
+func (c *Controller) syncHandler(key string) error {
+	// Get the type of the key
+	keyParts := strings.Split(key, ":::")
+
+	if len(keyParts) != 2 {
+		return fmt.Errorf("no key type found in %s", key)
+	}
+	// Convert the namespace/name string into a distinct namespace and name
+	namespace, name, err := cache.SplitMetaNamespaceKey(keyParts[1])
+	if err != nil {
+		c.logger.Warnf("invalid resource key: %s", keyParts[1])
+		return nil
+	}
+
+	if keyParts[0] == activityKeyPrefix {
+		return c.syncActivity(namespace, name, key)
+	}
+	if keyParts[0] == jobKeyPrefix {
+		return c.syncJob(namespace, name, key)
+	}
+
+	return fmt.Errorf("unknown key type: %s", keyParts[0])
+}
+
+func (c *Controller) syncJob(namespace, name, key string) error {
+	// Get the LighthouseJob resource with this namespace/name
+	origJob, err := c.lhLister.LighthouseJobs(namespace).Get(name)
+	if err != nil {
+		if kubeerrors.IsNotFound(err) {
+			c.logger.Warnf("activity '%s' in work queue no longer exists", key)
+			return nil
+		}
+
+		// Return an error here so that we requeue and retry.
+		return err
+	}
+
+	spec := &origJob.Spec
+
+	// Only launch for the appropriate agent types and for triggered state
+	if origJob.Status.State == v1alpha1.TriggeredState && (spec.Agent == config.JenkinsXAgent || spec.Agent == config.LegacyDefaultAgent) {
+		jobName := spec.Refs.Repo
+		owner := spec.Refs.Org
+		sourceURL := spec.Refs.CloneURI
+
+		pullRefData := c.getPullRefs(sourceURL, spec)
+		pullRefs := ""
+		if len(spec.Refs.Pulls) > 0 {
+			pullRefs = pullRefData.String()
+		}
+
+		branch := spec.GetBranch()
+		if branch == "" {
+			branch = "master"
+		}
+		if pullRefs == "" {
+			pullRefs = branch + ":"
+		}
+
+		job := spec.Job
+		var kind metapipeline.PipelineKind
+		if len(spec.Refs.Pulls) > 0 {
+			kind = metapipeline.PullRequestPipeline
+		} else {
+			kind = metapipeline.ReleasePipeline
+		}
+
+		l := logrus.WithFields(logrus.Fields(map[string]interface{}{
+			"Owner":     owner,
+			"Name":      jobName,
+			"SourceURL": sourceURL,
+			"Branch":    branch,
+			"PullRefs":  pullRefs,
+			"Job":       job,
+		}))
+		l.Info("about to start Jenkinx X meta pipeline")
+
+		sa := os.Getenv("JX_SERVICE_ACCOUNT")
+		if sa == "" {
+			sa = "tekton-bot"
+		}
+
+		pipelineCreateParam := metapipeline.PipelineCreateParam{
+			PullRef:      pullRefData,
+			PipelineKind: kind,
+			Context:      spec.Context,
+			// No equivalent to https://github.com/jenkins-x/jx/blob/bb59278c2707e0e99b3c24be926745c324824388/pkg/cmd/controller/pipeline/pipelinerunner_controller.go#L236
+			//   for getting environment variables from the prow job here, so far as I can tell (abayer)
+			// Also not finding an equivalent to labels from the PipelineRunRequest
+			ServiceAccount: sa,
+			// I believe we can use an empty string default image?
+			DefaultImage: os.Getenv("JX_DEFAULT_IMAGE"),
+			EnvVariables: spec.GetEnvVars(),
+		}
+
+		activityKey, tektonCRDs, err := c.mpClient.Create(pipelineCreateParam)
+		if err != nil {
+			return errors.Wrap(err, "unable to create Tekton CRDs")
+		}
+
+		jobCopy := origJob.DeepCopy()
+
+		// Add the build number from the activity key to the labels on the job
+		jobCopy.Labels[util.BuildNumLabel] = activityKey.Build
+
+		origJSON, err := json.Marshal(origJob)
+		if err != nil {
+			return errors.Wrapf(err, "failed to marshal original job %s", origJob.Name)
+		}
+		copyJSON, err := json.Marshal(jobCopy)
+		if err != nil {
+			return errors.Wrapf(err, "failed to marshal updated job %s", jobCopy.Name)
+		}
+		patch, err := jsonpatch.CreateMergePatch(origJSON, copyJSON)
+		if err != nil {
+			return errors.Wrapf(err, "failed to create JSON patch for job %s", jobCopy.Name)
+		}
+
+		appliedJob, err := c.lhClient.LighthouseV1alpha1().LighthouseJobs(c.ns).Patch(jobCopy.Name, types.MergePatchType, patch)
+		if err != nil {
+			return errors.Wrapf(err, "unable to set build number on LighthouseJob %s", jobCopy.Name)
+		}
+
+		// Set status on the job
+		appliedJob.Status = v1alpha1.LighthouseJobStatus{
+			State:        v1alpha1.PendingState,
+			ActivityName: util.ToValidName(activityKey.Name),
+			StartTime:    metav1.Now(),
+		}
+		_, err = c.lhClient.LighthouseV1alpha1().LighthouseJobs(c.ns).UpdateStatus(appliedJob)
+		if err != nil {
+			return errors.Wrapf(err, "unable to set status on LighthouseJob %s", appliedJob.Name)
+		}
+
+		err = c.mpClient.Apply(activityKey, tektonCRDs)
+		if err != nil {
+			return errors.Wrap(err, "unable to apply Tekton CRDs")
+		}
+	}
+	return nil
+}
+
+func (c *Controller) syncActivity(namespace, name, key string) error {
+	// Get the PipelineActivity resource with this namespace/name
+	jxActivity, err := c.activityLister.PipelineActivities(namespace).Get(name)
+	if err != nil {
+		if kubeerrors.IsNotFound(err) {
+			c.logger.Warnf("activity '%s' in work queue no longer exists", key)
+			return nil
+		}
+
+		// Return an error here so that we requeue and retry.
+		return err
+	}
+	activityRecord, err := ConvertPipelineActivity(jxActivity)
+	if err != nil {
+		return err
+	}
+
+	var job *v1alpha1.LighthouseJob
+
+	// Get all LighthouseJobs with the same owner/repo/branch/build/context
+	labelSelector, err := createLabelSelectorFromActivity(activityRecord)
+	possibleJobs, err := c.lhLister.LighthouseJobs(namespace).List(labelSelector)
+	if err != nil {
+		return err
+	}
+	if len(possibleJobs) == 0 {
+		// TODO: Something to handle jx start pipeline cases - my previous approach resulted in infinite creations of new jobs, which was...wrong. (apb)
+		c.logger.Warnf("no LighthouseJobs found matching label selector %s", labelSelector.String())
+		return nil
+	}
+
+	// To be safe, find the job with the activity's name in its status.
+	for _, j := range possibleJobs {
+		if j.Status.ActivityName == activityRecord.Name {
+			job = j
+		}
+	}
+
+	if job == nil {
+		return nil
+	}
+
+	// Update the job's status for the activity.
+	jobCopy := job.DeepCopy()
+	jobCopy.Status.Activity = activityRecord
+
+	currentJob, err := c.lhLister.LighthouseJobs(namespace).Get(jobCopy.Name)
+	if err != nil {
+		c.logger.WithError(err).Errorf("couldn't get the orig of job %s", jobCopy.Name)
+		// Return an error here so we requeue and retry.
+		return err
+	}
+	if !reflect.DeepEqual(currentJob.Status, jobCopy.Status) {
+		c.logger.Infof("Recording updated activity for job %s", currentJob.Name)
+		currentJob.Status = jobCopy.Status
+		_, err = c.lhClient.LighthouseV1alpha1().LighthouseJobs(namespace).UpdateStatus(currentJob)
+		if err != nil {
+			c.logger.WithError(err).Errorf("error updating status with new activity for job %s", currentJob.Name)
+			// Return an error here so we requeue and retry.
+			return err
+		}
+	}
+	return nil
+}
+
+// RateLimiter creates a ratelimiting queue for the foghorn controller.
+func RateLimiter() workqueue.RateLimitingInterface {
+	rl := workqueue.NewMaxOfRateLimiter(
+		workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 120*time.Second),
+		&workqueue.BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(1000), 50000)},
+	)
+	return workqueue.NewNamedRateLimitingQueue(rl, controllerName)
+}
+
+func createLabelSelectorFromActivity(activity *v1alpha1.ActivityRecord) (labels.Selector, error) {
+	var selectors []string
+
+	if activity.Owner != "" {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", util.OrgLabel, strings.ToLower(activity.Owner)))
+	}
+	if activity.Repo != "" {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", util.RepoLabel, activity.Repo))
+	}
+	if activity.Branch != "" {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", util.BranchLabel, activity.Branch))
+	}
+	if activity.BuildIdentifier != "" {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", util.BuildNumLabel, activity.BuildIdentifier))
+	}
+	if activity.Context != "" {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", util.ContextLabel, activity.Context))
+	}
+
+	return labels.Parse(strings.Join(selectors, ","))
+}
+
+func (c *Controller) getPullRefs(sourceURL string, spec *v1alpha1.LighthouseJobSpec) metapipeline.PullRef {
+	var pullRef metapipeline.PullRef
+	if len(spec.Refs.Pulls) > 0 {
+		var prs []metapipeline.PullRequestRef
+		for _, pull := range spec.Refs.Pulls {
+			prs = append(prs, metapipeline.PullRequestRef{ID: strconv.Itoa(pull.Number), MergeSHA: pull.SHA})
+		}
+
+		pullRef = metapipeline.NewPullRefWithPullRequest(sourceURL, spec.Refs.BaseRef, spec.Refs.BaseSHA, prs...)
+	} else {
+		pullRef = metapipeline.NewPullRef(sourceURL, spec.Refs.BaseRef, spec.Refs.BaseSHA)
+	}
+
+	return pullRef
+}
+
+// stopper returns a channel that remains open until an interrupt is received.
+func stopper() chan struct{} {
+	stop := make(chan struct{})
+	c := make(chan os.Signal, 2)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		logrus.Warn("Interrupt received, attempting clean shutdown...")
+		close(stop)
+		<-c
+		logrus.Error("Second interrupt received, force exiting...")
+		os.Exit(1)
+	}()
+	return stop
+}
diff --git a/pkg/jx/helpers.go b/pkg/jx/helpers.go
index cdb9d1d2..e55bb454 100644
--- a/pkg/jx/helpers.go
+++ b/pkg/jx/helpers.go
@@ -3,43 +3,40 @@ package jx
 import (
 	"fmt"
 	"os"
-	"runtime/debug"
 
 	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/jx/v2/pkg/tekton/metapipeline"
 	"github.com/jenkins-x/jx/v2/pkg/util"
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
-	tektonclient "github.com/tektoncd/pipeline/pkg/client/clientset/versioned"
-	kubeclient "k8s.io/client-go/kubernetes"
 )
 
 // NewMetaPipelineClient creates a new client for the creation and application of meta pipelines.
 // The responsibility of the meta pipeline is to prepare the execution pipeline and to allow Apps to contribute
 // the this execution pipeline.
-func NewMetaPipelineClient(factory jxfactory.Factory) (metapipeline.Client, tektonclient.Interface, jxclient.Interface, kubeclient.Interface, clientset.Interface, string, error) {
-	if factory == nil {
-		logrus.Warnf("no jxfactory passed in to create metapipeline.Client: %s", string(debug.Stack()))
-		factory = jxfactory.NewFactory()
-	}
+func NewMetaPipelineClient(ns string) (metapipeline.Client, jxclient.Interface, clientset.Interface, error) {
 	logrus.Info("creating a metapipeline client")
 	// lets make sure that we have the jx home dir created
 	cfgHome := util.HomeDir()
 	err := os.MkdirAll(cfgHome, util.DefaultWritePermissions)
 	if err != nil {
-		return nil, nil, nil, nil, nil, "", errors.Wrapf(err, "failed to create jx home dir %s", cfgHome)
+		return nil, nil, nil, errors.Wrapf(err, "failed to create jx home dir %s", cfgHome)
 	}
 
-	tektonClient, jxClient, kubeClient, lhClient, ns, err := clients.GetClientsAndNamespace(factory)
+	tektonClient, kubeClient, lhClient, kubeCfg, err := clients.GetAPIClients()
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	jxClient, err := jxclient.NewForConfig(kubeCfg)
 	if err != nil {
-		return nil, nil, nil, nil, nil, "", err
+		return nil, nil, nil, errors.Wrap(err, "unable to create Jenkins X client")
 	}
+
 	client, err := metapipeline.NewMetaPipelineClientWithClientsAndNamespace(jxClient, tektonClient, kubeClient, ns)
 	if err == nil && client == nil {
-		return nil, nil, nil, nil, nil, "", fmt.Errorf("no metapipeline client created")
+		return nil, nil, nil, fmt.Errorf("no metapipeline client created")
 	}
-	return client, tektonClient, jxClient, kubeClient, lhClient, ns, err
+	return client, jxClient, lhClient, err
 }
diff --git a/pkg/jx/launcher.go b/pkg/jx/launcher.go
deleted file mode 100644
index e3e9c069..00000000
--- a/pkg/jx/launcher.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package jx
-
-import (
-	"os"
-	"strconv"
-
-	"github.com/jenkins-x/go-scm/scm"
-	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
-	"github.com/jenkins-x/jx/v2/pkg/tekton/metapipeline"
-	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
-	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
-	launcher2 "github.com/jenkins-x/lighthouse/pkg/launcher"
-	"github.com/jenkins-x/lighthouse/pkg/util"
-	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-)
-
-// launcher default launcher
-type launcher struct {
-	jxClient           jxclient.Interface
-	lhClient           clientset.Interface
-	metapipelineClient metapipeline.Client
-	namespace          string
-}
-
-// NewLauncher creates a new builder
-func NewLauncher() (launcher2.PipelineLauncher, error) {
-	factory := jxfactory.NewFactory()
-	mpClient, _, jxClient, _, lhClient, namespace, err := NewMetaPipelineClient(factory)
-	if err != nil {
-		return nil, errors.Wrapf(err, "couldn't get metapipeline client")
-	}
-	b := &launcher{
-		jxClient:           jxClient,
-		lhClient:           lhClient,
-		metapipelineClient: mpClient,
-		namespace:          namespace,
-	}
-	return b, nil
-}
-
-// Launch creates a pipeline
-// TODO: This should be moved somewhere else, probably, and needs some kind of unit testing (apb)
-func (b *launcher) Launch(request *v1alpha1.LighthouseJob, repository scm.Repository) (*v1alpha1.LighthouseJob, error) {
-	spec := &request.Spec
-
-	name := repository.Name
-	owner := repository.Namespace
-	sourceURL := repository.Clone
-
-	pullRefData := b.getPullRefs(sourceURL, spec)
-	pullRefs := ""
-	if len(spec.Refs.Pulls) > 0 {
-		pullRefs = pullRefData.String()
-	}
-
-	branch := spec.GetBranch()
-	if branch == "" {
-		branch = repository.Branch
-	}
-	if branch == "" {
-		branch = "master"
-	}
-	if pullRefs == "" {
-		pullRefs = branch + ":"
-	}
-
-	job := spec.Job
-	var kind metapipeline.PipelineKind
-	if len(spec.Refs.Pulls) > 0 {
-		kind = metapipeline.PullRequestPipeline
-	} else {
-		kind = metapipeline.ReleasePipeline
-	}
-
-	l := logrus.WithFields(logrus.Fields(map[string]interface{}{
-		"Owner":     owner,
-		"Name":      name,
-		"SourceURL": sourceURL,
-		"Branch":    branch,
-		"PullRefs":  pullRefs,
-		"Job":       job,
-	}))
-	l.Info("about to start Jenkinx X meta pipeline")
-
-	sa := os.Getenv("JX_SERVICE_ACCOUNT")
-	if sa == "" {
-		sa = "tekton-bot"
-	}
-
-	pipelineCreateParam := metapipeline.PipelineCreateParam{
-		PullRef:      pullRefData,
-		PipelineKind: kind,
-		Context:      spec.Context,
-		// No equivalent to https://github.com/jenkins-x/jx/blob/bb59278c2707e0e99b3c24be926745c324824388/pkg/cmd/controller/pipeline/pipelinerunner_controller.go#L236
-		//   for getting environment variables from the prow job here, so far as I can tell (abayer)
-		// Also not finding an equivalent to labels from the PipelineRunRequest
-		ServiceAccount: sa,
-		// I believe we can use an empty string default image?
-		DefaultImage: os.Getenv("JX_DEFAULT_IMAGE"),
-		EnvVariables: spec.GetEnvVars(),
-	}
-
-	activityKey, tektonCRDs, err := b.metapipelineClient.Create(pipelineCreateParam)
-	if err != nil {
-		return nil, errors.Wrap(err, "unable to create Tekton CRDs")
-	}
-
-	// Add the build number from the activity key to the labels on the job
-	request.Labels[util.BuildNumLabel] = activityKey.Build
-
-	appliedJob, err := b.lhClient.LighthouseV1alpha1().LighthouseJobs(b.namespace).Create(request)
-	if err != nil {
-		return nil, errors.Wrap(err, "unable to apply LighthouseJob")
-	}
-
-	// Set status on the job
-	appliedJob.Status = v1alpha1.LighthouseJobStatus{
-		State:        v1alpha1.PendingState,
-		ActivityName: util.ToValidName(activityKey.Name),
-		StartTime:    metav1.Now(),
-	}
-	fullyCreatedJob, err := b.lhClient.LighthouseV1alpha1().LighthouseJobs(b.namespace).UpdateStatus(appliedJob)
-	if err != nil {
-		return nil, errors.Wrapf(err, "unable to set status on LighthouseJob %s", appliedJob.Name)
-	}
-
-	err = b.metapipelineClient.Apply(activityKey, tektonCRDs)
-	if err != nil {
-		return nil, errors.Wrap(err, "unable to apply Tekton CRDs")
-	}
-	return fullyCreatedJob, nil
-}
-
-func (b *launcher) getPullRefs(sourceURL string, spec *v1alpha1.LighthouseJobSpec) metapipeline.PullRef {
-	var pullRef metapipeline.PullRef
-	if len(spec.Refs.Pulls) > 0 {
-		var prs []metapipeline.PullRequestRef
-		for _, pull := range spec.Refs.Pulls {
-			prs = append(prs, metapipeline.PullRequestRef{ID: strconv.Itoa(pull.Number), MergeSHA: pull.SHA})
-		}
-
-		pullRef = metapipeline.NewPullRefWithPullRequest(sourceURL, spec.Refs.BaseRef, spec.Refs.BaseSHA, prs...)
-	} else {
-		pullRef = metapipeline.NewPullRef(sourceURL, spec.Refs.BaseRef, spec.Refs.BaseSHA)
-	}
-
-	return pullRef
-}
diff --git a/pkg/keeper/githubapp/factory.go b/pkg/keeper/githubapp/factory.go
index 07a379ad..d4237a7a 100644
--- a/pkg/keeper/githubapp/factory.go
+++ b/pkg/keeper/githubapp/factory.go
@@ -5,8 +5,8 @@ import (
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/git"
-	"github.com/jenkins-x/lighthouse/pkg/jx"
 	"github.com/jenkins-x/lighthouse/pkg/keeper"
+	"github.com/jenkins-x/lighthouse/pkg/launcher"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/pkg/errors"
@@ -14,10 +14,10 @@ import (
 
 // NewKeeperController creates a new controller; either regular or a GitHub App flavour
 // depending on the $GITHUB_APP_SECRET_DIR environment variable
-func NewKeeperController(configAgent *config.Agent, botName string, gitKind string, gitToken string, serverURL string, maxRecordsPerPool int, historyURI string, statusURI string) (keeper.Controller, error) {
+func NewKeeperController(configAgent *config.Agent, botName string, gitKind string, gitToken string, serverURL string, maxRecordsPerPool int, historyURI string, statusURI string, ns string) (keeper.Controller, error) {
 	githubAppSecretDir := util.GetGitHubAppSecretDir()
 	if githubAppSecretDir != "" {
-		return NewGitHubAppKeeperController(githubAppSecretDir, configAgent, botName, gitKind, maxRecordsPerPool, historyURI, statusURI)
+		return NewGitHubAppKeeperController(githubAppSecretDir, configAgent, botName, gitKind, maxRecordsPerPool, historyURI, statusURI, ns)
 	}
 
 	scmClient, err := factory.NewClient(gitKind, serverURL, "")
@@ -34,14 +34,11 @@ func NewKeeperController(configAgent *config.Agent, botName string, gitKind stri
 		return []byte(gitToken)
 	})
 
-	tektonClient, _, _, lhClient, ns, err := clients.GetClientsAndNamespace(nil)
+	tektonClient, _, lhClient, _, err := clients.GetAPIClients()
 	if err != nil {
 		return nil, errors.Wrap(err, "Error creating kubernetes resource clients.")
 	}
-	launcherClient, err := jx.NewLauncher()
-	if err != nil {
-		return nil, errors.Wrap(err, "Error getting PipelineLauncher client.")
-	}
+	launcherClient := launcher.NewLauncher(lhClient, ns)
 	c, err := keeper.NewController(gitproviderClient, gitproviderClient, launcherClient, tektonClient, lhClient, ns, configAgent.Config, gitClient, maxRecordsPerPool, historyURI, statusURI, nil)
 	return c, err
 }
diff --git a/pkg/keeper/githubapp/github_app_ctrl.go b/pkg/keeper/githubapp/github_app_ctrl.go
index 0da1428b..2370a26f 100644
--- a/pkg/keeper/githubapp/github_app_ctrl.go
+++ b/pkg/keeper/githubapp/github_app_ctrl.go
@@ -5,16 +5,16 @@ import (
 	"net/http"
 	"sync"
 
+	"github.com/hashicorp/go-multierror"
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/go-scm/scm/factory"
 	"github.com/jenkins-x/go-scm/scm/transport"
-	"github.com/jenkins-x/jx/v2/pkg/errorutil"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/git"
-	"github.com/jenkins-x/lighthouse/pkg/jx"
 	"github.com/jenkins-x/lighthouse/pkg/keeper"
 	"github.com/jenkins-x/lighthouse/pkg/keeper/history"
+	"github.com/jenkins-x/lighthouse/pkg/launcher"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/pkg/errors"
@@ -32,13 +32,14 @@ type gitHubAppKeeperController struct {
 	maxRecordsPerPool  int
 	historyURI         string
 	statusURI          string
+	ns                 string
 	logger             *logrus.Entry
 	m                  sync.Mutex
 }
 
 // NewGitHubAppKeeperController creates a GitHub App style controller which needs to process each github owner
 // using a separate git provider client due to the way GitHub App tokens work
-func NewGitHubAppKeeperController(githubAppSecretDir string, configAgent *config.Agent, botName string, gitKind string, maxRecordsPerPool int, historyURI string, statusURI string) (keeper.Controller, error) {
+func NewGitHubAppKeeperController(githubAppSecretDir string, configAgent *config.Agent, botName string, gitKind string, maxRecordsPerPool int, historyURI string, statusURI string, ns string) (keeper.Controller, error) {
 
 	gitServer := util.GithubServer
 	return &gitHubAppKeeperController{
@@ -50,6 +51,7 @@ func NewGitHubAppKeeperController(githubAppSecretDir string, configAgent *config
 		maxRecordsPerPool: maxRecordsPerPool,
 		historyURI:        historyURI,
 		statusURI:         statusURI,
+		ns:                ns,
 		logger:            logrus.NewEntry(logrus.StandardLogger()),
 	}, nil
 
@@ -62,14 +64,14 @@ func (g *gitHubAppKeeperController) Sync() error {
 		return err
 	}
 	// now lets sync them all
-	errs := []error{}
+	var errs *multierror.Error
 	for _, c := range g.controllers {
 		err := c.Sync()
 		if err != nil {
-			errs = append(errs, err)
+			errs = multierror.Append(errs, err)
 		}
 	}
-	return errorutil.CombineErrors(errs...)
+	return errs.ErrorOrNil()
 }
 
 func (g *gitHubAppKeeperController) Shutdown() {
@@ -119,7 +121,7 @@ func (g *gitHubAppKeeperController) createOwnerControllers() error {
 	g.Shutdown()
 	g.controllers = nil
 
-	errs := []error{}
+	var errs *multierror.Error
 
 	cfg := g.configAgent.Config()
 	if cfg == nil {
@@ -137,12 +139,12 @@ func (g *gitHubAppKeeperController) createOwnerControllers() error {
 
 		c, err := g.createOwnerController(owner, configGetter)
 		if err != nil {
-			errs = append(errs, err)
+			errs = multierror.Append(errs, err)
 		} else {
 			g.controllers = append(g.controllers, c)
 		}
 	}
-	return errorutil.CombineErrors(errs...)
+	return errs.ErrorOrNil()
 }
 
 func (g *gitHubAppKeeperController) createOwnerController(owner string, configGetter config.Getter) (keeper.Controller, error) {
@@ -167,15 +169,12 @@ func (g *gitHubAppKeeperController) createOwnerController(owner string, configGe
 	gitClient.SetCredentials(util.GitHubAppGitRemoteUsername, func() []byte {
 		return []byte(token)
 	})
-	tektonClient, _, _, lhClient, ns, err := clients.GetClientsAndNamespace(nil)
+	tektonClient, _, lhClient, _, err := clients.GetAPIClients()
 	if err != nil {
 		return nil, errors.Wrap(err, "Error creating kubernetes resource clients.")
 	}
-	launcherClient, err := jx.NewLauncher()
-	if err != nil {
-		return nil, errors.Wrap(err, "Error getting PipelineLauncher client.")
-	}
-	c, err := keeper.NewController(gitproviderClient, gitproviderClient, launcherClient, tektonClient, lhClient, ns, configGetter, gitClient, g.maxRecordsPerPool, g.historyURI, g.statusURI, nil)
+	launcherClient := launcher.NewLauncher(lhClient, g.ns)
+	c, err := keeper.NewController(gitproviderClient, gitproviderClient, launcherClient, tektonClient, lhClient, g.ns, configGetter, gitClient, g.maxRecordsPerPool, g.historyURI, g.statusURI, nil)
 	return c, err
 }
 
diff --git a/pkg/keeper/keeper.go b/pkg/keeper/keeper.go
index c983e525..db983c13 100644
--- a/pkg/keeper/keeper.go
+++ b/pkg/keeper/keeper.go
@@ -54,7 +54,7 @@ import (
 var sleep = time.Sleep
 
 type launcher interface {
-	Launch(*v1alpha1.LighthouseJob, scm.Repository) (*v1alpha1.LighthouseJob, error)
+	Launch(*v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error)
 }
 
 type scmProviderClient interface {
@@ -1065,10 +1065,11 @@ func tryMerge(mergeFunc func() error) (bool, error) {
 
 func (c *DefaultController) trigger(sp subpool, presubmits map[int][]config.Presubmit, prs []PullRequest) error {
 	refs := v1alpha1.Refs{
-		Org:     sp.org,
-		Repo:    sp.repo,
-		BaseRef: sp.branch,
-		BaseSHA: sp.sha,
+		Org:      sp.org,
+		Repo:     sp.repo,
+		BaseRef:  sp.branch,
+		BaseSHA:  sp.sha,
+		CloneURI: sp.cloneURL,
 	}
 	for _, pr := range prs {
 		refs.Pulls = append(
@@ -1099,21 +1100,7 @@ func (c *DefaultController) trigger(sp subpool, presubmits map[int][]config.Pres
 			}
 			pj := jobutil.NewLighthouseJob(spec, ps.Labels, ps.Annotations)
 			start := time.Now()
-			cloneURL := string(pr.Repository.URL)
-			if cloneURL == "" {
-				c.logger.WithField("owner", refs.Org).WithField("repository", refs.Repo).Warnf("no GitURL returned")
-				// TODO load URL via scm client?
-			}
-			if !strings.HasPrefix(cloneURL, ".git") {
-				cloneURL = cloneURL + ".git"
-			}
-			repo := scm.Repository{
-				Name:      string(pr.Repository.Name),
-				Namespace: string(pr.Repository.Owner.Login),
-				Branch:    string(pr.BaseRef.Name),
-				Clone:     cloneURL,
-			}
-			if _, err := c.launcherClient.Launch(&pj, repo); err != nil {
+			if _, err := c.launcherClient.Launch(&pj); err != nil {
 				c.logger.WithField("duration", time.Since(start).String()).Debug("Failed to create pipeline on the cluster.")
 				return fmt.Errorf("failed to create a pipeline for job: %q, PRs: %v: %v", spec.Job, prNumbers(prs), err)
 			}
@@ -1272,9 +1259,9 @@ func (c *DefaultController) presubmitsByPull(sp *subpool) (map[int][]config.Pres
 }
 
 func (c *DefaultController) syncSubpool(sp subpool, blocks []blockers.Blocker) (Pool, error) {
-	sp.log.Infof("Syncing subpool: %d PRs, %d PJs.", len(sp.prs), len(sp.pjs))
-	successes, pendings, missings, missingSerialTests := accumulate(sp.presubmits, sp.prs, sp.pjs, sp.log)
-	batchMerge, batchPending := accumulateBatch(sp.presubmits, sp.prs, sp.pjs, sp.log)
+	sp.log.Infof("Syncing subpool: %d PRs, %d PJs.", len(sp.prs), len(sp.ljs))
+	successes, pendings, missings, missingSerialTests := accumulate(sp.presubmits, sp.prs, sp.ljs, sp.log)
+	batchMerge, batchPending := accumulateBatch(sp.presubmits, sp.prs, sp.ljs, sp.log)
 	sp.log.WithFields(logrus.Fields{
 		"prs-passing":   prNumbers(successes),
 		"prs-pending":   prNumbers(pendings),
@@ -1366,16 +1353,17 @@ func sortPools(pools []Pool) {
 }
 
 type subpool struct {
-	log    *logrus.Entry
-	org    string
-	repo   string
-	branch string
+	log      *logrus.Entry
+	org      string
+	repo     string
+	branch   string
+	cloneURL string
 	// sha is the baseSHA for this subpool
 	sha string
 
-	// pjs contains all PipelineActivitys of type Presubmit or Batch
+	// ljs contains all LighthouseJobs of type Presubmit or Batch
 	// that have the same baseSHA as the subpool
-	pjs []v1alpha1.LighthouseJob
+	ljs []v1alpha1.LighthouseJob
 	prs []PullRequest
 
 	cc contextChecker
@@ -1397,6 +1385,13 @@ func (c *DefaultController) dividePool(pool map[string]PullRequest, pjs []v1alph
 		repo := string(pr.Repository.Name)
 		branch := string(pr.BaseRef.Name)
 		branchRef := string(pr.BaseRef.Prefix) + string(pr.BaseRef.Name)
+		cloneURL := string(pr.Repository.URL)
+		if cloneURL == "" {
+			return nil, errors.New("no clone URL specified for repository")
+		}
+		if !strings.HasSuffix(cloneURL, ".git") {
+			cloneURL = cloneURL + ".git"
+		}
 		fn := poolKey(org, repo, branch)
 		if sps[fn] == nil {
 			sha, err := c.spc.GetRef(org, repo, strings.TrimPrefix(branchRef, "refs/"))
@@ -1405,15 +1400,17 @@ func (c *DefaultController) dividePool(pool map[string]PullRequest, pjs []v1alph
 			}
 			sps[fn] = &subpool{
 				log: c.logger.WithFields(logrus.Fields{
-					"org":      org,
-					"repo":     repo,
-					"branch":   branch,
-					"base-sha": sha,
+					"org":       org,
+					"repo":      repo,
+					"branch":    branch,
+					"base-sha":  sha,
+					"clone-url": cloneURL,
 				}),
-				org:    org,
-				repo:   repo,
-				branch: branch,
-				sha:    sha,
+				org:      org,
+				repo:     repo,
+				branch:   branch,
+				sha:      sha,
+				cloneURL: cloneURL,
 			}
 		}
 		sps[fn].prs = append(sps[fn].prs, pr)
@@ -1426,7 +1423,7 @@ func (c *DefaultController) dividePool(pool map[string]PullRequest, pjs []v1alph
 		if sps[fn] == nil || pj.Spec.Refs.BaseSHA != sps[fn].sha {
 			continue
 		}
-		sps[fn].pjs = append(sps[fn].pjs, pj)
+		sps[fn].ljs = append(sps[fn].ljs, pj)
 	}
 	return sps, nil
 }
@@ -1755,7 +1752,7 @@ func scmRepoToGraphQLRepo(scmRepo *scm.Repository) Repository {
 	return Repository{
 		Name:          githubql.String(scmRepo.Name),
 		NameWithOwner: githubql.String(scmRepo.FullName),
-		URL:           githubql.String(scmRepo.Link),
+		URL:           githubql.String(scmRepo.Clone),
 		Owner:         SCMUser{Login: githubql.String(scmRepo.Namespace)},
 	}
 }
diff --git a/pkg/launcher/fake/fake.go b/pkg/launcher/fake/fake.go
index 7d20b3e0..d91730ca 100644
--- a/pkg/launcher/fake/fake.go
+++ b/pkg/launcher/fake/fake.go
@@ -3,7 +3,6 @@ package fake
 import (
 	"errors"
 
-	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/launcher"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -27,7 +26,7 @@ func NewLauncher() *Launcher {
 }
 
 // Launch creates a launcher job
-func (p *Launcher) Launch(po *v1alpha1.LighthouseJob, repo scm.Repository) (*v1alpha1.LighthouseJob, error) {
+func (p *Launcher) Launch(po *v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error) {
 	if p.FailJobs.Has(po.Spec.Job) {
 		return po, errors.New("failed to create job")
 	}
diff --git a/pkg/launcher/interface.go b/pkg/launcher/interface.go
index 44781dee..04462842 100644
--- a/pkg/launcher/interface.go
+++ b/pkg/launcher/interface.go
@@ -1,12 +1,11 @@
 package launcher
 
 import (
-	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 )
 
 // PipelineLauncher the interface is the service which creates Pipelines
 type PipelineLauncher interface {
 	// Launch creates new pipelines
-	Launch(*v1alpha1.LighthouseJob, scm.Repository) (*v1alpha1.LighthouseJob, error)
+	Launch(*v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error)
 }
diff --git a/pkg/launcher/launcher.go b/pkg/launcher/launcher.go
new file mode 100644
index 00000000..32dd03e9
--- /dev/null
+++ b/pkg/launcher/launcher.go
@@ -0,0 +1,41 @@
+package launcher
+
+import (
+	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
+	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
+	"github.com/pkg/errors"
+)
+
+// launcherImpl default launcher
+type launcherImpl struct {
+	lhClient  clientset.Interface
+	namespace string
+}
+
+// NewLauncher creates a new builder
+func NewLauncher(lhClient clientset.Interface, ns string) PipelineLauncher {
+	b := &launcherImpl{
+		lhClient:  lhClient,
+		namespace: ns,
+	}
+	return b
+}
+
+// Launch creates a pipeline
+// TODO: This should be moved somewhere else, probably, and needs some kind of unit testing (apb)
+func (b *launcherImpl) Launch(request *v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error) {
+	appliedJob, err := b.lhClient.LighthouseV1alpha1().LighthouseJobs(b.namespace).Create(request)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to apply LighthouseJob")
+	}
+	// Set status on the job
+	appliedJob.Status = v1alpha1.LighthouseJobStatus{
+		State: v1alpha1.TriggeredState,
+	}
+	fullyCreatedJob, err := b.lhClient.LighthouseV1alpha1().LighthouseJobs(b.namespace).UpdateStatus(appliedJob)
+	if err != nil {
+		return nil, errors.Wrapf(err, "unable to set status on LighthouseJob %s", appliedJob.Name)
+	}
+
+	return fullyCreatedJob, nil
+}
diff --git a/pkg/plugins/override/override.go b/pkg/plugins/override/override.go
index 872a4b1c..d23e36d9 100644
--- a/pkg/plugins/override/override.go
+++ b/pkg/plugins/override/override.go
@@ -23,7 +23,6 @@ import (
 	"strings"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	lighthouseclient "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned/typed/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/jobutil"
@@ -63,10 +62,9 @@ type overrideClient interface {
 }
 
 type client struct {
-	spc           scmProviderClient
-	jc            config.JobConfig
-	clientFactory jxfactory.Factory
-	lhClient      lighthouseclient.LighthouseJobInterface
+	spc      scmProviderClient
+	jc       config.JobConfig
+	lhClient lighthouseclient.LighthouseJobInterface
 }
 
 func (c client) createOverrideJob(job *v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error) {
@@ -143,14 +141,13 @@ func helpProvider(config *plugins.Configuration, enabledRepos []string) (*plugin
 
 func handleGenericComment(pc plugins.Agent, e scmprovider.GenericCommentEvent) error {
 	c := client{
-		spc:           pc.SCMProviderClient,
-		clientFactory: pc.ClientFactory,
-		lhClient:      pc.LighthouseClient,
+		spc:      pc.SCMProviderClient,
+		lhClient: pc.LighthouseClient,
 	}
 	if pc.Config != nil {
 		c.jc = pc.Config.JobConfig
 	}
-	return handle(pc.ClientFactory, c, pc.Logger, &e)
+	return handle(c, pc.Logger, &e)
 }
 
 func authorized(spc scmProviderClient, log *logrus.Entry, org, repo, user string) bool {
@@ -181,7 +178,7 @@ func formatList(list []string) string {
 	return strings.Join(lines, "\n")
 }
 
-func handle(clientFactory jxfactory.Factory, oc overrideClient, log *logrus.Entry, e *scmprovider.GenericCommentEvent) error {
+func handle(oc overrideClient, log *logrus.Entry, e *scmprovider.GenericCommentEvent) error {
 
 	if !e.IsPR || e.IssueState != "open" || e.Action != scm.ActionCreate {
 		return nil
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index f068d3c1..7fd2a8a0 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -27,7 +27,6 @@ import (
 	"time"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	lighthouseclient "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned/typed/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/commentpruner"
@@ -136,7 +135,6 @@ func RegisterGenericCommentHandler(name string, fn GenericCommentHandler, help H
 
 // Agent may be used concurrently, so each entry must be thread-safe.
 type Agent struct {
-	ClientFactory     jxfactory.Factory
 	SCMProviderClient *scmprovider.Client
 	LauncherClient    launcher.PipelineLauncher
 	GitClient         git2.Client
@@ -162,12 +160,11 @@ type Agent struct {
 }
 
 // NewAgent bootstraps a new Agent struct from the passed dependencies.
-func NewAgent(clientFactory jxfactory.Factory, configAgent *config.Agent, pluginConfigAgent *ConfigAgent, clientAgent *ClientAgent, serverURL *url.URL, logger *logrus.Entry) Agent {
+func NewAgent(configAgent *config.Agent, pluginConfigAgent *ConfigAgent, clientAgent *ClientAgent, serverURL *url.URL, logger *logrus.Entry) Agent {
 	prowConfig := configAgent.Config()
 	pluginConfig := pluginConfigAgent.Config()
 	scmClient := scmprovider.ToClient(clientAgent.SCMProviderClient, clientAgent.BotName)
 	return Agent{
-		ClientFactory:     clientFactory,
 		SCMProviderClient: scmClient,
 		GitClient:         clientAgent.GitClient,
 		LauncherClient:    clientAgent.LauncherClient,
diff --git a/pkg/plugins/trigger/push.go b/pkg/plugins/trigger/push.go
index 962d248d..399690cf 100644
--- a/pkg/plugins/trigger/push.go
+++ b/pkg/plugins/trigger/push.go
@@ -54,6 +54,7 @@ func createRefs(pe *scm.PushHook) v1alpha1.Refs {
 		BaseRef:  branch,
 		BaseSHA:  pe.After,
 		BaseLink: pe.Compare,
+		CloneURI: pe.Repo.Clone,
 	}
 }
 
@@ -77,7 +78,7 @@ func handlePE(c Client, pe scm.PushHook) error {
 		labels[scmprovider.EventGUID] = pe.GUID
 		pj := jobutil.NewLighthouseJob(jobutil.PostsubmitSpec(j, refs), labels, j.Annotations)
 		c.Logger.WithFields(jobutil.LighthouseJobFields(&pj)).Info("Creating a new LighthouseJob.")
-		if _, err := c.LauncherClient.Launch(&pj, pe.Repository()); err != nil {
+		if _, err := c.LauncherClient.Launch(&pj); err != nil {
 			return err
 		}
 	}
diff --git a/pkg/plugins/trigger/trigger.go b/pkg/plugins/trigger/trigger.go
index 2d7a825d..11a25cff 100644
--- a/pkg/plugins/trigger/trigger.go
+++ b/pkg/plugins/trigger/trigger.go
@@ -111,7 +111,7 @@ type scmProviderClient interface {
 }
 
 type launcher interface {
-	Launch(*v1alpha1.LighthouseJob, scm.Repository) (*v1alpha1.LighthouseJob, error)
+	Launch(*v1alpha1.LighthouseJob) (*v1alpha1.LighthouseJob, error)
 }
 
 // Client holds the necessary structures to work with prow via logging, github, kubernetes and its configuration.
@@ -257,7 +257,7 @@ func runRequested(c Client, pr *scm.PullRequest, requestedJobs []config.Presubmi
 		c.Logger.Infof("Starting %s build.", job.Name)
 		pj := jobutil.NewPresubmit(pr, baseSHA, job, eventGUID)
 		c.Logger.WithFields(jobutil.LighthouseJobFields(&pj)).Info("Creating a new LighthouseJob.")
-		if _, err := c.LauncherClient.Launch(&pj, pr.Repository()); err != nil {
+		if _, err := c.LauncherClient.Launch(&pj); err != nil {
 			c.Logger.WithError(err).Error("Failed to create LighthouseJob.")
 			errors = append(errors, err)
 			if _, statusErr := c.SCMProviderClient.CreateStatus(pr.Base.Repo.Namespace, pr.Base.Repo.Name, pr.Head.Ref, failedStatusForMetapipelineCreation(job.Context, err)); statusErr != nil {
diff --git a/pkg/record/record.go b/pkg/record/record.go
deleted file mode 100644
index 503d81b3..00000000
--- a/pkg/record/record.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package record
-
-import (
-	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-)
-
-// ActivityRecord is a struct for reporting information on a pipeline, build, or other activity triggered by Lighthouse
-type ActivityRecord struct {
-	Name            string                 `json:"name"`
-	Owner           string                 `json:"owner,omitempty"`
-	Repo            string                 `json:"repo,omitempty"`
-	Branch          string                 `json:"branch,omitempty"`
-	BuildIdentifier string                 `json:"buildId,omitempty"`
-	Context         string                 `json:"context,omitempty"`
-	GitURL          string                 `json:"gitURL,omitempty"`
-	LogURL          string                 `json:"logURL,omitempty"`
-	LinkURL         string                 `json:"linkURL,omitempty"`
-	Status          v1alpha1.PipelineState `json:"status,omitempty"`
-	BaseSHA         string                 `json:"baseSHA,omitempty"`
-	LastCommitSHA   string                 `json:"lastCommitSHA,omitempty"`
-	StartTime       *metav1.Time           `json:"startTime,omitempty"`
-	CompletionTime  *metav1.Time           `json:"completionTime,omitempty"`
-	Stages          []*ActivityStageOrStep `json:"stages,omitempty"`
-	Steps           []*ActivityStageOrStep `json:"steps,omitEmpty"`
-}
-
-// ActivityStageOrStep represents a stage of an activity
-type ActivityStageOrStep struct {
-	Name           string                 `json:"name"`
-	Status         v1alpha1.PipelineState `json:"status"`
-	StartTime      *metav1.Time           `json:"startTime,omitempty"`
-	CompletionTime *metav1.Time           `json:"completionTime,omitempty"`
-	Stages         []*ActivityStageOrStep `json:"stages,omitempty"`
-	Steps          []*ActivityStageOrStep `json:"steps,omitempty"`
-}
-
-// RunningStages returns the list of stages currently running
-func (a *ActivityRecord) RunningStages() []string {
-	var running []string
-
-	for _, stage := range a.Stages {
-		if stage.Status == v1alpha1.RunningState {
-			running = append(running, stage.Name)
-		}
-	}
-	return running
-}
diff --git a/pkg/util/externalplugins.go b/pkg/util/externalplugins.go
index 968ae14a..8626c72c 100644
--- a/pkg/util/externalplugins.go
+++ b/pkg/util/externalplugins.go
@@ -16,14 +16,14 @@ import (
 
 	goscmhmac "github.com/jenkins-x/go-scm/pkg/hmac"
 	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
-	"github.com/jenkins-x/lighthouse/pkg/record"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
 
 // ParseExternalPluginEvent parses a webhook relayed to an external plugin
-func ParseExternalPluginEvent(req *http.Request, secretToken string) (scm.Webhook, *record.ActivityRecord, error) {
+func ParseExternalPluginEvent(req *http.Request, secretToken string) (scm.Webhook, *v1alpha1.ActivityRecord, error) {
 	data, err := ioutil.ReadAll(
 		io.LimitReader(req.Body, 10000000),
 	)
@@ -59,7 +59,7 @@ func ParseExternalPluginEvent(req *http.Request, secretToken string) (scm.Webhoo
 		}
 		return hook, nil, nil
 	case LighthousePayloadTypeActivity:
-		ar := new(record.ActivityRecord)
+		ar := new(v1alpha1.ActivityRecord)
 		err := json.Unmarshal(data, ar)
 		if err != nil {
 			return nil, nil, errors.Wrap(err, "parsing activity")
@@ -70,8 +70,8 @@ func ParseExternalPluginEvent(req *http.Request, secretToken string) (scm.Webhoo
 	}
 }
 
-func parseActivity(l *logrus.Entry, req *http.Request, data []byte) (*record.ActivityRecord, error) {
-	ar := new(record.ActivityRecord)
+func parseActivity(l *logrus.Entry, req *http.Request, data []byte) (*v1alpha1.ActivityRecord, error) {
+	ar := new(v1alpha1.ActivityRecord)
 	err := json.Unmarshal(data, ar)
 	return ar, err
 }
@@ -190,7 +190,7 @@ func callExternalPlugins(l *logrus.Entry, externalPlugins []plugins.ExternalPlug
 }
 
 // CallExternalPluginsWithActivityRecord dispatches the provided activity record to the external plugins.
-func CallExternalPluginsWithActivityRecord(l *logrus.Entry, externalPlugins []plugins.ExternalPlugin, activity *record.ActivityRecord, hmacToken string, wg *sync.WaitGroup) {
+func CallExternalPluginsWithActivityRecord(l *logrus.Entry, externalPlugins []plugins.ExternalPlugin, activity *v1alpha1.ActivityRecord, hmacToken string, wg *sync.WaitGroup) {
 	headers := http.Header{}
 	headers.Set(LighthousePayloadTypeHeader, LighthousePayloadTypeActivity)
 	payload, err := json.Marshal(activity)
diff --git a/pkg/util/files.go b/pkg/util/files.go
index c296f6ca..84a470d7 100644
--- a/pkg/util/files.go
+++ b/pkg/util/files.go
@@ -1,7 +1,9 @@
 package util
 
 import (
+	"bytes"
 	"os"
+	"strings"
 
 	"github.com/pkg/errors"
 )
@@ -28,3 +30,21 @@ func DirExists(path string) (bool, error) {
 	}
 	return false, err
 }
+
+// URLJoin joins the given paths so that there is only ever one '/' character between the paths
+func URLJoin(paths ...string) string {
+	var buffer bytes.Buffer
+	last := len(paths) - 1
+	for i, path := range paths {
+		p := path
+		if i > 0 {
+			buffer.WriteString("/")
+			p = strings.TrimPrefix(p, "/")
+		}
+		if i < last {
+			p = strings.TrimSuffix(p, "/")
+		}
+		buffer.WriteString(p)
+	}
+	return buffer.String()
+}
diff --git a/pkg/util/owner_tokens_dir.go b/pkg/util/owner_tokens_dir.go
index 8a0563cf..d70540b3 100644
--- a/pkg/util/owner_tokens_dir.go
+++ b/pkg/util/owner_tokens_dir.go
@@ -5,7 +5,6 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/jenkins-x/jx/v2/pkg/util"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -24,7 +23,7 @@ func NewOwnerTokensDir(gitServer, dir string) *OwnerTokensDir {
 // FindToken finds the token for the given owner
 func (o *OwnerTokensDir) FindToken(owner string) (string, error) {
 	dir := o.dir
-	ownerURL := util.UrlJoin(o.gitServer, owner)
+	ownerURL := URLJoin(o.gitServer, owner)
 	prefix := ownerURL + "="
 	files, err := ioutil.ReadDir(dir)
 	if err != nil {
diff --git a/pkg/webhook/events.go b/pkg/webhook/events.go
index 0efb4133..283d2798 100644
--- a/pkg/webhook/events.go
+++ b/pkg/webhook/events.go
@@ -22,7 +22,6 @@ import (
 	"sync"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
@@ -31,7 +30,6 @@ import (
 
 // Server keeps the information required to start a server
 type Server struct {
-	ClientFactory  jxfactory.Factory
 	ClientAgent    *plugins.ClientAgent
 	Plugins        *plugins.ConfigAgent
 	ConfigAgent    *config.Agent
@@ -59,7 +57,7 @@ func (s *Server) HandleIssueCommentEvent(l *logrus.Entry, ic scm.IssueCommentHoo
 		s.wg.Add(1)
 		go func(p string, h plugins.IssueCommentHandler) {
 			defer s.wg.Done()
-			agent := plugins.NewAgent(s.ClientFactory, s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
+			agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 			agent.InitializeCommentPruner(
 				ic.Repo.Namespace,
 				ic.Repo.Name,
@@ -127,7 +125,7 @@ func (s *Server) handleGenericComment(l *logrus.Entry, ce *scmprovider.GenericCo
 		s.wg.Add(1)
 		go func(p string, h plugins.GenericCommentHandler) {
 			defer s.wg.Done()
-			agent := plugins.NewAgent(s.ClientFactory, s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
+			agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 			agent.InitializeCommentPruner(
 				ce.Repo.Namespace,
 				ce.Repo.Name,
@@ -156,7 +154,7 @@ func (s *Server) HandlePushEvent(l *logrus.Entry, pe *scm.PushHook) {
 		c++
 		go func(p string, h plugins.PushEventHandler) {
 			defer s.wg.Done()
-			agent := plugins.NewAgent(s.ClientFactory, s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
+			agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 			if err := h(agent, *pe); err != nil {
 				agent.Logger.WithError(err).Error("Error handling PushEvent.")
 			}
@@ -186,7 +184,7 @@ func (s *Server) HandlePullRequestEvent(l *logrus.Entry, pr *scm.PullRequestHook
 		c++
 		go func(p string, h plugins.PullRequestHandler) {
 			defer s.wg.Done()
-			agent := plugins.NewAgent(s.ClientFactory, s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
+			agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 			agent.InitializeCommentPruner(
 				pr.Repo.Namespace,
 				pr.Repo.Name,
@@ -242,7 +240,7 @@ func (s *Server) HandleReviewEvent(l *logrus.Entry, re scm.ReviewHook) {
 		s.wg.Add(1)
 		go func(p string, h plugins.ReviewEventHandler) {
 			defer s.wg.Done()
-			agent := plugins.NewAgent(s.ClientFactory, s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
+			agent := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", p))
 			agent.InitializeCommentPruner(
 				re.Repo.Namespace,
 				re.Repo.Name,
diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
index 405eee50..5fde600e 100644
--- a/pkg/webhook/webhook.go
+++ b/pkg/webhook/webhook.go
@@ -14,12 +14,10 @@ import (
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/go-scm/scm/factory"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/cmd/helper"
 	"github.com/jenkins-x/lighthouse/pkg/git"
-	"github.com/jenkins-x/lighthouse/pkg/jx"
 	"github.com/jenkins-x/lighthouse/pkg/launcher"
 	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
 	"github.com/jenkins-x/lighthouse/pkg/metrics"
@@ -47,7 +45,6 @@ type Options struct {
 	Port        int
 	JSONLog     bool
 
-	factory          jxfactory.Factory
 	namespace        string
 	pluginFilename   string
 	configFilename   string
@@ -81,29 +78,17 @@ func NewCmdWebhook() *cobra.Command {
 	cmd.Flags().StringVar(&options.pluginFilename, "plugin-file", "", "Path to the plugins.yaml file. If not specified it is loaded from the 'plugins' ConfigMap")
 	cmd.Flags().StringVar(&options.configFilename, "config-file", "", "Path to the config.yaml file. If not specified it is loaded from the 'config' ConfigMap")
 	cmd.Flags().StringVar(&options.botName, "bot-name", "", "The name of the bot user to run as. Defaults to $GIT_USER if not specified.")
-
+	cmd.Flags().StringVar(&options.namespace, "namespace", "", "The namespace to listen in")
 	return cmd
 }
 
-// NewWebhook creates a new webhook handler
-func NewWebhook(factory jxfactory.Factory, server *Server) *Options {
-	return &Options{
-		factory: factory,
-		server:  server,
-	}
-}
-
 // Run will implement this command
 func (o *Options) Run() error {
 	if o.JSONLog {
 		logrus.SetFormatter(logrusutil.CreateDefaultFormatter())
 	}
 
-	_, ns, err := o.GetFactory().CreateJXClient()
-	if err != nil {
-		return errors.Wrapf(err, "failed to create JX Client")
-	}
-	o.namespace = ns
+	var err error
 	o.server, err = o.createHookServer()
 	if err != nil {
 		return errors.Wrapf(err, "failed to create Hook Server")
@@ -128,12 +113,11 @@ func (o *Options) Run() error {
 
 	o.gitClient = gitClient
 
-	o.launcher, err = jx.NewLauncher()
+	_, _, lhClient, _, err := clients.GetAPIClients()
 	if err != nil {
-		err = errors.Wrapf(err, "failed to create PipelineLauncher client")
-		logrus.Errorf("%s", err.Error())
-		return err
+		return errors.Wrap(err, "Error creating kubernetes resource clients.")
 	}
+	o.launcher = launcher.NewLauncher(lhClient, o.namespace)
 	mux := http.NewServeMux()
 	mux.Handle(HealthPath, http.HandlerFunc(o.health))
 	mux.Handle(ReadyPath, http.HandlerFunc(o.ready))
@@ -246,7 +230,7 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request)
 			return
 		}
 	}
-	_, _, kubeClient, lhClient, _, err := clients.GetClientsAndNamespace(nil)
+	_, kubeClient, lhClient, _, err := clients.GetAPIClients()
 	if err != nil {
 		responseHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("500 Internal Server Error: %s", err.Error()))
 	}
@@ -421,14 +405,6 @@ func (o *Options) ProcessWebHook(l *logrus.Entry, webhook scm.Webhook) (*logrus.
 	return l, fmt.Sprintf("unknown hook %s", webhook.Kind()), nil
 }
 
-// GetFactory lazily creates a Factory if its not already created
-func (o *Options) GetFactory() jxfactory.Factory {
-	if o.factory == nil {
-		o.factory = jxfactory.NewFactory()
-	}
-	return o.factory
-}
-
 func (o *Options) hmacToken() string {
 	return os.Getenv("HMAC_TOKEN")
 }
@@ -506,8 +482,7 @@ func (o *Options) createHookServer() (*Server, error) {
 		}
 	}
 
-	clientFactory := o.GetFactory()
-	kubeClient, _, err := clientFactory.CreateKubeClient()
+	_, kubeClient, _, _, err := clients.GetAPIClients()
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to create Kube client")
 	}
@@ -550,11 +525,10 @@ func (o *Options) createHookServer() (*Server, error) {
 		return nil, errors.Wrapf(err, "failed to parse server URL %s", o.gitServerURL)
 	}
 	server := &Server{
-		ClientFactory: clientFactory,
-		ConfigAgent:   configAgent,
-		Plugins:       pluginAgent,
-		Metrics:       promMetrics,
-		ServerURL:     serverURL,
+		ConfigAgent: configAgent,
+		Plugins:     pluginAgent,
+		Metrics:     promMetrics,
+		ServerURL:   serverURL,
 		//TokenGenerator: secretAgent.GetTokenGenerator(o.webhookSecretFile),
 	}
 	return server, nil


Test patch:
diff --git a/pkg/jx/activity_test.go b/pkg/jx/activity_test.go
index 9f576132..389acad7 100644
--- a/pkg/jx/activity_test.go
+++ b/pkg/jx/activity_test.go
@@ -9,7 +9,6 @@ import (
 	v1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/jx"
-	"github.com/jenkins-x/lighthouse/pkg/record"
 	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/stretchr/testify/assert"
 	"sigs.k8s.io/yaml"
@@ -42,7 +41,7 @@ func TestConvertPipelineActivity(t *testing.T) {
 
 	expectedBytes, err := ioutil.ReadFile(fmt.Sprintf("%s/test_data/record.yaml", workDir))
 	assert.NoError(t, err)
-	expectedRecord := &record.ActivityRecord{}
+	expectedRecord := &v1alpha1.ActivityRecord{}
 	err = yaml.Unmarshal(expectedBytes, expectedRecord)
 
 	assert.Equal(t, expectedRecord, converted)
diff --git a/pkg/jx/controller_test.go b/pkg/jx/controller_test.go
new file mode 100644
index 00000000..dffd35f3
--- /dev/null
+++ b/pkg/jx/controller_test.go
@@ -0,0 +1,257 @@
+package jx
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	jxv1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1"
+	jxclient "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned"
+	jxfake "github.com/jenkins-x/jx-api/pkg/client/clientset/versioned/fake"
+	jxinformers "github.com/jenkins-x/jx-api/pkg/client/informers/externalversions"
+	"github.com/jenkins-x/jx/v2/pkg/gits"
+	"github.com/jenkins-x/jx/v2/pkg/kube"
+	"github.com/jenkins-x/jx/v2/pkg/tekton"
+	"github.com/jenkins-x/jx/v2/pkg/tekton/metapipeline"
+	"github.com/jenkins-x/jx/v2/pkg/util"
+	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
+	"github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned/fake"
+	lhinformers "github.com/jenkins-x/lighthouse/pkg/client/informers/externalversions"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/tools/cache"
+	"sigs.k8s.io/yaml"
+)
+
+type fakeMetapipelineClient struct {
+	jxClient jxclient.Interface
+
+	ns string
+}
+
+// Create just creates a PipelineActivity key
+func (f *fakeMetapipelineClient) Create(param metapipeline.PipelineCreateParam) (kube.PromoteStepActivityKey, tekton.CRDWrapper, error) {
+	gitInfo, err := gits.ParseGitURL(param.PullRef.SourceURL())
+	if err != nil {
+		return kube.PromoteStepActivityKey{}, tekton.CRDWrapper{}, errors.Wrap(err, fmt.Sprintf("unable to determine needed git info from the specified git url '%s'", param.PullRef.SourceURL()))
+	}
+
+	var branchIdentifier string
+	switch param.PipelineKind {
+	case metapipeline.ReleasePipeline:
+		// no pull requests to merge, taking base branch name as identifier
+		branchIdentifier = param.PullRef.BaseBranch()
+	case metapipeline.PullRequestPipeline:
+		if len(param.PullRef.PullRequests()) == 0 {
+			return kube.PromoteStepActivityKey{}, tekton.CRDWrapper{}, errors.New("pullrequest pipeline requested, but no pull requests specified")
+		}
+		branchIdentifier = fmt.Sprintf("PR-%s", param.PullRef.PullRequests()[0].ID)
+	default:
+		branchIdentifier = "unknown"
+	}
+
+	pr, _ := tekton.ParsePullRefs(param.PullRef.String())
+	pipelineActivity := tekton.GeneratePipelineActivity("1", branchIdentifier, gitInfo, param.Context, pr)
+
+	return *pipelineActivity, tekton.CRDWrapper{}, nil
+}
+
+// Apply just applies the PipelineActivity
+func (f *fakeMetapipelineClient) Apply(pipelineActivity kube.PromoteStepActivityKey, crds tekton.CRDWrapper) error {
+	_, _, err := pipelineActivity.GetOrCreate(f.jxClient, f.ns)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Close is a no-op here
+func (f *fakeMetapipelineClient) Close() error {
+	return nil
+}
+
+func TestSyncHandler(t *testing.T) {
+	testCases := []struct {
+		name       string
+		inputIsJob bool
+	}{
+		{
+			name:       "start-pullrequest",
+			inputIsJob: true,
+		},
+		{
+			name:       "update-job",
+			inputIsJob: false,
+		},
+		{
+			name:       "no-job-for-activity",
+			inputIsJob: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testData := path.Join("test_data", "controller", tc.name)
+			_, err := os.Stat(testData)
+			assert.NoError(t, err)
+
+			observedActivity, err := loadPipelineActivity(true, testData)
+			assert.NoError(t, err)
+			observedJob, err := loadLighthouseJob(true, testData)
+			assert.NoError(t, err)
+
+			expectedActivity, err := loadPipelineActivity(false, testData)
+			assert.NoError(t, err)
+			expectedJob, err := loadLighthouseJob(false, testData)
+			assert.NoError(t, err)
+
+			ns := "jx"
+			var jxObjects []runtime.Object
+			if observedActivity != nil {
+				jxObjects = append(jxObjects, observedActivity)
+			}
+			var lhObjects []runtime.Object
+			if observedJob != nil {
+				lhObjects = append(lhObjects, observedJob)
+			}
+
+			jxClient := jxfake.NewSimpleClientset(jxObjects...)
+			lhClient := fake.NewSimpleClientset(lhObjects...)
+
+			lhInformerFactory := lhinformers.NewSharedInformerFactoryWithOptions(lhClient, time.Minute*30, lhinformers.WithNamespace(ns))
+
+			lhInformer := lhInformerFactory.Lighthouse().V1alpha1().LighthouseJobs()
+			lhLister := lhInformer.Lister()
+
+			jxInformerFactory := jxinformers.NewSharedInformerFactoryWithOptions(jxClient, time.Minute*30, jxinformers.WithNamespace(ns))
+
+			jxInformer := jxInformerFactory.Jenkins().V1().PipelineActivities()
+			jxLister := jxInformer.Lister()
+
+			stopCh := context.Background().Done()
+
+			jxInformerFactory.Start(stopCh)
+			lhInformerFactory.Start(stopCh)
+
+			if ok := cache.WaitForCacheSync(stopCh, lhInformer.Informer().HasSynced, jxInformer.Informer().HasSynced); !ok {
+				t.Fatalf("caches never synced")
+			}
+			mpc := &fakeMetapipelineClient{
+				jxClient: jxClient,
+				ns:       ns,
+			}
+
+			controller := &Controller{
+				jxClient:       jxClient,
+				lhClient:       lhClient,
+				mpClient:       mpc,
+				activityLister: jxLister,
+				lhLister:       lhLister,
+				logger:         logrus.NewEntry(logrus.StandardLogger()).WithField("controller", controllerName),
+				ns:             ns,
+			}
+
+			var key string
+			if tc.inputIsJob {
+				if observedJob != nil {
+					key, err = toKey(observedJob)
+				} else {
+					t.Fatal("Expected an observed LighthouseJob but none loaded from observed-lhjob.yml")
+				}
+			} else {
+				if observedActivity != nil {
+					key, err = toKey(observedActivity)
+				} else {
+					t.Fatal("Expected an observed PipelineActivity but none was loaded from observed-activity.yml")
+				}
+			}
+			assert.NoError(t, err)
+			err = controller.syncHandler(key)
+			assert.NoError(t, err)
+
+			if expectedActivity != nil {
+				activities, err := jxClient.JenkinsV1().PipelineActivities(ns).List(metav1.ListOptions{})
+				assert.NoError(t, err)
+				assert.Len(t, activities.Items, 1)
+				updatedActivity := activities.Items[0].DeepCopy()
+				if d := cmp.Diff(expectedActivity, updatedActivity); d != "" {
+					t.Errorf("PipelineActivity did not match expected: %s", d)
+				}
+			}
+			if expectedJob != nil {
+				jobs, err := lhClient.LighthouseV1alpha1().LighthouseJobs(ns).List(metav1.ListOptions{})
+				assert.NoError(t, err)
+				assert.Len(t, jobs.Items, 1)
+				// Ignore status.starttime since that's always going to be different
+				updatedJob := jobs.Items[0].DeepCopy()
+				updatedJob.Status.StartTime = metav1.Time{}
+				if d := cmp.Diff(expectedJob, updatedJob); d != "" {
+					t.Errorf("LighthouseJob did not match expected: %s", d)
+				}
+			}
+		})
+	}
+}
+
+func loadLighthouseJob(isObserved bool, dir string) (*v1alpha1.LighthouseJob, error) {
+	var baseFn string
+	if isObserved {
+		baseFn = "observed-lhjob.yml"
+	} else {
+		baseFn = "expected-lhjob.yml"
+	}
+	fileName := filepath.Join(dir, baseFn)
+	exists, err := util.FileExists(fileName)
+	if err != nil {
+		return nil, err
+	}
+	if exists {
+		lhjob := &v1alpha1.LighthouseJob{}
+		data, err := ioutil.ReadFile(fileName)
+		if err != nil {
+			return nil, err
+		}
+		err = yaml.Unmarshal(data, lhjob)
+		if err != nil {
+			return nil, err
+		}
+		return lhjob, err
+	}
+	return nil, nil
+}
+
+func loadPipelineActivity(isObserved bool, dir string) (*jxv1.PipelineActivity, error) {
+	var baseFn string
+	if isObserved {
+		baseFn = "observed-activity.yml"
+	} else {
+		baseFn = "expected-activity.yml"
+	}
+	fileName := filepath.Join(dir, baseFn)
+	exists, err := util.FileExists(fileName)
+	if err != nil {
+		return nil, err
+	}
+	if exists {
+		activity := &jxv1.PipelineActivity{}
+		data, err := ioutil.ReadFile(fileName)
+		if err != nil {
+			return nil, err
+		}
+		err = yaml.Unmarshal(data, activity)
+		if err != nil {
+			return nil, err
+		}
+		return activity, err
+	}
+	return nil, nil
+}
diff --git a/pkg/jx/test_data/controller/no-job-for-activity/observed-activity.yml b/pkg/jx/test_data/controller/no-job-for-activity/observed-activity.yml
new file mode 100644
index 00000000..24c00916
--- /dev/null
+++ b/pkg/jx/test_data/controller/no-job-for-activity/observed-activity.yml
@@ -0,0 +1,127 @@
+apiVersion: jenkins.io/v1
+kind: PipelineActivity
+metadata:
+  creationTimestamp: "2020-06-22T11:53:16Z"
+  generation: 24
+  labels:
+    branch: PR-813
+    build: "1"
+    context: github
+    lastCommitSha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    owner: jenkins-x
+    provider: github
+    repository: lighthouse
+  name: jenkins-x-lighthouse-pr-813-1
+  namespace: jx
+  resourceVersion: "196520840"
+  selfLink: /apis/jenkins.io/v1/namespaces/jx/pipelineactivities/jenkins-x-lighthouse-pr-813-6
+  uid: f53aa32b-b47e-11ea-b02a-42010a8401ad
+spec:
+  author: abayer
+  authorAvatarURL: https://avatars2.githubusercontent.com/u/120218?v=4
+  authorURL: https://github.com/abayer
+  baseSHA: e8d56b5ee9671599c75644af574a251dd3b94a5c
+  batchPipelineActivity: {}
+  build: "1"
+  context: github
+  gitBranch: PR-813
+  gitOwner: jenkins-x
+  gitRepository: lighthouse
+  gitUrl: https://github.com/jenkins-x/lighthouse.git
+  lastCommitSHA: dd64c739442d505cf5381e2a14b60968e8a0d86e
+  pipeline: jenkins-x/lighthouse/PR-813
+  pullTitle: 'WIP: feat: Work towards supporting other engines besides jx'
+  startedTimestamp: "2020-06-22T11:53:17Z"
+  status: Running
+  steps:
+  - kind: Stage
+    stage:
+      completedTimestamp: "2020-06-22T11:53:49Z"
+      name: meta pipeline
+      startedTimestamp: "2020-06-22T11:53:17Z"
+      status: Succeeded
+      steps:
+      - completedTimestamp: "2020-06-22T11:53:17Z"
+        name: Credential Initializer C5wrl
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:17Z"
+        name: Working Dir Initializer Vjz4t
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:18Z"
+        name: Place Tools
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:25Z"
+        description: https://github.com/jenkins-x/lighthouse.git
+        name: Git Source Meta Jenkins X Lighthouse Pr 81 7gtvf Vxn85
+        startedTimestamp: "2020-06-22T11:53:18Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:29Z"
+        name: Git Merge
+        startedTimestamp: "2020-06-22T11:53:25Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:32Z"
+        name: Merge Pull Refs
+        startedTimestamp: "2020-06-22T11:53:29Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:42Z"
+        name: Create Effective Pipeline
+        startedTimestamp: "2020-06-22T11:53:32Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:49Z"
+        name: Create Tekton Crds
+        startedTimestamp: "2020-06-22T11:53:42Z"
+        status: Succeeded
+  - kind: Stage
+    stage:
+      name: ci
+      startedTimestamp: "2020-06-22T11:53:50Z"
+      status: Running
+      steps:
+      - completedTimestamp: "2020-06-22T11:53:50Z"
+        name: Credential Initializer 52cws
+        startedTimestamp: "2020-06-22T11:53:50Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:51Z"
+        name: Working Dir Initializer Z594z
+        startedTimestamp: "2020-06-22T11:53:50Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:52Z"
+        name: Place Tools
+        startedTimestamp: "2020-06-22T11:53:51Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:59Z"
+        description: https://github.com/jenkins-x/lighthouse.git
+        name: Git Source Jenkins X Lighthouse Pr 813 Git R8sdb F64b8
+        startedTimestamp: "2020-06-22T11:53:52Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:54:02Z"
+        name: Git Merge
+        startedTimestamp: "2020-06-22T11:53:59Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:55:37Z"
+        name: Build Binaries
+        startedTimestamp: "2020-06-22T11:54:02Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:55:54Z"
+        name: Build And Push Webhooks
+        startedTimestamp: "2020-06-22T11:55:37Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:10Z"
+        name: Build And Push Keeper
+        startedTimestamp: "2020-06-22T11:55:54Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:26Z"
+        name: Build And Push Foghorn
+        startedTimestamp: "2020-06-22T11:56:10Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:41Z"
+        name: Build And Push Gc Jobs
+        startedTimestamp: "2020-06-22T11:56:26Z"
+        status: Succeeded
+      - name: Runci
+        startedTimestamp: "2020-06-22T11:56:41Z"
+        status: Running
+status: {}
diff --git a/pkg/jx/test_data/controller/start-pullrequest/expected-activity.yml b/pkg/jx/test_data/controller/start-pullrequest/expected-activity.yml
new file mode 100644
index 00000000..01b6048b
--- /dev/null
+++ b/pkg/jx/test_data/controller/start-pullrequest/expected-activity.yml
@@ -0,0 +1,22 @@
+metadata:
+  creationTimestamp: null
+  labels:
+    branch: PR-813
+    build: "1"
+    context: github
+    lastCommitSha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    owner: jenkins-x
+    provider: github
+    repository: lighthouse
+  name: jenkins-x-lighthouse-pr-813-1
+  namespace: jx
+spec:
+  batchPipelineActivity: {}
+  build: "1"
+  context: github
+  gitBranch: PR-813
+  gitOwner: jenkins-x
+  gitRepository: lighthouse
+  gitUrl: https://github.com/jenkins-x/lighthouse.git
+  pipeline: jenkins-x/lighthouse/PR-813
+status: {}
diff --git a/pkg/jx/test_data/controller/start-pullrequest/expected-lhjob.yml b/pkg/jx/test_data/controller/start-pullrequest/expected-lhjob.yml
new file mode 100644
index 00000000..364bef39
--- /dev/null
+++ b/pkg/jx/test_data/controller/start-pullrequest/expected-lhjob.yml
@@ -0,0 +1,42 @@
+apiVersion: lighthouse.jenkins.io/v1alpha1
+kind: LighthouseJob
+metadata:
+  annotations:
+    lighthouse.jenkins-x.io/job: github
+  labels:
+    created-by-lighthouse: "true"
+    lighthouse.jenkins-x.io/branch: PR-813
+    lighthouse.jenkins-x.io/buildNum: "1"
+    lighthouse.jenkins-x.io/context: github
+    lighthouse.jenkins-x.io/job: github
+    lighthouse.jenkins-x.io/refs.org: jenkins-x
+    lighthouse.jenkins-x.io/refs.pull: "813"
+    lighthouse.jenkins-x.io/refs.repo: lighthouse
+    lighthouse.jenkins-x.io/type: presubmit
+  name: f46327af-b47e-11ea-b797-9256b7b8d9b0
+  namespace: jx
+spec:
+  agent: tekton
+  context: github
+  job: github
+  namespace: jx
+  refs:
+    base_link: https://github.com/jenkins-x/lighthouse/commit/e8d56b5ee9671599c75644af574a251dd3b94a5c
+    base_ref: master
+    base_sha: e8d56b5ee9671599c75644af574a251dd3b94a5c
+    clone_uri: https://github.com/jenkins-x/lighthouse.git
+    org: jenkins-x
+    pulls:
+    - author: abayer
+      author_link: https://github.com/abayer
+      commit_link: https://github.com/jenkins-x/lighthouse/pull/813/commits/dd64c739442d505cf5381e2a14b60968e8a0d86e
+      link: https://github.com/jenkins-x/lighthouse/pull/813.diff
+      number: 813
+      sha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    repo: lighthouse
+    repo_link: https://github.com/jenkins-x/lighthouse
+  rerun_command: /test github
+  type: presubmit
+status:
+  state: pending
+  activityName: jenkins-x-lighthouse-pr-813-1
diff --git a/pkg/jx/test_data/controller/start-pullrequest/observed-lhjob.yml b/pkg/jx/test_data/controller/start-pullrequest/observed-lhjob.yml
new file mode 100644
index 00000000..accedda4
--- /dev/null
+++ b/pkg/jx/test_data/controller/start-pullrequest/observed-lhjob.yml
@@ -0,0 +1,41 @@
+apiVersion: lighthouse.jenkins.io/v1alpha1
+kind: LighthouseJob
+metadata:
+  annotations:
+    lighthouse.jenkins-x.io/job: github
+  labels:
+    created-by-lighthouse: "true"
+    lighthouse.jenkins-x.io/branch: PR-813
+    lighthouse.jenkins-x.io/buildNum: "6"
+    lighthouse.jenkins-x.io/context: github
+    lighthouse.jenkins-x.io/job: github
+    lighthouse.jenkins-x.io/refs.org: jenkins-x
+    lighthouse.jenkins-x.io/refs.pull: "813"
+    lighthouse.jenkins-x.io/refs.repo: lighthouse
+    lighthouse.jenkins-x.io/type: presubmit
+  name: f46327af-b47e-11ea-b797-9256b7b8d9b0
+  namespace: jx
+spec:
+  agent: tekton
+  context: github
+  job: github
+  namespace: jx
+  refs:
+    base_link: https://github.com/jenkins-x/lighthouse/commit/e8d56b5ee9671599c75644af574a251dd3b94a5c
+    base_ref: master
+    base_sha: e8d56b5ee9671599c75644af574a251dd3b94a5c
+    clone_uri: https://github.com/jenkins-x/lighthouse.git
+    org: jenkins-x
+    pulls:
+    - author: abayer
+      author_link: https://github.com/abayer
+      commit_link: https://github.com/jenkins-x/lighthouse/pull/813/commits/dd64c739442d505cf5381e2a14b60968e8a0d86e
+      link: https://github.com/jenkins-x/lighthouse/pull/813.diff
+      number: 813
+      sha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    repo: lighthouse
+    repo_link: https://github.com/jenkins-x/lighthouse
+  rerun_command: /test github
+  type: presubmit
+status:
+  state: triggered
diff --git a/pkg/jx/test_data/controller/update-job/expected-lhjob.yml b/pkg/jx/test_data/controller/update-job/expected-lhjob.yml
new file mode 100644
index 00000000..00337567
--- /dev/null
+++ b/pkg/jx/test_data/controller/update-job/expected-lhjob.yml
@@ -0,0 +1,139 @@
+apiVersion: lighthouse.jenkins.io/v1alpha1
+kind: LighthouseJob
+metadata:
+  annotations:
+    lighthouse.jenkins-x.io/job: github
+  labels:
+    created-by-lighthouse: "true"
+    lighthouse.jenkins-x.io/branch: PR-813
+    lighthouse.jenkins-x.io/buildNum: "1"
+    lighthouse.jenkins-x.io/context: github
+    lighthouse.jenkins-x.io/job: github
+    lighthouse.jenkins-x.io/refs.org: jenkins-x
+    lighthouse.jenkins-x.io/refs.pull: "813"
+    lighthouse.jenkins-x.io/refs.repo: lighthouse
+    lighthouse.jenkins-x.io/type: presubmit
+  name: f46327af-b47e-11ea-b797-9256b7b8d9b0
+  namespace: jx
+spec:
+  agent: tekton
+  context: github
+  job: github
+  namespace: jx
+  refs:
+    base_link: https://github.com/jenkins-x/lighthouse/commit/e8d56b5ee9671599c75644af574a251dd3b94a5c
+    base_ref: master
+    base_sha: e8d56b5ee9671599c75644af574a251dd3b94a5c
+    clone_uri: https://github.com/jenkins-x/lighthouse.git
+    org: jenkins-x
+    pulls:
+    - author: abayer
+      author_link: https://github.com/abayer
+      commit_link: https://github.com/jenkins-x/lighthouse/pull/813/commits/dd64c739442d505cf5381e2a14b60968e8a0d86e
+      link: https://github.com/jenkins-x/lighthouse/pull/813.diff
+      number: 813
+      sha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    repo: lighthouse
+    repo_link: https://github.com/jenkins-x/lighthouse
+  rerun_command: /test github
+  type: presubmit
+status:
+  state: pending
+  activity:
+    baseSHA: e8d56b5ee9671599c75644af574a251dd3b94a5c
+    branch: PR-813
+    buildId: "1"
+    context: github
+    gitURL: https://github.com/jenkins-x/lighthouse.git
+    lastCommitSHA: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    name: jenkins-x-lighthouse-pr-813-1
+    owner: jenkins-x
+    repo: lighthouse
+    stages:
+      - completionTime: "2020-06-22T11:53:49Z"
+        name: meta pipeline
+        startTime: "2020-06-22T11:53:17Z"
+        status: success
+        steps:
+          - completionTime: "2020-06-22T11:53:17Z"
+            name: Credential Initializer C5wrl
+            startTime: "2020-06-22T11:53:17Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:17Z"
+            name: Working Dir Initializer Vjz4t
+            startTime: "2020-06-22T11:53:17Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:18Z"
+            name: Place Tools
+            startTime: "2020-06-22T11:53:17Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:25Z"
+            name: Git Source Meta Jenkins X Lighthouse Pr 81 7gtvf Vxn85
+            startTime: "2020-06-22T11:53:18Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:29Z"
+            name: Git Merge
+            startTime: "2020-06-22T11:53:25Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:32Z"
+            name: Merge Pull Refs
+            startTime: "2020-06-22T11:53:29Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:42Z"
+            name: Create Effective Pipeline
+            startTime: "2020-06-22T11:53:32Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:49Z"
+            name: Create Tekton Crds
+            startTime: "2020-06-22T11:53:42Z"
+            status: success
+      - name: ci
+        startTime: "2020-06-22T11:53:50Z"
+        status: running
+        steps:
+          - completionTime: "2020-06-22T11:53:50Z"
+            name: Credential Initializer 52cws
+            startTime: "2020-06-22T11:53:50Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:51Z"
+            name: Working Dir Initializer Z594z
+            startTime: "2020-06-22T11:53:50Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:52Z"
+            name: Place Tools
+            startTime: "2020-06-22T11:53:51Z"
+            status: success
+          - completionTime: "2020-06-22T11:53:59Z"
+            name: Git Source Jenkins X Lighthouse Pr 813 Git R8sdb F64b8
+            startTime: "2020-06-22T11:53:52Z"
+            status: success
+          - completionTime: "2020-06-22T11:54:02Z"
+            name: Git Merge
+            startTime: "2020-06-22T11:53:59Z"
+            status: success
+          - completionTime: "2020-06-22T11:55:37Z"
+            name: Build Binaries
+            startTime: "2020-06-22T11:54:02Z"
+            status: success
+          - completionTime: "2020-06-22T11:55:54Z"
+            name: Build And Push Webhooks
+            startTime: "2020-06-22T11:55:37Z"
+            status: success
+          - completionTime: "2020-06-22T11:56:10Z"
+            name: Build And Push Keeper
+            startTime: "2020-06-22T11:55:54Z"
+            status: success
+          - completionTime: "2020-06-22T11:56:26Z"
+            name: Build And Push Foghorn
+            startTime: "2020-06-22T11:56:10Z"
+            status: success
+          - completionTime: "2020-06-22T11:56:41Z"
+            name: Build And Push Gc Jobs
+            startTime: "2020-06-22T11:56:26Z"
+            status: success
+          - name: Runci
+            startTime: "2020-06-22T11:56:41Z"
+            status: running
+    startTime: "2020-06-22T11:53:17Z"
+    status: running
+  activityName: jenkins-x-lighthouse-pr-813-1
diff --git a/pkg/jx/test_data/controller/update-job/observed-activity.yml b/pkg/jx/test_data/controller/update-job/observed-activity.yml
new file mode 100644
index 00000000..24c00916
--- /dev/null
+++ b/pkg/jx/test_data/controller/update-job/observed-activity.yml
@@ -0,0 +1,127 @@
+apiVersion: jenkins.io/v1
+kind: PipelineActivity
+metadata:
+  creationTimestamp: "2020-06-22T11:53:16Z"
+  generation: 24
+  labels:
+    branch: PR-813
+    build: "1"
+    context: github
+    lastCommitSha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    owner: jenkins-x
+    provider: github
+    repository: lighthouse
+  name: jenkins-x-lighthouse-pr-813-1
+  namespace: jx
+  resourceVersion: "196520840"
+  selfLink: /apis/jenkins.io/v1/namespaces/jx/pipelineactivities/jenkins-x-lighthouse-pr-813-6
+  uid: f53aa32b-b47e-11ea-b02a-42010a8401ad
+spec:
+  author: abayer
+  authorAvatarURL: https://avatars2.githubusercontent.com/u/120218?v=4
+  authorURL: https://github.com/abayer
+  baseSHA: e8d56b5ee9671599c75644af574a251dd3b94a5c
+  batchPipelineActivity: {}
+  build: "1"
+  context: github
+  gitBranch: PR-813
+  gitOwner: jenkins-x
+  gitRepository: lighthouse
+  gitUrl: https://github.com/jenkins-x/lighthouse.git
+  lastCommitSHA: dd64c739442d505cf5381e2a14b60968e8a0d86e
+  pipeline: jenkins-x/lighthouse/PR-813
+  pullTitle: 'WIP: feat: Work towards supporting other engines besides jx'
+  startedTimestamp: "2020-06-22T11:53:17Z"
+  status: Running
+  steps:
+  - kind: Stage
+    stage:
+      completedTimestamp: "2020-06-22T11:53:49Z"
+      name: meta pipeline
+      startedTimestamp: "2020-06-22T11:53:17Z"
+      status: Succeeded
+      steps:
+      - completedTimestamp: "2020-06-22T11:53:17Z"
+        name: Credential Initializer C5wrl
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:17Z"
+        name: Working Dir Initializer Vjz4t
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:18Z"
+        name: Place Tools
+        startedTimestamp: "2020-06-22T11:53:17Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:25Z"
+        description: https://github.com/jenkins-x/lighthouse.git
+        name: Git Source Meta Jenkins X Lighthouse Pr 81 7gtvf Vxn85
+        startedTimestamp: "2020-06-22T11:53:18Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:29Z"
+        name: Git Merge
+        startedTimestamp: "2020-06-22T11:53:25Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:32Z"
+        name: Merge Pull Refs
+        startedTimestamp: "2020-06-22T11:53:29Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:42Z"
+        name: Create Effective Pipeline
+        startedTimestamp: "2020-06-22T11:53:32Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:49Z"
+        name: Create Tekton Crds
+        startedTimestamp: "2020-06-22T11:53:42Z"
+        status: Succeeded
+  - kind: Stage
+    stage:
+      name: ci
+      startedTimestamp: "2020-06-22T11:53:50Z"
+      status: Running
+      steps:
+      - completedTimestamp: "2020-06-22T11:53:50Z"
+        name: Credential Initializer 52cws
+        startedTimestamp: "2020-06-22T11:53:50Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:51Z"
+        name: Working Dir Initializer Z594z
+        startedTimestamp: "2020-06-22T11:53:50Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:52Z"
+        name: Place Tools
+        startedTimestamp: "2020-06-22T11:53:51Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:53:59Z"
+        description: https://github.com/jenkins-x/lighthouse.git
+        name: Git Source Jenkins X Lighthouse Pr 813 Git R8sdb F64b8
+        startedTimestamp: "2020-06-22T11:53:52Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:54:02Z"
+        name: Git Merge
+        startedTimestamp: "2020-06-22T11:53:59Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:55:37Z"
+        name: Build Binaries
+        startedTimestamp: "2020-06-22T11:54:02Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:55:54Z"
+        name: Build And Push Webhooks
+        startedTimestamp: "2020-06-22T11:55:37Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:10Z"
+        name: Build And Push Keeper
+        startedTimestamp: "2020-06-22T11:55:54Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:26Z"
+        name: Build And Push Foghorn
+        startedTimestamp: "2020-06-22T11:56:10Z"
+        status: Succeeded
+      - completedTimestamp: "2020-06-22T11:56:41Z"
+        name: Build And Push Gc Jobs
+        startedTimestamp: "2020-06-22T11:56:26Z"
+        status: Succeeded
+      - name: Runci
+        startedTimestamp: "2020-06-22T11:56:41Z"
+        status: Running
+status: {}
diff --git a/pkg/jx/test_data/controller/update-job/observed-lhjob.yml b/pkg/jx/test_data/controller/update-job/observed-lhjob.yml
new file mode 100644
index 00000000..9496378d
--- /dev/null
+++ b/pkg/jx/test_data/controller/update-job/observed-lhjob.yml
@@ -0,0 +1,42 @@
+apiVersion: lighthouse.jenkins.io/v1alpha1
+kind: LighthouseJob
+metadata:
+  annotations:
+    lighthouse.jenkins-x.io/job: github
+  labels:
+    created-by-lighthouse: "true"
+    lighthouse.jenkins-x.io/branch: PR-813
+    lighthouse.jenkins-x.io/buildNum: "1"
+    lighthouse.jenkins-x.io/context: github
+    lighthouse.jenkins-x.io/job: github
+    lighthouse.jenkins-x.io/refs.org: jenkins-x
+    lighthouse.jenkins-x.io/refs.pull: "813"
+    lighthouse.jenkins-x.io/refs.repo: lighthouse
+    lighthouse.jenkins-x.io/type: presubmit
+  name: f46327af-b47e-11ea-b797-9256b7b8d9b0
+  namespace: jx
+spec:
+  agent: tekton
+  context: github
+  job: github
+  namespace: jx
+  refs:
+    base_link: https://github.com/jenkins-x/lighthouse/commit/e8d56b5ee9671599c75644af574a251dd3b94a5c
+    base_ref: master
+    base_sha: e8d56b5ee9671599c75644af574a251dd3b94a5c
+    clone_uri: https://github.com/jenkins-x/lighthouse.git
+    org: jenkins-x
+    pulls:
+      - author: abayer
+        author_link: https://github.com/abayer
+        commit_link: https://github.com/jenkins-x/lighthouse/pull/813/commits/dd64c739442d505cf5381e2a14b60968e8a0d86e
+        link: https://github.com/jenkins-x/lighthouse/pull/813.diff
+        number: 813
+        sha: dd64c739442d505cf5381e2a14b60968e8a0d86e
+    repo: lighthouse
+    repo_link: https://github.com/jenkins-x/lighthouse
+  rerun_command: /test github
+  type: presubmit
+status:
+  state: pending
+  activityName: jenkins-x-lighthouse-pr-813-1
diff --git a/pkg/keeper/keeper_test.go b/pkg/keeper/keeper_test.go
index 680e57ab..3915918a 100644
--- a/pkg/keeper/keeper_test.go
+++ b/pkg/keeper/keeper_test.go
@@ -712,6 +712,7 @@ func TestDividePool(t *testing.T) {
 		npr.BaseRef.Prefix = "refs/heads/"
 		npr.Repository.Name = githubql.String(p.repo)
 		npr.Repository.Owner.Login = githubql.String(p.org)
+		npr.Repository.URL = githubql.String(fmt.Sprintf("https://github.com/%s/%s.git", p.org, p.repo))
 		pulls[prKey(&npr)] = npr
 	}
 	var pjs []v1alpha1.LighthouseJob
@@ -749,7 +750,7 @@ func TestDividePool(t *testing.T) {
 				t.Errorf("PR in wrong subpool. Got PR %+v in subpool %s.", pr, name)
 			}
 		}
-		for _, pj := range sp.pjs {
+		for _, pj := range sp.ljs {
 			if pj.Spec.Type != config.PresubmitJob && pj.Spec.Type != config.BatchJob {
 				t.Errorf("PJ with bad type in subpool %s: %+v", name, pj)
 			}
@@ -1523,6 +1524,7 @@ func testPR(org, repo, branch string, number int, mergeable githubql.MergeableSt
 	pr.Repository.Name = githubql.String(repo)
 	pr.Repository.NameWithOwner = githubql.String(fmt.Sprintf("%s/%s", org, repo))
 	pr.BaseRef.Name = githubql.String(branch)
+	pr.Repository.URL = githubql.String(fmt.Sprintf("https://github.com/%s/%s.git", org, repo))
 
 	pr.Commits.Nodes = append(pr.Commits.Nodes, struct{ Commit Commit }{
 		Commit{
@@ -1628,71 +1630,70 @@ func TestSync(t *testing.T) {
 	}
 
 	for _, tc := range testcases {
-		t.Logf("Starting case %q...", tc.name)
-		fgc := &fgc{prs: tc.prs}
-		fakeLauncher := launcherfake.NewLauncher()
-		fakeLighthouseClient := fake.NewSimpleClientset()
-		fakeTektonClient := tektonfake.NewSimpleClientset()
-		ca := &config.Agent{}
-		ca.Set(&config.Config{
-			ProwConfig: config.ProwConfig{
-				Keeper: config.Keeper{
-					Queries:            []config.KeeperQuery{{}},
-					MaxGoroutines:      4,
-					StatusUpdatePeriod: time.Second * 0,
+		t.Run(tc.name, func(t *testing.T) {
+			fgc := &fgc{prs: tc.prs}
+			fakeLauncher := launcherfake.NewLauncher()
+			fakeLighthouseClient := fake.NewSimpleClientset()
+			fakeTektonClient := tektonfake.NewSimpleClientset()
+			ca := &config.Agent{}
+			ca.Set(&config.Config{
+				ProwConfig: config.ProwConfig{
+					Keeper: config.Keeper{
+						Queries:            []config.KeeperQuery{{}},
+						MaxGoroutines:      4,
+						StatusUpdatePeriod: time.Second * 0,
+					},
 				},
-			},
-		})
-		hist, err := history.New(100, "")
-		if err != nil {
-			t.Fatalf("Failed to create history client: %v", err)
-		}
-		sc := &statusController{
-			logger:         logrus.WithField("controller", "status-update"),
-			spc:            fgc,
-			config:         ca.Config,
-			newPoolPending: make(chan bool, 1),
-			shutDown:       make(chan bool),
-		}
-		go sc.run()
-		defer sc.shutdown()
-		c := &DefaultController{
-			config:         ca.Config,
-			spc:            fgc,
-			launcherClient: fakeLauncher,
-			tektonClient:   fakeTektonClient,
-			lhClient:       fakeLighthouseClient,
-			ns:             "jx",
-			logger:         logrus.WithField("controller", "sync"),
-			sc:             sc,
-			changedFiles: &changedFilesAgent{
-				spc:             fgc,
-				nextChangeCache: make(map[changeCacheKey][]string),
-			},
-			History: hist,
-		}
+			})
+			hist, err := history.New(100, "")
+			if err != nil {
+				t.Fatalf("Failed to create history client: %v", err)
+			}
+			sc := &statusController{
+				logger:         logrus.WithField("controller", "status-update"),
+				spc:            fgc,
+				config:         ca.Config,
+				newPoolPending: make(chan bool, 1),
+				shutDown:       make(chan bool),
+			}
+			go sc.run()
+			defer sc.shutdown()
+			c := &DefaultController{
+				config:         ca.Config,
+				spc:            fgc,
+				launcherClient: fakeLauncher,
+				tektonClient:   fakeTektonClient,
+				lhClient:       fakeLighthouseClient,
+				ns:             "jx",
+				logger:         logrus.WithField("controller", "sync"),
+				sc:             sc,
+				changedFiles: &changedFilesAgent{
+					spc:             fgc,
+					nextChangeCache: make(map[changeCacheKey][]string),
+				},
+				History: hist,
+			}
 
-		if err := c.Sync(); err != nil {
-			t.Errorf("Unexpected error from 'Sync()': %v.", err)
-			continue
-		}
-		if len(tc.expectedPools) != len(c.pools) {
-			t.Errorf("Keeper pools did not match expected. Got %#v, expected %#v.", c.pools, tc.expectedPools)
-			continue
-		}
-		for _, expected := range tc.expectedPools {
-			var match *Pool
-			for i, actual := range c.pools {
-				if expected.Org == actual.Org && expected.Repo == actual.Repo && expected.Branch == actual.Branch {
-					match = &c.pools[i]
-				}
+			if err := c.Sync(); err != nil {
+				t.Fatalf("Unexpected error from 'Sync()': %v.", err)
 			}
-			if match == nil {
-				t.Errorf("Failed to find expected pool %s/%s %s.", expected.Org, expected.Repo, expected.Branch)
-			} else if !reflect.DeepEqual(*match, expected) {
-				t.Errorf("Expected pool %#v does not match actual pool %#v.", expected, *match)
+			if len(tc.expectedPools) != len(c.pools) {
+				t.Fatalf("Keeper pools did not match expected. Got %#v, expected %#v.", c.pools, tc.expectedPools)
 			}
-		}
+			for _, expected := range tc.expectedPools {
+				var match *Pool
+				for i, actual := range c.pools {
+					if expected.Org == actual.Org && expected.Repo == actual.Repo && expected.Branch == actual.Branch {
+						match = &c.pools[i]
+					}
+				}
+				if match == nil {
+					t.Errorf("Failed to find expected pool %s/%s %s.", expected.Org, expected.Repo, expected.Branch)
+				} else if !reflect.DeepEqual(*match, expected) {
+					t.Errorf("Expected pool %#v does not match actual pool %#v.", expected, *match)
+				}
+			}
+		})
 	}
 }
 
diff --git a/pkg/plugins/override/override_test.go b/pkg/plugins/override/override_test.go
index 895f7802..6fa6267c 100644
--- a/pkg/plugins/override/override_test.go
+++ b/pkg/plugins/override/override_test.go
@@ -24,13 +24,11 @@ import (
 	"testing"
 
 	"github.com/jenkins-x/go-scm/scm"
-	"github.com/jenkins-x/jx/v2/pkg/jxfactory"
 	"github.com/jenkins-x/lighthouse/pkg/apis/lighthouse/v1alpha1"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/sirupsen/logrus"
 	"k8s.io/apimachinery/pkg/util/sets"
 
-	"github.com/jenkins-x/jx/v2/pkg/tekton/metapipeline"
 	"github.com/jenkins-x/lighthouse-config/pkg/config"
 )
 
@@ -144,7 +142,7 @@ func (c *fakeClient) GetRef(org, repo, ref string) (string, error) {
 	return fakeBaseSHA, nil
 }
 
-func (c *fakeClient) Launch(pj *v1alpha1.LighthouseJob, metapipelineClient metapipeline.Client, repository scm.Repository) (*v1alpha1.LighthouseJob, error) {
+func (c *fakeClient) Launch(pj *v1alpha1.LighthouseJob, repository scm.Repository) (*v1alpha1.LighthouseJob, error) {
 	if pj.Spec.Context == "fail-create" {
 		return pj, errors.New("injected Launch error")
 	}
@@ -207,7 +205,6 @@ func TestAuthorized(t *testing.T) {
 }
 
 func TestHandle(t *testing.T) {
-	clientFactory := jxfactory.NewFactory()
 	cases := []struct {
 		name          string
 		action        scm.Action
@@ -560,7 +557,7 @@ func TestHandle(t *testing.T) {
 				tc.jobs = sets.String{}
 			}
 
-			err := handle(clientFactory, &fc, log, &event)
+			err := handle(&fc, log, &event)
 			switch {
 			case err != nil:
 				if !tc.err {