test: add toolkit for forked projects
diff --git a/docs/developer/repos.md b/docs/developer/repos.md
new file mode 100644
index 0000000..28c4461
--- /dev/null
+++ b/docs/developer/repos.md
@@ -0,0 +1,51 @@
+# Repositories
+
+Atmosphere uses a few different Git repositories to host the code for the
+project.  This document explains how to work with the different repositories,
+their purpose, and how to maintain them.
+
+## OpenStack
+
+Atmosphere has a few forks of the OpenStack repositories.  These are used to
+apply patches to the upstream code that contain fixes which have not yet been
+merged upstream.  The list of forked repositories is as follows:
+
+* [openstack/horizon](https://github.com/vexxhost/horizon)
+
+### Creating a new fork
+
+In order to create a new fork of an OpenStack repository, we'll need to create
+a fork under the `vexxhost` organization.  In this example, we'll assume that
+you're creating a fork of the Horizon project.
+
+In order to fork the project, you'll start with the following command which
+assumes that you have the `gh` command line tool installed:
+
+```bash
+./hack/repos/openstack/fork horizon
+```
+
+Once you're done, you'll also need to update the `FORKED_PROJECTS` variable in
+the `internal/pkg/image_repositories/build_workflow.go` file to include the
+newly forked project.
+
+### Applying patches
+
+The only time that it is necessary to apply patches to the forked repositories
+is when there is a fix that has not yet been merged upstream.  In order to
+apply a patch, you can use the following command which includes the project
+name and the Gerrit patch number:
+
+```bash
+./hack/repos/openstack/patch horizon 874351
+```
+
+This command will take care of automatically cloning the project, downloading
+the patch, and applying it to the repository.  Once the patch has been applied,
+it will push it in a new branch to the forked repository and create a pull
+request.
+
+> *Note**
+>
+> If the process fails because of a merge conflict, you'll need to resolve the
+> conflict and then run the command again.
diff --git a/hack/repos/openstack/cherry-pick b/hack/repos/openstack/cherry-pick
new file mode 100755
index 0000000..af1508a
--- /dev/null
+++ b/hack/repos/openstack/cherry-pick
@@ -0,0 +1,29 @@
+#!/bin/bash -xe
+
+# Clone the repository in a temporary directory if it doesn't exist
+if [ ! -d "/tmp/openstack-${1}" ]; then
+  gh repo clone vexxhost/${1} /tmp/openstack-${1}
+fi
+
+# Switch to the repository
+cd /tmp/openstack-${1}
+
+# Update the repository
+git fetch origin
+
+# Switch to the branch that we're cherry-picking into
+git checkout -B backport/${3}/${2} origin/${3}
+
+# Cherry-pick the change
+git cherry-pick -x ${2}
+
+# Push this branch to the remote
+git push -u origin backport/${3}/${2}
+
+# Create a PR for this change
+gh repo set-default vexxhost/${1}
+gh pr create \
+  --title "[${3}] $(git show -s --format=%s)" \
+  --body "$(git show -s --format=%B)" \
+  --base ${3} \
+  --head backport/${3}/${2}
diff --git a/hack/repos/openstack/fork b/hack/repos/openstack/fork
new file mode 100755
index 0000000..2daa1fe
--- /dev/null
+++ b/hack/repos/openstack/fork
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# gh repo fork openstack/${1} --org vexxhost
+
+REPOSITORY_ID_QUERY="{repository(owner: \"vexxhost\", name: \"${1}\"){id}}"
+REPOSITORY_ID=$(gh api graphql -f query="${REPOSITORY_ID_QUERY}" -q .data.repository.id)
+
+gh api graphql -f query='
+mutation($repositoryId:ID!) {
+  createBranchProtectionRule(input: {
+    repositoryId: $repositoryId
+    pattern: "master"
+    requiresApprovingReviews: true
+    requiredApprovingReviewCount: 1
+    requiresConversationResolution: true
+    requiresLinearHistory: true
+  }) { clientMutationId }
+}' -f repositoryId=${REPOSITORY_ID}
+
+gh api graphql -f query='
+mutation($repositoryId:ID!) {
+  createBranchProtectionRule(input: {
+    repositoryId: $repositoryId
+    pattern: "stable/*"
+    requiresApprovingReviews: true
+    requiredApprovingReviewCount: 1
+    requiresConversationResolution: true
+    requiresLinearHistory: true
+  }) { clientMutationId }
+}' -f repositoryId=${REPOSITORY_ID}
diff --git a/hack/repos/openstack/patch b/hack/repos/openstack/patch
new file mode 100755
index 0000000..c3445b7
--- /dev/null
+++ b/hack/repos/openstack/patch
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+# Clone the repository in a temporary directory
+mkdir -p /tmp/openstack-${1}-${2}
+gh repo clone vexxhost/${1} /tmp/openstack-${1}-${2}
+
+# Switch to the repository
+cd /tmp/openstack-${1}-${2}
+
+# Create a new branch
+git checkout -b opendev/${2}
+
+# Detect the most recent revision of the change
+LATEST_REV=$(git ls-remote https://opendev.org/openstack/${1} | grep -E "refs/changes/[[:digit:]]+/${2}/" | sort -t / -k 5 -g | tail -n1 | awk '{print $2}')
+
+# Cherry-pick the change
+git fetch https://review.opendev.org/openstack/${1} ${LATEST_REV} && git cherry-pick FETCH_HEAD
+
+# Push this branch to the remote
+git push -u origin opendev/${2}
+
+# Create a PR for this change
+gh repo set-default vexxhost/${1}
+gh pr create \
+  --title "$(git show -s --format=%s)" \
+  --body "$(git show -s --format=%B)" \
+  --base master \
+  --head opendev/${2}
diff --git a/internal/pkg/image_repositories/build_workflow.go b/internal/pkg/image_repositories/build_workflow.go
index 9b3ed94..dca7711 100644
--- a/internal/pkg/image_repositories/build_workflow.go
+++ b/internal/pkg/image_repositories/build_workflow.go
@@ -7,6 +7,7 @@
 
 var FORKED_PROJECTS map[string]bool = map[string]bool{
 	"keystone": true,
+	"horizon":  true,
 }
 var EXTRAS map[string]string = map[string]string{}
 var PROFILES map[string]string = map[string]string{