Repository: jenkins-x/lighthouse
Pull request #876 (2020-07-31 17:58:31)

feat: Add back the Prow-style config updater plugin
Jenkins X doesn't use this, but non-jx usage definitely would like to!

Signed-off-by: Andrew Bayer <andrew.bayer@gmail.com>


Patch:
diff --git a/charts/lighthouse/templates/foghorn-role.yaml b/charts/lighthouse/templates/foghorn-role.yaml
index fc979c30..76a9dd63 100644
--- a/charts/lighthouse/templates/foghorn-role.yaml
+++ b/charts/lighthouse/templates/foghorn-role.yaml
@@ -3,14 +3,6 @@ apiVersion: rbac.authorization.k8s.io/v1
 metadata:
   name: {{ template "foghorn.name" . }}
 rules:
-- apiGroups:
-  - jenkins.io
-  resources:
-  - pipelineactivities
-  verbs:
-  - list
-  - get
-  - watch
 - apiGroups:
   - ""
   resources:
diff --git a/charts/lighthouse/templates/keeper-role.yaml b/charts/lighthouse/templates/keeper-role.yaml
index adcc51c2..93a3610a 100644
--- a/charts/lighthouse/templates/keeper-role.yaml
+++ b/charts/lighthouse/templates/keeper-role.yaml
@@ -3,22 +3,6 @@ apiVersion: rbac.authorization.k8s.io/v1
 metadata:
   name: {{ template "keeper.name" . }}
 rules:
-  - apiGroups:
-      - jenkins.io
-    resources:
-      - apps
-      - environments
-      - pipelineactivities
-      - sourcerepositories
-      - pipelinestructures
-    verbs:
-      - create
-      - get
-      - edit
-      - update
-      - patch
-      - list
-      - watch
   - apiGroups:
       - ""
     resources:
diff --git a/charts/lighthouse/templates/webhooks-role.yaml b/charts/lighthouse/templates/webhooks-role.yaml
index 4165c7a3..46a4b1d4 100644
--- a/charts/lighthouse/templates/webhooks-role.yaml
+++ b/charts/lighthouse/templates/webhooks-role.yaml
@@ -3,29 +3,6 @@ apiVersion: rbac.authorization.k8s.io/v1
 metadata:
   name: {{ template "webhooks.name" . }}
 rules:
-- apiGroups:
-  - jenkins.io
-  resources:
-  - pipelineactivities
-  - pipelinestructures
-  - sourcerepositories
-  - environments
-  verbs:
-  - create
-  - list
-  - update
-  - get
-  - watch
-  - patch
-- apiGroups:
-  - jenkins.io
-  resources:
-  - apps
-  - plugins
-  verbs:
-  - list
-  - get
-  - watch
 - apiGroups:
   - ""
   resources:
@@ -34,20 +11,10 @@ rules:
   - secrets
   verbs:
   - get
-  - list
-  - watch
-- apiGroups:
-  - tekton.dev
-  resources:
-  - pipelineresources
-  - tasks
-  - pipelines
-  - pipelineruns
-  verbs:
+  - update
   - create
   - list
-  - get
-  - update
+  - watch
 - apiGroups:
   - lighthouse.jenkins.io
   resources:
diff --git a/cmd/foghorn/main.go b/cmd/foghorn/main.go
index 45ad9713..a7a48e49 100644
--- a/cmd/foghorn/main.go
+++ b/cmd/foghorn/main.go
@@ -3,8 +3,6 @@ package main
 import (
 	"flag"
 	"os"
-	"os/signal"
-	"syscall"
 	"time"
 
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
@@ -13,6 +11,7 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/foghorn"
 	"github.com/jenkins-x/lighthouse/pkg/interrupts"
 	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
+	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/sirupsen/logrus"
 	"k8s.io/client-go/kubernetes"
 )
@@ -40,28 +39,12 @@ func gatherOptions(fs *flag.FlagSet, args ...string) 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-foghorn")
 
 	defer interrupts.WaitForGracefulShutdown()
 
-	stopCh := stopper()
+	stopCh := util.Stopper()
 
 	o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...)
 	if err := o.Validate(); err != nil {
diff --git a/cmd/keeper/main.go b/cmd/keeper/main.go
index bac448c2..2b5536c5 100644
--- a/cmd/keeper/main.go
+++ b/cmd/keeper/main.go
@@ -109,7 +109,7 @@ func main() {
 	var err error
 	botName := o.botName
 	if botName == "" {
-		botName = util.GetBotName()
+		botName = util.GetBotName(configAgent.Config)
 	}
 	if util.GetGitHubAppSecretDir() != "" {
 		botName, err = util.GetGitHubAppAPIUser()
@@ -122,11 +122,11 @@ func main() {
 	}
 	serverURL := o.gitServerURL
 	if serverURL == "" {
-		serverURL = util.GetGitServer()
+		serverURL = util.GetGitServer(configAgent.Config)
 	}
 	gitKind := o.gitKind
 	if gitKind == "" {
-		gitKind = util.GitKind()
+		gitKind = util.GitKind(configAgent.Config)
 	}
 	gitToken, err := util.GetSCMToken(gitKind)
 	if err != nil {
diff --git a/cmd/tektoncontroller/main.go b/cmd/tektoncontroller/main.go
index 2af1b297..bb7f10ff 100644
--- a/cmd/tektoncontroller/main.go
+++ b/cmd/tektoncontroller/main.go
@@ -3,8 +3,6 @@ package main
 import (
 	"flag"
 	"os"
-	"os/signal"
-	"syscall"
 	"time"
 
 	clientset "github.com/jenkins-x/lighthouse/pkg/client/clientset/versioned"
@@ -13,6 +11,7 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/engines/tekton"
 	"github.com/jenkins-x/lighthouse/pkg/interrupts"
 	"github.com/jenkins-x/lighthouse/pkg/logrusutil"
+	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/sirupsen/logrus"
 	tektonclient "github.com/tektoncd/pipeline/pkg/client/clientset/versioned"
 	tektoninformers "github.com/tektoncd/pipeline/pkg/client/informers/externalversions"
@@ -42,28 +41,12 @@ func gatherOptions(fs *flag.FlagSet, args ...string) 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()
+	stopCh := util.Stopper()
 
 	o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...)
 	if err := o.Validate(); err != nil {
diff --git a/go.mod b/go.mod
index 74bfda49..883e9a20 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,8 @@ require (
 	github.com/google/go-cmp v0.4.1
 	github.com/gorilla/sessions v1.2.0
 	github.com/hashicorp/go-multierror v1.1.0
-	github.com/jenkins-x/go-scm v1.5.152
+	github.com/jenkins-x/go-scm v1.5.153
+	github.com/mattn/go-zglob v0.0.1
 	github.com/onsi/ginkgo v1.11.0
 	github.com/onsi/gomega v1.8.1
 	github.com/pkg/errors v0.9.1
@@ -26,6 +27,7 @@ require (
 	k8s.io/api v0.18.1
 	k8s.io/apimachinery v0.18.1
 	k8s.io/client-go v11.0.1-0.20190805182717-6502b5e7b1b5+incompatible
+	k8s.io/utils v0.0.0-20200124190032-861946025e34
 	knative.dev/pkg v0.0.0-20200702222342-ea4d6e985ba0
 	sigs.k8s.io/yaml v1.2.0
 )
diff --git a/go.sum b/go.sum
index ceb6cebd..e31e847e 100644
--- a/go.sum
+++ b/go.sum
@@ -629,6 +629,8 @@ github.com/jenkins-x/go-scm v1.5.79/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGE
 github.com/jenkins-x/go-scm v1.5.117/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGEKd7jPkeYg=
 github.com/jenkins-x/go-scm v1.5.152 h1:Zn/5ZOpFJMkCe0l6e4+my1E+uOBkkyRsVdWAaHn29Xg=
 github.com/jenkins-x/go-scm v1.5.152/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGEKd7jPkeYg=
+github.com/jenkins-x/go-scm v1.5.153 h1:FJmGMbCkByuOMNCcCyPa7gMLeqNyuQQB3azBVmiTdMI=
+github.com/jenkins-x/go-scm v1.5.153/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGEKd7jPkeYg=
 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -739,6 +741,7 @@ github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
 github.com/mattn/go-shellwords v1.0.9/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mattn/go-sqlite3 v0.0.0-20160514122348-38ee283dabf1/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-zglob v0.0.1 h1:xsEx/XUoVlI6yXjqBK062zYhRTZltCNmYPx6v+8DNaY=
 github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
 github.com/matttproud/golang_protobuf_extensions v1.0.0/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 94d78456..6ac0d716 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -115,6 +115,20 @@ type ProwConfig struct {
 
 	// GitHubOptions allows users to control how prow applications display GitHub website links.
 	GitHubOptions GitHubOptions `json:"github,omitempty"`
+
+	// ProviderConfig contains optional SCM provider information
+	ProviderConfig *ProviderConfig `json:"providerConfig,omitempty"`
+}
+
+// ProviderConfig is optionally used to configure information about the SCM provider being used. These values will be
+// used as fallbacks if environment variables aren't set.
+type ProviderConfig struct {
+	// Kind is the go-scm driver name
+	Kind string `json:"kind,omitempty"`
+	// Server is the base URL for the provider, like https://github.com
+	Server string `json:"server,omitempty"`
+	// BotUser is the username on the provider the bot will use
+	BotUser string `json:"botUser,omitempty"`
 }
 
 // OwnersDirBlacklist is used to configure which directories to ignore when
diff --git a/pkg/engines/tekton/controller.go b/pkg/engines/tekton/controller.go
index e046eb47..b508520f 100644
--- a/pkg/engines/tekton/controller.go
+++ b/pkg/engines/tekton/controller.go
@@ -3,14 +3,11 @@ package tekton
 import (
 	"encoding/json"
 	"fmt"
-	"os"
-	"os/signal"
 	"reflect"
 	"regexp"
 	"strconv"
 	"strings"
 	"sync"
-	"syscall"
 	"time"
 
 	jsonpatch "github.com/evanphx/json-patch"
@@ -91,26 +88,7 @@ func NewController(kubeClient kubernetes.Interface, tektonClient tektonclient.In
 
 	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())
+	configMapWatcher, err := watcher.SetupConfigMapWatchers(ns, configAgent, nil)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to create ConfigMap watcher")
 	}
@@ -484,22 +462,6 @@ func createLabelSelectorFromActivity(activity *v1alpha1.ActivityRecord) (labels.
 	return labels.Parse(strings.Join(selectors, ","))
 }
 
-// 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
-}
-
 // generateBuildID generates a unique build ID for consistency with Prow behavior
 func generateBuildID() string {
 	return fmt.Sprintf("%d", utilrand.Int())
diff --git a/pkg/foghorn/controller.go b/pkg/foghorn/controller.go
index 91cdb7fd..83afa6c8 100644
--- a/pkg/foghorn/controller.go
+++ b/pkg/foghorn/controller.go
@@ -2,12 +2,9 @@ package foghorn
 
 import (
 	"fmt"
-	"os"
-	"os/signal"
 	"reflect"
 	"strings"
 	"sync"
-	"syscall"
 	"time"
 
 	"github.com/jenkins-x/go-scm/scm"
@@ -69,43 +66,7 @@ func NewController(kubeClient kubernetes.Interface, lhClient clientset.Interface
 	configAgent := &config.Agent{}
 	pluginAgent := &plugins.ConfigAgent{}
 
-	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)
-			}
-		}
-	}
-
-	onPluginsYamlChange := func(text string) {
-		if text != "" {
-			cfg, err := pluginAgent.LoadYAMLConfig([]byte(text))
-			if err != nil {
-				logrus.WithError(err).Error("Error processing the prow Plugins YAML")
-			} else {
-				logrus.Info("updating the prow plugins configuration")
-				pluginAgent.Set(cfg)
-			}
-		}
-	}
-
-	callbacks := []watcher.ConfigMapCallback{
-		&watcher.ConfigMapEntryCallback{
-			Name:     util.ProwConfigMapName,
-			Key:      util.ProwConfigFilename,
-			Callback: onConfigYamlChange,
-		},
-		&watcher.ConfigMapEntryCallback{
-			Name:     util.ProwPluginsConfigMapName,
-			Key:      util.ProwPluginsFilename,
-			Callback: onPluginsYamlChange,
-		},
-	}
-	configMapWatcher, err := watcher.NewConfigMapWatcher(kubeClient, ns, callbacks, stopper())
+	configMapWatcher, err := watcher.SetupConfigMapWatchers(ns, configAgent, pluginAgent)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to create ConfigMap watcher")
 	}
@@ -327,7 +288,7 @@ func (c *Controller) reportStatus(ns string, activity *v1alpha1.ActivityRecord,
 	repo := activity.Repo
 	gitURL := activity.GitURL
 	activityStatus := activity.Status
-	statusInfo := toScmStatusDescriptionRunningStages(activity, util.GitKind())
+	statusInfo := toScmStatusDescriptionRunningStages(activity, util.GitKind(c.jobConfig.Config))
 
 	fields := map[string]interface{}{
 		"name":        activity.Name,
@@ -394,7 +355,7 @@ func (c *Controller) reportStatus(ns string, activity *v1alpha1.ActivityRecord,
 		Desc:   statusInfo.description,
 		Target: job.Status.ReportURL,
 	}
-	scmClient, _, _, _, err := util.GetSCMClient(owner)
+	scmClient, _, _, _, err := util.GetSCMClient(owner, c.jobConfig.Config)
 	if err != nil {
 		c.logger.WithFields(fields).WithError(err).Warnf("failed to create SCM client")
 		return
@@ -467,19 +428,3 @@ func durationString(start *metav1.Time, end *metav1.Time) string {
 	}
 	return end.Sub(start.Time).Round(time.Second).String()
 }
-
-// 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/plugins/config.go b/pkg/plugins/config.go
index 1e809c7d..6e7438ee 100644
--- a/pkg/plugins/config.go
+++ b/pkg/plugins/config.go
@@ -404,7 +404,10 @@ type ConfigMapSpec struct {
 	// Namespaces in which the configMap needs to be deployed, in addition to the above
 	// namespace provided, or the default if it is not set.
 	AdditionalNamespaces []string `json:"additional_namespaces,omitempty"`
-
+	// GZIP toggles whether the key's data should be GZIP'd before being stored
+	// If set to false and the global GZIP option is enabled, this file will
+	// will not be GZIP'd.
+	GZIP *bool `json:"gzip,omitempty"`
 	// Namespaces is the fully resolved list of Namespaces to deploy the ConfigMap in
 	Namespaces []string `json:"-"`
 }
@@ -428,6 +431,9 @@ type ConfigUpdater struct {
 	// github.com/kubernetes/test-infra/prow/plugins.yaml assuming the config-updater
 	// plugin is enabled for kubernetes/test-infra. Defaults to "prow/plugins.yaml".
 	PluginFile string `json:"plugin_file,omitempty"`
+	// If GZIP is true then files will be gzipped before insertion into
+	// their corresponding configmap
+	GZIP bool `json:"gzip"`
 }
 
 // MergeWarning is a config for the slackevents plugin's manual merge warnings.
diff --git a/pkg/plugins/updateconfig/updateconfig.go b/pkg/plugins/updateconfig/updateconfig.go
new file mode 100644
index 00000000..0befbac1
--- /dev/null
+++ b/pkg/plugins/updateconfig/updateconfig.go
@@ -0,0 +1,305 @@
+/*
+Copyright 2017 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package updateconfig
+
+import (
+	"bytes"
+	"compress/gzip"
+	"fmt"
+	"path"
+	"unicode/utf8"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/lighthouse/pkg/pluginhelp"
+	"github.com/jenkins-x/lighthouse/pkg/plugins"
+	zglob "github.com/mattn/go-zglob"
+	"github.com/sirupsen/logrus"
+	coreapi "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
+)
+
+const (
+	pluginName = "config-updater"
+)
+
+func init() {
+	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
+}
+
+func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
+	var configInfo map[string]string
+	if len(enabledRepos) == 1 {
+		msg := fmt.Sprintf(
+			"The main configuration is kept in sync with '%s/%s'.\nThe plugin configuration is kept in sync with '%s/%s'.",
+			enabledRepos[0],
+			config.ConfigUpdater.ConfigFile,
+			enabledRepos[0],
+			config.ConfigUpdater.PluginFile,
+		)
+		configInfo = map[string]string{"": msg}
+	}
+	return &pluginhelp.PluginHelp{
+			Description: "The config-updater plugin automatically redeploys configuration and plugin configuration files when they change. The plugin watches for pull request merges that modify either of the config files and updates the cluster's configmap resources in response.",
+			Config:      configInfo,
+		},
+		nil
+}
+
+type scmProviderClient interface {
+	CreateComment(owner, repo string, number int, isPR bool, comment string) error
+	GetPullRequestChanges(org, repo string, number int) ([]*scm.Change, error)
+	GetFile(org, repo, filepath, commit string) ([]byte, error)
+}
+
+func handlePullRequest(pc plugins.Agent, pre scm.PullRequestHook) error {
+	return handle(pc.SCMProviderClient, pc.KubernetesClient.CoreV1(), pc.Config.LighthouseJobNamespace, pc.Logger, pre, pc.PluginConfig.ConfigUpdater)
+}
+
+// FileGetter knows how to get the contents of a file by name
+type FileGetter interface {
+	GetFile(filename string) ([]byte, error)
+}
+
+type scmFileGetter struct {
+	org, repo, commit string
+	client            scmProviderClient
+}
+
+func (g *scmFileGetter) GetFile(filename string) ([]byte, error) {
+	return g.client.GetFile(g.org, g.repo, filename, g.commit)
+}
+
+// Update updates the configmap with the data from the identified files
+func Update(fg FileGetter, kc corev1.ConfigMapInterface, name, namespace string, updates []ConfigMapUpdate, logger *logrus.Entry) error {
+	cm, getErr := kc.Get(name, metav1.GetOptions{})
+	isNotFound := errors.IsNotFound(getErr)
+	if getErr != nil && !isNotFound {
+		return fmt.Errorf("failed to fetch current state of configmap: %v", getErr)
+	}
+
+	if cm == nil {
+		cm = &coreapi.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: namespace,
+			},
+		}
+	}
+	if cm.Data == nil {
+		cm.Data = map[string]string{}
+	}
+	if cm.BinaryData == nil {
+		cm.BinaryData = map[string][]byte{}
+	}
+
+	for _, upd := range updates {
+		if upd.Filename == "" {
+			logger.WithField("key", upd.Key).Debug("Deleting key.")
+			delete(cm.Data, upd.Key)
+			delete(cm.BinaryData, upd.Key)
+			continue
+		}
+
+		content, err := fg.GetFile(upd.Filename)
+		if err != nil {
+			return fmt.Errorf("get file err: %v", err)
+		}
+		logger.WithFields(logrus.Fields{"key": upd.Key, "filename": upd.Filename}).Debug("Populating key.")
+		value := content
+		if upd.GZIP {
+			buff := bytes.NewBuffer([]byte{})
+			// TODO: this error is wildly unlikely for anything that
+			// would actually fit in a configmap, we could just as well return
+			// the error instead of falling back to the raw content
+			z := gzip.NewWriter(buff)
+			if _, err := z.Write(content); err != nil {
+				logger.WithError(err).Error("failed to gzip content, falling back to raw")
+			} else {
+				if err := z.Close(); err != nil {
+					logger.WithError(err).Error("failed to flush gzipped content (!?), falling back to raw")
+				} else {
+					value = buff.Bytes()
+				}
+			}
+		}
+		if utf8.ValidString(string(value)) {
+			delete(cm.BinaryData, upd.Key)
+			cm.Data[upd.Key] = string(value)
+		} else {
+			delete(cm.Data, upd.Key)
+			cm.BinaryData[upd.Key] = value
+		}
+	}
+
+	var updateErr error
+	var verb string
+	if getErr != nil && isNotFound {
+		verb = "create"
+		_, updateErr = kc.Create(cm)
+	} else {
+		verb = "update"
+		_, updateErr = kc.Update(cm)
+	}
+	if updateErr != nil {
+		return fmt.Errorf("%s config map err: %v", verb, updateErr)
+	}
+	return nil
+}
+
+// ConfigMapID is a name/namespace combination that identifies a config map
+type ConfigMapID struct {
+	Name, Namespace string
+}
+
+// ConfigMapUpdate is populated with information about a config map that should
+// be updated.
+type ConfigMapUpdate struct {
+	Key, Filename string
+	GZIP          bool
+}
+
+// FilterChanges determines which of the changes are relevant for config updating, returning mapping of
+// config map to key to filename to update that key from.
+func FilterChanges(cfg plugins.ConfigUpdater, changes []*scm.Change, log *logrus.Entry) map[ConfigMapID][]ConfigMapUpdate {
+	toUpdate := map[ConfigMapID][]ConfigMapUpdate{}
+	for _, change := range changes {
+		var cm plugins.ConfigMapSpec
+		found := false
+
+		for key, configMap := range cfg.Maps {
+			var matchErr error
+			found, matchErr = zglob.Match(key, change.Path)
+			if matchErr != nil {
+				// Should not happen, log matchErr and continue
+				log.WithError(matchErr).Info("key matching error")
+				continue
+			}
+
+			if found {
+				cm = configMap
+				break
+			}
+		}
+
+		if !found {
+			continue // This file does not define a configmap
+		}
+
+		// Yes, update the configmap with the contents of this file
+		for _, ns := range append(cm.Namespaces) {
+			id := ConfigMapID{Name: cm.Name, Namespace: ns}
+			key := cm.Key
+			if key == "" {
+				key = path.Base(change.Path)
+				// if the key changed, we need to remove the old key
+				if change.Renamed {
+					oldKey := path.Base(change.PreviousPath)
+					// not setting the filename field will cause the key to be
+					// deleted
+					toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: oldKey})
+				}
+			}
+			if change.Deleted {
+				toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: key})
+			} else {
+				shouldGZIP := cfg.GZIP
+				if cm.GZIP != nil {
+					shouldGZIP = *cm.GZIP
+				}
+				toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: key, Filename: change.Path, GZIP: shouldGZIP})
+			}
+		}
+	}
+	return toUpdate
+}
+
+func handle(spc scmProviderClient, kc corev1.ConfigMapsGetter, defaultNamespace string, log *logrus.Entry, pre scm.PullRequestHook, config plugins.ConfigUpdater) error {
+	// Only consider newly merged PRs
+	if pre.Action != scm.ActionClose {
+		return nil
+	}
+
+	if len(config.Maps) == 0 { // Nothing to update
+		return nil
+	}
+
+	pr := pre.PullRequest
+
+	if !pr.Merged || pr.MergeSha == "" || pr.Base.Repo.Branch != pr.Base.Ref {
+		return nil
+	}
+
+	org := pr.Base.Repo.Namespace
+	repo := pr.Base.Repo.Name
+
+	// Which files changed in this PR?
+	changes, err := spc.GetPullRequestChanges(org, repo, pr.Number)
+	if err != nil {
+		return err
+	}
+
+	message := func(name, namespace string, updates []ConfigMapUpdate, indent string) string {
+		identifier := fmt.Sprintf("`%s` configmap", name)
+		if namespace != "" {
+			identifier = fmt.Sprintf("%s in namespace `%s`", identifier, namespace)
+		}
+		msg := fmt.Sprintf("%s using the following files:", identifier)
+		for _, u := range updates {
+			msg = fmt.Sprintf("%s\n%s- key `%s` using file `%s`", msg, indent, u.Key, u.Filename)
+		}
+		return msg
+	}
+
+	// Are any of the changes files ones that define a configmap we want to update?
+	toUpdate := FilterChanges(config, changes, log)
+
+	var updated []string
+	indent := " " // one space
+	if len(toUpdate) > 1 {
+		indent = "   " // three spaces for sub bullets
+	}
+	for cm, data := range toUpdate {
+		if cm.Namespace == "" {
+			cm.Namespace = defaultNamespace
+		}
+		logger := log.WithFields(logrus.Fields{"configmap": map[string]string{"name": cm.Name, "namespace": cm.Namespace}})
+		if err := Update(&scmFileGetter{org: org, repo: repo, commit: pr.MergeSha, client: spc}, kc.ConfigMaps(cm.Namespace), cm.Name, cm.Namespace, data, logger); err != nil {
+			return err
+		}
+		updated = append(updated, message(cm.Name, cm.Namespace, data, indent))
+	}
+
+	var msg string
+	switch n := len(updated); n {
+	case 0:
+		return nil
+	case 1:
+		msg = fmt.Sprintf("Updated the %s", updated[0])
+	default:
+		msg = fmt.Sprintf("Updated the following %d configmaps:\n", n)
+		for _, updateMsg := range updated {
+			msg += fmt.Sprintf(" * %s\n", updateMsg) // one space indent
+		}
+	}
+
+	if err := spc.CreateComment(org, repo, pr.Number, true, plugins.FormatResponseRaw(pr.Body, pr.Link, pr.Author.Login, msg)); err != nil {
+		return fmt.Errorf("comment err: %v", err)
+	}
+	return nil
+}
diff --git a/pkg/util/scmclient.go b/pkg/util/scmclient.go
index ce2bf071..5adc9e3b 100644
--- a/pkg/util/scmclient.go
+++ b/pkg/util/scmclient.go
@@ -9,6 +9,7 @@ import (
 	"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/lighthouse/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
@@ -54,9 +55,12 @@ func defaultScmTransport(scmClient *scm.Client) {
 }
 
 // GetGitServer returns the git server base URL from the environment
-func GetGitServer() string {
+func GetGitServer(cfg config.Getter) string {
 	serverURL := os.Getenv("GIT_SERVER")
 
+	if serverURL == "" && cfg != nil && cfg().ProviderConfig != nil {
+		serverURL = cfg().ProviderConfig.Server
+	}
 	if serverURL == "" {
 		serverURL = "https://github.com"
 	}
@@ -64,9 +68,9 @@ func GetGitServer() string {
 }
 
 // GetSCMClient gets the Lighthouse SCM client, go-scm client, server URL, and token for the current user and server
-func GetSCMClient(owner string) (scmprovider.SCMClient, *scm.Client, string, string, error) {
-	kind := GitKind()
-	serverURL := GetGitServer()
+func GetSCMClient(owner string, cfg config.Getter) (scmprovider.SCMClient, *scm.Client, string, string, error) {
+	kind := GitKind(cfg)
+	serverURL := GetGitServer(cfg)
 	ghaSecretDir := GetGitHubAppSecretDir()
 
 	var token string
@@ -86,13 +90,16 @@ func GetSCMClient(owner string) (scmprovider.SCMClient, *scm.Client, string, str
 	}
 
 	client, err := factory.NewClient(kind, serverURL, token)
-	scmClient := scmprovider.ToClient(client, GetBotName())
+	scmClient := scmprovider.ToClient(client, GetBotName(cfg))
 	return scmClient, client, serverURL, token, err
 }
 
 // GitKind gets the git kind from the environment
-func GitKind() string {
+func GitKind(cfg config.Getter) string {
 	kind := os.Getenv("GIT_KIND")
+	if kind == "" && cfg != nil && cfg().ProviderConfig != nil {
+		kind = cfg().ProviderConfig.Kind
+	}
 	if kind == "" {
 		kind = "github"
 	}
@@ -100,7 +107,7 @@ func GitKind() string {
 }
 
 // GetBotName returns the bot name from the environment
-func GetBotName() string {
+func GetBotName(cfg config.Getter) string {
 	if GetGitHubAppSecretDir() != "" {
 		ghaBotName, err := GetGitHubAppAPIUser()
 		// TODO: Probably should handle error cases here better, but for now, just fall through.
@@ -109,6 +116,9 @@ func GetBotName() string {
 		}
 	}
 	botName := os.Getenv("GIT_USER")
+	if botName == "" && cfg != nil && cfg().ProviderConfig != nil {
+		botName = cfg().ProviderConfig.BotUser
+	}
 	if botName == "" {
 		botName = "jenkins-x-bot"
 	}
diff --git a/pkg/util/stopper.go b/pkg/util/stopper.go
new file mode 100644
index 00000000..63e490fd
--- /dev/null
+++ b/pkg/util/stopper.go
@@ -0,0 +1,25 @@
+package util
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/sirupsen/logrus"
+)
+
+// 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/watcher/configmap_watcher.go b/pkg/watcher/configmap_watcher.go
index b6e8452b..4803ee9e 100644
--- a/pkg/watcher/configmap_watcher.go
+++ b/pkg/watcher/configmap_watcher.go
@@ -3,6 +3,10 @@ package watcher
 import (
 	"fmt"
 
+	"github.com/jenkins-x/lighthouse/pkg/clients"
+	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/plugins"
+	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 	v1 "k8s.io/api/core/v1"
@@ -37,6 +41,60 @@ type ConfigMapEntryCallback struct {
 	oldValue string
 }
 
+// SetupConfigMapWatchers takes a config agent and plugin agent, each potentially nil, and sets up the appropriate watchers for them.
+func SetupConfigMapWatchers(ns string, configAgent *config.Agent, pluginAgent *plugins.ConfigAgent) (*ConfigMapWatcher, error) {
+	var callbacks []ConfigMapCallback
+
+	if configAgent != nil {
+		onConfigYamlChange := func(text string) {
+			if text != "" {
+				loadedConfig, 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(loadedConfig)
+				}
+			}
+		}
+		callbacks = append(callbacks, &ConfigMapEntryCallback{
+			Name:     util.ProwConfigMapName,
+			Key:      util.ProwConfigFilename,
+			Callback: onConfigYamlChange,
+		})
+	}
+
+	if pluginAgent != nil {
+		onPluginsYamlChange := func(text string) {
+			if text != "" {
+				config, err := pluginAgent.LoadYAMLConfig([]byte(text))
+				if err != nil {
+					logrus.WithError(err).Error("Error processing the prow Plugins YAML")
+				} else {
+					logrus.Info("updating the prow plugins configuration")
+					pluginAgent.Set(config)
+				}
+			}
+		}
+		callbacks = append(callbacks, &ConfigMapEntryCallback{
+			Name:     util.ProwPluginsConfigMapName,
+			Key:      util.ProwPluginsFilename,
+			Callback: onPluginsYamlChange,
+		})
+	}
+
+	// No watch to configure
+	if len(callbacks) == 0 {
+		return nil, nil
+	}
+	_, kubeClient, _, _, err := clients.GetAPIClients()
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to create Kube client")
+	}
+
+	return NewConfigMapWatcher(kubeClient, ns, callbacks, util.Stopper())
+}
+
 // OnChange invokes the callback function if the value is not empty and changes
 func (cb *ConfigMapEntryCallback) OnChange(configMap *v1.ConfigMap) {
 	if cb.Name == configMap.Name {
diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
index b93bb0c3..b2ffeecb 100644
--- a/pkg/webhook/webhook.go
+++ b/pkg/webhook/webhook.go
@@ -6,11 +6,8 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/url"
-	"os"
-	"os/signal"
 	"strconv"
 	"strings"
-	"syscall"
 
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
@@ -94,8 +91,9 @@ func (o *Options) Run() error {
 	}
 	defer o.configMapWatcher.Stop()
 
-	o.gitServerURL = util.GetGitServer()
-	gitClient, err := git.NewClient(o.gitServerURL, util.GitKind())
+	cfg := o.server.ConfigAgent.Config
+	o.gitServerURL = util.GetGitServer(cfg)
+	gitClient, err := git.NewClient(o.gitServerURL, util.GitKind(cfg))
 	if err != nil {
 		logrus.WithError(err).Fatal("Error getting git client.")
 	}
@@ -167,6 +165,8 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request)
 	}
 	logrus.Debug("about to parse webhook")
 
+	cfg := o.server.ConfigAgent.Config
+
 	bodyBytes, err := ioutil.ReadAll(r.Body)
 	if err != nil {
 		logrus.Errorf("failed to Read Body: %s", err.Error())
@@ -182,7 +182,7 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request)
 	}
 
 	r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
-	_, scmClient, serverURL, _, err := util.GetSCMClient("")
+	_, scmClient, serverURL, _, err := util.GetSCMClient("", cfg)
 	if err != nil {
 		logrus.Errorf("failed to create SCM scmClient: %s", err.Error())
 		responseHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("500 Internal Server Error: Failed to parse webhook: %s", err.Error()))
@@ -217,8 +217,8 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request)
 			return
 		}
 	} else {
-		gitCloneUser = util.GetBotName()
-		token, err = util.GetSCMToken(util.GitKind())
+		gitCloneUser = util.GetBotName(cfg)
+		token, err = util.GetSCMToken(util.GitKind(cfg))
 		if err != nil {
 			logrus.Errorf("no scm token specified: %s", err.Error())
 			responseHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("500 Internal Server Error: no scm token specified: %s", err.Error()))
@@ -236,7 +236,7 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request)
 	util.AddAuthToSCMClient(scmClient, token, ghaSecretDir != "")
 
 	o.server.ClientAgent = &plugins.ClientAgent{
-		BotName:           util.GetBotName(),
+		BotName:           util.GetBotName(cfg),
 		SCMProviderClient: scmClient,
 		KubernetesClient:  kubeClient,
 		GitClient:         o.gitClient,
@@ -408,48 +408,8 @@ func (o *Options) createHookServer() (*Server, error) {
 	configAgent := &config.Agent{}
 	pluginAgent := &plugins.ConfigAgent{}
 
-	onConfigYamlChange := func(text string) {
-		if text != "" {
-			config, 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(config)
-			}
-		}
-	}
-
-	onPluginsYamlChange := func(text string) {
-		if text != "" {
-			config, err := pluginAgent.LoadYAMLConfig([]byte(text))
-			if err != nil {
-				logrus.WithError(err).Error("Error processing the prow Plugins YAML")
-			} else {
-				logrus.Info("updating the prow plugins configuration")
-				pluginAgent.Set(config)
-			}
-		}
-	}
-
-	_, kubeClient, _, _, err := clients.GetAPIClients()
-	if err != nil {
-		return nil, errors.Wrapf(err, "failed to create Kube client")
-	}
-
-	callbacks := []watcher.ConfigMapCallback{
-		&watcher.ConfigMapEntryCallback{
-			Name:     util.ProwConfigMapName,
-			Key:      util.ProwConfigFilename,
-			Callback: onConfigYamlChange,
-		},
-		&watcher.ConfigMapEntryCallback{
-			Name:     util.ProwPluginsConfigMapName,
-			Key:      util.ProwPluginsFilename,
-			Callback: onPluginsYamlChange,
-		},
-	}
-	o.configMapWatcher, err = watcher.NewConfigMapWatcher(kubeClient, o.namespace, callbacks, stopper())
+	var err error
+	o.configMapWatcher, err = watcher.SetupConfigMapWatchers(o.namespace, configAgent, pluginAgent)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to create ConfigMap watcher")
 	}
@@ -491,19 +451,3 @@ func responseHTTPError(w http.ResponseWriter, statusCode int, response string) {
 	}).Info(response)
 	http.Error(w, response, statusCode)
 }
-
-// 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
-}


Test patch:
diff --git a/pkg/plugins/config_test.go b/pkg/plugins/config_test.go
index 93f246f6..04a349b1 100644
--- a/pkg/plugins/config_test.go
+++ b/pkg/plugins/config_test.go
@@ -20,6 +20,8 @@ import (
 	"errors"
 	"reflect"
 	"testing"
+
+	"k8s.io/utils/diff"
 )
 
 func TestValidateExternalPlugins(t *testing.T) {
@@ -166,20 +168,21 @@ func TestSetDefault_Maps(t *testing.T) {
 		},
 	}
 	for _, tc := range cases {
-		cfg := Configuration{
-			ConfigUpdater: tc.config,
-		}
-		cfg.setDefaults()
-		actual := cfg.ConfigUpdater.Maps
-		if len(actual) != len(tc.expected) {
-			t.Errorf("%s: actual and expected have different keys: %v %v", tc.name, actual, tc.expected)
-			continue
-		}
-		for k, n := range tc.expected {
-			if an := actual[k]; !reflect.DeepEqual(an, n) {
-				t.Errorf("%s - %s: expected %s != actual %s", tc.name, k, n, an)
+		t.Run(tc.name, func(t *testing.T) {
+			cfg := Configuration{
+				ConfigUpdater: tc.config,
 			}
-		}
+			cfg.setDefaults()
+			actual := cfg.ConfigUpdater.Maps
+			if len(actual) != len(tc.expected) {
+				t.Fatalf("%s: actual and expected have different keys: %v %v", tc.name, actual, tc.expected)
+			}
+			for k, n := range tc.expected {
+				if an := actual[k]; !reflect.DeepEqual(an, n) {
+					t.Errorf("%s - %s: unexpected value. Diff: %v", tc.name, k, diff.ObjectReflectDiff(an, n))
+				}
+			}
+		})
 	}
 }
 
diff --git a/pkg/plugins/updateconfig/updateconfig_test.go b/pkg/plugins/updateconfig/updateconfig_test.go
new file mode 100644
index 00000000..261fb886
--- /dev/null
+++ b/pkg/plugins/updateconfig/updateconfig_test.go
@@ -0,0 +1,1142 @@
+/*
+Copyright 2017 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package updateconfig
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/lighthouse/pkg/plugins"
+	fake2 "github.com/jenkins-x/lighthouse/pkg/scmprovider/fake"
+	"github.com/sirupsen/logrus"
+	coreapi "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/diff"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/kubernetes/fake"
+	clienttesting "k8s.io/client-go/testing"
+)
+
+const defaultNamespace = "default"
+
+func TestUpdateConfig(t *testing.T) {
+	basicPR := scm.PullRequest{
+		Number: 1,
+		Base: scm.PullRequestBranch{
+			Repo: scm.Repository{
+				Namespace: "kubernetes",
+				Name:      "kubernetes",
+			},
+		},
+		Author: scm.User{
+			Login: "foo",
+		},
+	}
+
+	testcases := []struct {
+		name               string
+		prAction           scm.Action
+		merged             bool
+		mergeCommit        string
+		changes            []*scm.Change
+		existConfigMaps    []runtime.Object
+		expectedConfigMaps []*coreapi.ConfigMap
+		config             *plugins.ConfigUpdater
+	}{
+		{
+			name:     "Opened PR, no update",
+			prAction: scm.ActionOpen,
+			merged:   false,
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+		},
+		{
+			name:   "Opened PR, not merged, no update",
+			merged: false,
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+		},
+		{
+			name:     "Closed PR, no prow changes, no update",
+			prAction: scm.ActionClose,
+			merged:   false,
+			changes: []*scm.Change{
+				{
+					Path:      "foo.txt",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+		},
+		{
+			name:     "For whatever reason no merge commit SHA",
+			prAction: scm.ActionClose,
+			merged:   true,
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+		},
+		{
+			name:        "changed config.yaml, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "old-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "changed config.yaml, existed configmap, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "old-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "changed plugins.yaml, 1 update with custom key",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/plugins.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"test-key": "old-plugins",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"test-key": "new-plugins",
+					},
+				},
+			},
+		},
+		{
+			name:        "changed resources.yaml, 1 update with custom namespace",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "boskos/resources.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "boskos-config",
+						Namespace: "boskos",
+					},
+					Data: map[string]string{
+						"resources.yaml": "old-boskos-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "boskos-config",
+						Namespace: "boskos",
+					},
+					Data: map[string]string{
+						"resources.yaml": "new-boskos-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "changed config.yaml, plugins.yaml and resources.yaml, 3 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/plugins.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "boskos/resources.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "old-config",
+					},
+				},
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"test-key": "old-plugins",
+					},
+				},
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "boskos-config",
+						Namespace: "boskos",
+					},
+					Data: map[string]string{
+						"resources.yaml": "old-boskos-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"test-key": "new-plugins",
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "boskos-config",
+						Namespace: "boskos",
+					},
+					Data: map[string]string{
+						"resources.yaml": "new-boskos-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "edited both config/foo.yaml and config/bar.yaml, 2 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "config/foo.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "config/bar.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"foo.yaml": "old-foo-config",
+						"bar.yaml": "old-bar-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"foo.yaml": "new-foo-config",
+						"bar.yaml": "new-bar-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "edited config/foo.yaml, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "config/foo.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"foo.yaml": "old-foo-config",
+						"bar.yaml": "old-bar-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"foo.yaml": "new-foo-config",
+						"bar.yaml": "old-bar-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "remove config/foo.yaml, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:    "config/foo.yaml",
+					Deleted: true,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"foo.yaml": "old-foo-config",
+						"bar.yaml": "old-bar-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "multikey-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"bar.yaml": "old-bar-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "edited dir/subdir/fejtaverse/krzyzacy.yaml, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "dir/subdir/fejtaverse/krzyzacy.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"fejta.yaml":    "old-fejta-config",
+						"krzyzacy.yaml": "old-krzyzacy-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"fejta.yaml":    "old-fejta-config",
+						"krzyzacy.yaml": "new-krzyzacy-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "renamed dir/subdir/fejtaverse/krzyzacy.yaml, 1 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "54321",
+			changes: []*scm.Change{
+				{
+					Path:         "dir/subdir/fejtaverse/fejtabot.yaml",
+					PreviousPath: "dir/subdir/fejtaverse/krzyzacy.yaml",
+					Renamed:      true,
+					Additions:    1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"krzyzacy.yaml": "old-krzyzacy-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"fejtabot.yaml": "new-fejtabot-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "add delete edit glob config, 3 update",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "dir/subdir/fejta.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "dir/subdir/fejtaverse/sig-foo/added.yaml",
+					Added:     true,
+					Additions: 1,
+				},
+				{
+					Path:    "dir/subdir/fejtaverse/sig-bar/removed.yaml",
+					Deleted: true,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"fejta.yaml":    "old-fejta-config",
+						"krzyzacy.yaml": "old-krzyzacy-config",
+						"removed.yaml":  "old-removed-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "glob-config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"fejta.yaml":    "new-fejta-config",
+						"krzyzacy.yaml": "old-krzyzacy-config",
+						"added.yaml":    "new-added-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "config changes without a backing configmap causes creation",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+				},
+			},
+		},
+		{
+			name:        "gzips all content if the top level gzip flag is set",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"config.yaml": {31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 202, 75, 45, 215, 77, 206, 207, 75, 203, 76, 7, 4, 0, 0, 255, 255, 84, 214, 231, 87, 10, 0, 0, 0},
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				GZIP: true,
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/config.yaml": {
+						Name: "config",
+					},
+					"prow/plugins.yaml": {
+						Name: "plugins",
+						Key:  "test-key",
+					},
+				},
+			},
+		},
+		{
+			name:        "gzips all content except one marked false if the top level gzip flag is set",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "prow/plugins.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"config.yaml": {31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 202, 75, 45, 215, 77, 206, 207, 75, 203, 76, 7, 4, 0, 0, 255, 255, 84, 214, 231, 87, 10, 0, 0, 0},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"plugins.yaml": "new-plugins",
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				GZIP: true,
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/config.yaml": {
+						Name: "config",
+					},
+					"prow/plugins.yaml": {
+						Name: "plugins",
+						GZIP: boolPtr(false),
+					},
+				},
+			},
+		},
+		{
+			name:        "gzips only one marked file if the top level gzip flag is set to false",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "prow/plugins.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"config.yaml": {31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 202, 75, 45, 215, 77, 206, 207, 75, 203, 76, 7, 4, 0, 0, 255, 255, 84, 214, 231, 87, 10, 0, 0, 0},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "plugins",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"plugins.yaml": "new-plugins",
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				GZIP: false,
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/config.yaml": {
+						Name: "config",
+						GZIP: boolPtr(true),
+					},
+					"prow/plugins.yaml": {
+						Name: "plugins",
+					},
+				},
+			},
+		},
+		{
+			name:        "adds both binary and text keys for a single configmap",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "prow/binary.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+					BinaryData: map[string][]byte{
+						"binary.yaml": []byte("new-binary\x00\xFF\xFF"),
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+		{
+			name:        "converts a text key to a binary key when it becomes binary",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/becoming-binary.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"becoming-binary.yaml": "not-yet-binary",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"becoming-binary.yaml": []byte("now-binary\x00\xFF\xFF"),
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+		{
+			name:        "converts a binary key to a text key when it becomes text",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/becoming-text.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"becoming-text.yaml": []byte("not-yet-text\x00\xFF\xFF"),
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"becoming-text.yaml": "now-text",
+					},
+					BinaryData: map[string][]uint8{},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+		{
+			name:        "simultaneously converts text to binary and binary to text",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/becoming-text.yaml",
+					Additions: 1,
+				},
+				{
+					Path:      "prow/becoming-binary.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"becoming-text.yaml": []byte("not-yet-text\x00\xFF\xFF"),
+					},
+					Data: map[string]string{
+						"becoming-binary.yaml": "not-yet-binary",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"becoming-binary.yaml": []byte("now-binary\x00\xFF\xFF"),
+					},
+					Data: map[string]string{
+						"becoming-text.yaml": "now-text",
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+		{
+			name:        "correctly converts to binary when gzipping",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "old-config",
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"config.yaml": {31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 202, 75, 45, 215, 77, 206, 207, 75, 203, 76, 7, 4, 0, 0, 255, 255, 84, 214, 231, 87, 10, 0, 0, 0},
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				GZIP: true,
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+		{
+			name:        "correctly converts to text when ungzipping",
+			prAction:    scm.ActionClose,
+			merged:      true,
+			mergeCommit: "12345",
+			changes: []*scm.Change{
+				{
+					Path:      "prow/config.yaml",
+					Additions: 1,
+				},
+			},
+			existConfigMaps: []runtime.Object{
+				&coreapi.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					BinaryData: map[string][]byte{
+						"config.yaml": {31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 202, 75, 45, 215, 77, 206, 207, 75, 203, 76, 7, 4, 0, 0, 255, 255, 84, 214, 231, 87, 10, 0, 0, 0},
+					},
+				},
+			},
+			expectedConfigMaps: []*coreapi.ConfigMap{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "config",
+						Namespace: defaultNamespace,
+					},
+					Data: map[string]string{
+						"config.yaml": "new-config",
+					},
+				},
+			},
+			config: &plugins.ConfigUpdater{
+				GZIP: false,
+				Maps: map[string]plugins.ConfigMapSpec{
+					"prow/*.yaml": {
+						Name: "config",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			log := logrus.WithField("plugin", pluginName)
+			event := scm.PullRequestHook{
+				Action:      tc.prAction,
+				PullRequest: basicPR,
+			}
+			event.PullRequest.Merged = tc.merged
+			if tc.mergeCommit != "" {
+				event.PullRequest.MergeSha = tc.mergeCommit
+			}
+
+			fspc := &fake2.SCMClient{
+				PullRequests: map[int]*scm.PullRequest{
+					basicPR.Number: &basicPR,
+				},
+				PullRequestChanges: map[int][]*scm.Change{
+					basicPR.Number: tc.changes,
+				},
+				PullRequestComments: map[int][]*scm.Comment{},
+				RemoteFiles: map[string]map[string]string{
+					"prow/config.yaml": {
+						"master": "old-config",
+						"12345":  "new-config",
+					},
+					"prow/binary.yaml": {
+						"master": "old-binary\x00\xFF\xFF",
+						"12345":  "new-binary\x00\xFF\xFF",
+					},
+					"prow/becoming-binary.yaml": {
+						"master": "not-yet-binary",
+						"12345":  "now-binary\x00\xFF\xFF",
+					},
+					"prow/becoming-text.yaml": {
+						"master": "not-yet-text\x00\xFF\xFF",
+						"12345":  "now-text",
+					},
+					"prow/plugins.yaml": {
+						"master": "old-plugins",
+						"12345":  "new-plugins",
+					},
+					"boskos/resources.yaml": {
+						"master": "old-boskos-config",
+						"12345":  "new-boskos-config",
+					},
+					"config/foo.yaml": {
+						"master": "old-foo-config",
+						"12345":  "new-foo-config",
+					},
+					"config/bar.yaml": {
+						"master": "old-bar-config",
+						"12345":  "new-bar-config",
+					},
+					"dir/subdir/fejta.yaml": {
+						"master": "old-fejta-config",
+						"12345":  "new-fejta-config",
+					},
+					"dir/subdir/fejtaverse/krzyzacy.yaml": {
+						"master": "old-krzyzacy-config",
+						"12345":  "new-krzyzacy-config",
+					},
+					"dir/subdir/fejtaverse/fejtabot.yaml": {
+						"54321": "new-fejtabot-config",
+					},
+					"dir/subdir/fejtaverse/sig-foo/added.yaml": {
+						"12345": "new-added-config",
+					},
+					"dir/subdir/fejtaverse/sig-bar/removed.yaml": {
+						"master": "old-removed-config",
+					},
+				},
+			}
+			fkc := fake.NewSimpleClientset(tc.existConfigMaps...)
+
+			m := tc.config
+			if m == nil {
+				m = &plugins.ConfigUpdater{
+					Maps: map[string]plugins.ConfigMapSpec{
+						"prow/config.yaml": {
+							Name: "config",
+						},
+						"prow/plugins.yaml": {
+							Name: "plugins",
+							Key:  "test-key",
+						},
+						"boskos/resources.yaml": {
+							Name:      "boskos-config",
+							Namespace: "boskos",
+						},
+						"config/foo.yaml": {
+							Name: "multikey-config",
+						},
+						"config/bar.yaml": {
+							Name: "multikey-config",
+						},
+						"dir/subdir/**/*.yaml": {
+							Name: "glob-config",
+						},
+					},
+				}
+			}
+			m.SetDefaults()
+
+			if err := handle(fspc, fkc.CoreV1(), defaultNamespace, log, event, *m); err != nil {
+				t.Fatalf("%s: unexpected error handling: %s", tc.name, err)
+			}
+
+			modifiedConfigMaps := sets.NewString()
+			for _, action := range fkc.Fake.Actions() {
+				var obj runtime.Object
+				switch action := action.(type) {
+				case clienttesting.CreateActionImpl:
+					obj = action.Object
+				case clienttesting.UpdateActionImpl:
+					obj = action.Object
+				default:
+					continue
+				}
+				objectMeta, err := meta.Accessor(obj)
+				if err != nil {
+					t.Fatalf("%s: client saw an action for something that wasn't an object: %v", tc.name, err)
+				}
+				modifiedConfigMaps.Insert(objectMeta.GetName())
+			}
+
+			if tc.expectedConfigMaps != nil {
+				if len(fspc.PullRequestComments[basicPR.Number]) != 1 {
+					t.Errorf("%s: Expect 1 comment, actually got %d", tc.name, len(fspc.PullRequestComments[basicPR.Number]))
+				} else {
+					comment := fspc.PullRequestComments[basicPR.Number][0].Body
+					if !strings.Contains(comment, "Updated the") {
+						t.Errorf("%s: missing Updated the from %s", tc.name, comment)
+					}
+					for _, configMap := range tc.expectedConfigMaps {
+						if modifiedConfigMaps.Has(configMap.Name) {
+							if !strings.Contains(comment, configMap.Name) {
+								t.Errorf("%s: missing %s from %s", tc.name, configMap.Name, comment)
+							}
+						} else if strings.Contains(comment, configMap.Name) {
+							t.Errorf("%s: should not contain %s in %s", tc.name, configMap.Name, comment)
+						}
+					}
+				}
+			}
+
+			expectedConfigMaps := sets.NewString()
+			for _, configMap := range tc.expectedConfigMaps {
+				expectedConfigMaps.Insert(configMap.Name)
+			}
+			if missing := expectedConfigMaps.Difference(modifiedConfigMaps); missing.Len() > 0 {
+				t.Errorf("%s: did not update expected configmaps: %v", tc.name, missing.List())
+			}
+			if extra := modifiedConfigMaps.Difference(expectedConfigMaps); extra.Len() > 0 {
+				t.Errorf("%s: found unexpectedly updated configmaps: %v", tc.name, extra.List())
+			}
+
+			for _, expected := range tc.expectedConfigMaps {
+				actual, err := fkc.CoreV1().ConfigMaps(expected.Namespace).Get(expected.Name, metav1.GetOptions{})
+				if err != nil && errors.IsNotFound(err) {
+					t.Errorf("%s: Should have updated or created configmap for '%s'", tc.name, expected)
+				} else if !equality.Semantic.DeepEqual(expected, actual) {
+					t.Errorf("%s: incorrect ConfigMap state after update: %v", tc.name, diff.ObjectReflectDiff(expected, actual))
+				}
+			}
+		})
+	}
+}
+
+func boolPtr(b bool) *bool {
+	return &b
+}
diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go
index 1bc228a2..15f32366 100644
--- a/pkg/webhook/webhook_test.go
+++ b/pkg/webhook/webhook_test.go
@@ -141,12 +141,12 @@ func (suite *WebhookTestSuite) SetupSuite() {
 	var objs []runtime.Object
 	kubeClient := kubefake.NewSimpleClientset(objs...)
 	lhClient := fake.NewSimpleClientset()
-	_, scmClient, serverURL, _, err := util.GetSCMClient("")
+	_, scmClient, serverURL, _, err := util.GetSCMClient("", configAgent.Config)
 	assert.NoError(t, err)
-	gitClient, err := git.NewClient(serverURL, util.GitKind())
+	gitClient, err := git.NewClient(serverURL, util.GitKind(configAgent.Config))
 	assert.NoError(t, err)
-	user := util.GetBotName()
-	token, err := util.GetSCMToken(util.GitKind())
+	user := util.GetBotName(configAgent.Config)
+	token, err := util.GetSCMToken(util.GitKind(configAgent.Config))
 	gitClient.SetCredentials(user, func() []byte {
 		return []byte(token)
 	})
@@ -156,7 +156,7 @@ func (suite *WebhookTestSuite) SetupSuite() {
 			ConfigAgent: configAgent,
 			Plugins:     pluginAgent,
 			ClientAgent: &plugins.ClientAgent{
-				BotName:           util.GetBotName(),
+				BotName:           user,
 				SCMProviderClient: scmClient,
 				KubernetesClient:  kubeClient,
 				GitClient:         gitClient,