diff --git a/package-lock.json b/package-lock.json
index d96f072047706163159ef826143b5ee939c4416d..194524c2b12f896c6f20fe955e3f1186b4b26b28 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,10 +14,12 @@
         "@octokit/plugin-retry": "^3.0.3",
         "@octokit/plugin-throttling": "^3.3.0",
         "@octokit/rest": "^18.0.3",
+        "@types/got": "^9.6.12",
         "@types/node": "^17.0.32",
         "@types/yargs": "^17.0.10",
         "axios": "^0.21.4",
         "flat-cache": "^2.0.1",
+        "got": "^12.0.4",
         "nodemailer": "^6.5.0",
         "openid-client": "^3.15.6",
         "parse-link-header": "^2.0.0",
@@ -738,6 +740,17 @@
       "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
       "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
     },
+    "node_modules/@sindresorhus/is": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+      "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
     "node_modules/@sinonjs/commons": {
       "version": "1.8.3",
       "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
@@ -773,6 +786,28 @@
       "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==",
       "dev": true
     },
+    "node_modules/@szmarczak/http-timer": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
+      "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
+      "dependencies": {
+        "defer-to-connect": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=14.16"
+      }
+    },
+    "node_modules/@types/cacheable-request": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
+      "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==",
+      "dependencies": {
+        "@types/http-cache-semantics": "*",
+        "@types/keyv": "*",
+        "@types/node": "*",
+        "@types/responselike": "*"
+      }
+    },
     "node_modules/@types/got": {
       "version": "9.6.12",
       "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.12.tgz",
@@ -796,6 +831,24 @@
         "node": ">= 0.12"
       }
     },
+    "node_modules/@types/http-cache-semantics": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
+      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+    },
+    "node_modules/@types/json-buffer": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz",
+      "integrity": "sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ=="
+    },
+    "node_modules/@types/keyv": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+      "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "17.0.32",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -807,6 +860,14 @@
       "integrity": "sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==",
       "dev": true
     },
+    "node_modules/@types/responselike": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
+      "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/simple-oauth2": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-4.1.1.tgz",
@@ -1065,6 +1126,31 @@
         "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
       }
     },
+    "node_modules/cacheable-lookup": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
+      "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==",
+      "engines": {
+        "node": ">=10.6.0"
+      }
+    },
+    "node_modules/cacheable-request": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
+      "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
+      "dependencies": {
+        "clone-response": "^1.0.2",
+        "get-stream": "^5.1.0",
+        "http-cache-semantics": "^4.0.0",
+        "keyv": "^4.0.0",
+        "lowercase-keys": "^2.0.0",
+        "normalize-url": "^6.0.1",
+        "responselike": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -1300,6 +1386,18 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/compress-brotli": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz",
+      "integrity": "sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==",
+      "dependencies": {
+        "@types/json-buffer": "~3.0.0",
+        "json-buffer": "~3.0.1"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1368,6 +1466,31 @@
         "node": ">=0.10"
       }
     },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/decompress-response/node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/deep-eql": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
@@ -1386,6 +1509,14 @@
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "node_modules/defer-to-connect": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+      "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/define-properties": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -2051,6 +2182,11 @@
         "node": ">= 6"
       }
     },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz",
+      "integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg=="
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -2190,6 +2326,62 @@
         "node": ">=4"
       }
     },
+    "node_modules/got": {
+      "version": "12.0.4",
+      "resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
+      "integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
+      "dependencies": {
+        "@sindresorhus/is": "^4.6.0",
+        "@szmarczak/http-timer": "^5.0.1",
+        "@types/cacheable-request": "^6.0.2",
+        "@types/responselike": "^1.0.0",
+        "cacheable-lookup": "^6.0.4",
+        "cacheable-request": "^7.0.2",
+        "decompress-response": "^6.0.0",
+        "form-data-encoder": "1.7.1",
+        "get-stream": "^6.0.1",
+        "http2-wrapper": "^2.1.10",
+        "lowercase-keys": "^3.0.0",
+        "p-cancelable": "^3.0.0",
+        "responselike": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/got?sponsor=1"
+      }
+    },
+    "node_modules/got/node_modules/get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/got/node_modules/lowercase-keys": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
+      "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/got/node_modules/p-cancelable": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
+      "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
+      "engines": {
+        "node": ">=12.20"
+      }
+    },
     "node_modules/growl": {
       "version": "1.10.5",
       "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
@@ -2268,6 +2460,18 @@
       "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
       "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
     },
+    "node_modules/http2-wrapper": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
+      "integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
+      "dependencies": {
+        "quick-lru": "^5.1.1",
+        "resolve-alpn": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=10.19.0"
+      }
+    },
     "node_modules/ignore": {
       "version": "4.0.6",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
@@ -2655,6 +2859,11 @@
         "node": ">=4"
       }
     },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+    },
     "node_modules/json-schema-traverse": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -2686,6 +2895,15 @@
       "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==",
       "dev": true
     },
+    "node_modules/keyv": {
+      "version": "4.2.7",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.2.7.tgz",
+      "integrity": "sha512-HeOstD8SXvtWoQhMMBCelcUuZsiV7T7MwsADtOXT0KuwYP9nCxrSoMDeLXNDTLN3VFSuRp38JzoGbbTboq3QQw==",
+      "dependencies": {
+        "compress-brotli": "^1.3.8",
+        "json-buffer": "3.0.1"
+      }
+    },
     "node_modules/kuler": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz",
@@ -3081,6 +3299,17 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/normalize-url": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+      "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/object-hash": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -3563,6 +3792,17 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
@@ -3622,6 +3862,11 @@
       "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
       "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
     },
+    "node_modules/resolve-alpn": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+      "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+    },
     "node_modules/resolve-from": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3631,6 +3876,14 @@
         "node": ">=4"
       }
     },
+    "node_modules/responselike": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz",
+      "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==",
+      "dependencies": {
+        "lowercase-keys": "^2.0.0"
+      }
+    },
     "node_modules/rimraf": {
       "version": "2.6.3",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
@@ -5067,6 +5320,11 @@
       "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
       "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
     },
+    "@sindresorhus/is": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+      "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
+    },
     "@sinonjs/commons": {
       "version": "1.8.3",
       "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
@@ -5102,6 +5360,25 @@
       "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==",
       "dev": true
     },
+    "@szmarczak/http-timer": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
+      "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
+      "requires": {
+        "defer-to-connect": "^2.0.1"
+      }
+    },
+    "@types/cacheable-request": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
+      "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==",
+      "requires": {
+        "@types/http-cache-semantics": "*",
+        "@types/keyv": "*",
+        "@types/node": "*",
+        "@types/responselike": "*"
+      }
+    },
     "@types/got": {
       "version": "9.6.12",
       "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.12.tgz",
@@ -5124,6 +5401,24 @@
         }
       }
     },
+    "@types/http-cache-semantics": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
+      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+    },
+    "@types/json-buffer": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz",
+      "integrity": "sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ=="
+    },
+    "@types/keyv": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+      "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/node": {
       "version": "17.0.32",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -5135,6 +5430,14 @@
       "integrity": "sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==",
       "dev": true
     },
+    "@types/responselike": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
+      "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/simple-oauth2": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-4.1.1.tgz",
@@ -5335,6 +5638,25 @@
         "picocolors": "^1.0.0"
       }
     },
+    "cacheable-lookup": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
+      "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A=="
+    },
+    "cacheable-request": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
+      "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
+      "requires": {
+        "clone-response": "^1.0.2",
+        "get-stream": "^5.1.0",
+        "http-cache-semantics": "^4.0.0",
+        "keyv": "^4.0.0",
+        "lowercase-keys": "^2.0.0",
+        "normalize-url": "^6.0.1",
+        "responselike": "^2.0.0"
+      }
+    },
     "call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -5521,6 +5843,15 @@
         "delayed-stream": "~1.0.0"
       }
     },
+    "compress-brotli": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz",
+      "integrity": "sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==",
+      "requires": {
+        "@types/json-buffer": "~3.0.0",
+        "json-buffer": "~3.0.1"
+      }
+    },
     "concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -5574,6 +5905,21 @@
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
       "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
     },
+    "decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "requires": {
+        "mimic-response": "^3.1.0"
+      },
+      "dependencies": {
+        "mimic-response": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+          "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
+        }
+      }
+    },
     "deep-eql": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
@@ -5589,6 +5935,11 @@
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "defer-to-connect": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+      "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
+    },
     "define-properties": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -6096,6 +6447,11 @@
         "mime-types": "^2.1.12"
       }
     },
+    "form-data-encoder": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz",
+      "integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg=="
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -6194,6 +6550,43 @@
       "dev": true,
       "peer": true
     },
+    "got": {
+      "version": "12.0.4",
+      "resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
+      "integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
+      "requires": {
+        "@sindresorhus/is": "^4.6.0",
+        "@szmarczak/http-timer": "^5.0.1",
+        "@types/cacheable-request": "^6.0.2",
+        "@types/responselike": "^1.0.0",
+        "cacheable-lookup": "^6.0.4",
+        "cacheable-request": "^7.0.2",
+        "decompress-response": "^6.0.0",
+        "form-data-encoder": "1.7.1",
+        "get-stream": "^6.0.1",
+        "http2-wrapper": "^2.1.10",
+        "lowercase-keys": "^3.0.0",
+        "p-cancelable": "^3.0.0",
+        "responselike": "^2.0.0"
+      },
+      "dependencies": {
+        "get-stream": {
+          "version": "6.0.1",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+          "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="
+        },
+        "lowercase-keys": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
+          "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="
+        },
+        "p-cancelable": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
+          "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="
+        }
+      }
+    },
     "growl": {
       "version": "1.10.5",
       "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
@@ -6245,6 +6638,15 @@
       "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
       "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
     },
+    "http2-wrapper": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
+      "integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
+      "requires": {
+        "quick-lru": "^5.1.1",
+        "resolve-alpn": "^1.2.0"
+      }
+    },
     "ignore": {
       "version": "4.0.6",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
@@ -6504,6 +6906,11 @@
       "dev": true,
       "peer": true
     },
+    "json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+    },
     "json-schema-traverse": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -6529,6 +6936,15 @@
       "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==",
       "dev": true
     },
+    "keyv": {
+      "version": "4.2.7",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.2.7.tgz",
+      "integrity": "sha512-HeOstD8SXvtWoQhMMBCelcUuZsiV7T7MwsADtOXT0KuwYP9nCxrSoMDeLXNDTLN3VFSuRp38JzoGbbTboq3QQw==",
+      "requires": {
+        "compress-brotli": "^1.3.8",
+        "json-buffer": "3.0.1"
+      }
+    },
     "kuler": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz",
@@ -6842,6 +7258,11 @@
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
       "dev": true
     },
+    "normalize-url": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+      "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
+    },
     "object-hash": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -7198,6 +7619,11 @@
         "strict-uri-encode": "^2.0.0"
       }
     },
+    "quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="
+    },
     "readable-stream": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
@@ -7239,12 +7665,25 @@
       "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
       "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
     },
+    "resolve-alpn": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+      "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+    },
     "resolve-from": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
       "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
       "dev": true
     },
+    "responselike": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz",
+      "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==",
+      "requires": {
+        "lowercase-keys": "^2.0.0"
+      }
+    },
     "rimraf": {
       "version": "2.6.3",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
diff --git a/package.json b/package.json
index c844ae5a81a8e93acc85ded8f48fee2544c7cdee..0f2be11f6e3b823af79410c1c29d31c045ac7227 100644
--- a/package.json
+++ b/package.json
@@ -25,10 +25,12 @@
     "@octokit/plugin-retry": "^3.0.3",
     "@octokit/plugin-throttling": "^3.3.0",
     "@octokit/rest": "^18.0.3",
+    "@types/got": "^9.6.12",
     "@types/node": "^17.0.32",
     "@types/yargs": "^17.0.10",
     "axios": "^0.21.4",
     "flat-cache": "^2.0.1",
+    "got": "^12.0.4",
     "nodemailer": "^6.5.0",
     "openid-client": "^3.15.6",
     "parse-link-header": "^2.0.0",
diff --git a/src/eclipse/EclipseAPI.ts b/src/eclipse/EclipseAPI.ts
index 908398e5430ecf9c02ee02fefa1e2ae0f19e4b9f..d282a57653e9e4da6cfb6ea37e11944575a257b4 100644
--- a/src/eclipse/EclipseAPI.ts
+++ b/src/eclipse/EclipseAPI.ts
@@ -251,7 +251,7 @@ const testProjects: EclipseProject[] = [
     ],
     gitlab_repos: [
       {
-        url: 'https://gitlab.eclipse.org/eclipsefdn/webdev/gitlab-testing',
+        url: 'http://localhost/eclipse/spider.pig',
       },
     ],
     contributors: [],
diff --git a/src/gl/AxiosRequester.ts b/src/gl/AxiosRequester.ts
index ccff72bacf5235af5d92811a4fd2dbf58f9e94f6..8185cf41e85bb810bddaa8e4a4865bba8d397348 100644
--- a/src/gl/AxiosRequester.ts
+++ b/src/gl/AxiosRequester.ts
@@ -1,21 +1,95 @@
-import {RequesterType} from '@gitbeaker/requester-utils';
 import axios from 'axios';
+import { decamelizeKeys } from 'xcase';
+import {
+  DefaultResourceOptions,
+  DefaultRequestReturn,
+  DefaultRequestOptions,
+  createRequesterFn,
+  defaultOptionsHandler as baseOptionsHandler,
+} from '@gitbeaker/requester-utils';
 
-export class AxiosRequester implements RequesterType {
-    get(endpoint: string, options?: Record<string, unknown>): Promise<any> {
-        throw new Error('Method not implemented.');
-    }
-    post(endpoint: string, options?: Record<string, unknown>): Promise<any> {
-        throw new Error('Method not implemented.');
-    }
-    put(endpoint: string, options?: Record<string, unknown>): Promise<any> {
-        throw new Error('Method not implemented.');
-    }
-    delete(endpoint: string, options?: Record<string, unknown>): Promise<any> {
-        throw new Error('Method not implemented.');
-    }
-    stream?(endpoint: string, options?: Record<string, unknown>): NodeJS.ReadableStream {
-        throw new Error('Method not implemented.');
+export function defaultOptionsHandler(
+  resourceOptions: DefaultResourceOptions,
+  { body, query, sudo, method }: DefaultRequestOptions = {}
+): DefaultRequestReturn & {
+  json?: Record<string, unknown>;
+  https?: { rejectUnauthorized: boolean };
+} {
+  const options: DefaultRequestReturn & {
+    json?: Record<string, unknown>;
+    https?: { rejectUnauthorized: boolean };
+  } = baseOptionsHandler(resourceOptions, { body, query, sudo, method });
+
+  // FIXME: Not the best comparison, but...it will have to do for now.
+  if (typeof body === 'object' && body.constructor.name !== 'FormData') {
+    options.json = decamelizeKeys(body);
+
+    delete options.body;
+  }
+
+  if (resourceOptions.url.includes('https') && resourceOptions.rejectUnauthorized != null && resourceOptions.rejectUnauthorized === false) {
+    options.https = {
+      rejectUnauthorized: resourceOptions.rejectUnauthorized,
+    };
+  }
+
+  return options;
+}
+
+export function processBody({ data, headers }: { data: Buffer; headers: Record<string, unknown> }) {
+  // Split to remove potential charset info from the content type
+  const contentType = ((headers['content-type'] as string) || '').split(';')[0].trim();
+
+  if (contentType === 'application/json') {
+    return data.length === 0 ? {} : data;
+  }
+
+  if (contentType.startsWith('text/')) {
+    return data.toString();
+  }
+
+  return Buffer.from(data);
+}
+
+export async function handler(endpoint: string, options: Record<string, unknown>) {
+  const retryCodes = [429, 502];
+  const maxRetries = 10;
+  let response;
+  // map the Got prefix to the axios base url
+  options['baseURL'] = options['prefixUrl'];
+  // map the qs glob into an object
+  options['params'] = Object.fromEntries(new URLSearchParams(options['searchParams'] as string));
+  options['data'] = options['json'];
+  delete options['json'];
+  for (let i = 0; i < maxRetries; i += 1) {
+    const waitTime = 2 ** i;
+
+    try {
+      response = await axios(endpoint, { ...options }); // eslint-disable-line
+      break;
+    } catch (e) {
+      if (e.response) {
+        if (retryCodes.includes(e.response.statusCode)) {
+          await new Promise(resolve => setTimeout(resolve, waitTime));
+          continue; // eslint-disable-line
+        }
+
+        if (typeof e.response.body === 'string' && e.response.body.length > 0) {
+          try {
+            const output = JSON.parse(e.response.body);
+            e.description = output.error || output.message;
+          } catch (err) {
+            e.description = e.response.body;
+          }
+        }
+      }
+
+      throw e;
     }
+  }
+  const { status, headers } = response;
+  const body = processBody(response);
+  return { body, headers, status: status };
+}
 
-}
\ No newline at end of file
+export const requesterFn = createRequesterFn(defaultOptionsHandler, handler);
diff --git a/src/gl/GitlabSync.ts b/src/gl/GitlabSync.ts
index 9596a57205745d726c9fbcfb20de28e2f5aee5db..f08c1915bd80111abe6ccbc55b7c1b45f6400651 100644
--- a/src/gl/GitlabSync.ts
+++ b/src/gl/GitlabSync.ts
@@ -50,8 +50,6 @@ let args = yargs(process.argv)
   .version('0.1')
   .alias('v', 'version')
   .epilog('Copyright 2019 Eclipse Foundation inc.').argv;
-
-const runner = 
 run();
 
 async function run() {
diff --git a/src/gl/GitlabSyncRunner.ts b/src/gl/GitlabSyncRunner.ts
index 558f5d57395f536a7b09735eaec8c9ffb9285b1f..9832c56fb8895a340df8845b8c3c772b346db213 100644
--- a/src/gl/GitlabSyncRunner.ts
+++ b/src/gl/GitlabSyncRunner.ts
@@ -1,14 +1,15 @@
 import { Logger } from 'winston';
+import { Projects, Resources } from '@gitbeaker/core/dist/types';
 import { AccessLevel, GroupSchema, MemberSchema, ProjectSchema, UserSchema } from '@gitbeaker/core/dist/types/types';
 import { v4 } from 'uuid';
 import { EclipseAPI, EclipseApiConfig } from '../eclipse/EclipseAPI';
 import { getLogger } from '../helpers/logger';
 import { SecretReader, getBaseConfig } from '../helpers/SecretReader';
 import { EclipseProject, EclipseUser } from '../interfaces/EclipseApi';
-import { Resources } from '@gitbeaker/core/dist/types';
 
 // used to make use of default requested based on Got rather than recreating our own
-import {Gitlab} from '@gitbeaker/core';
+import { Gitlab } from '@gitbeaker/core';
+import { requesterFn } from './AxiosRequester';
 
 const ADMIN_PERMISSIONS_LEVEL = 50;
 
@@ -34,6 +35,7 @@ interface GitlabSyncRunnerConfig {
   verbose: boolean;
   devMode: boolean;
   dryRun: boolean;
+  rootGroup?: string;
 }
 
 export class GitlabSyncRunner {
@@ -49,26 +51,35 @@ export class GitlabSyncRunner {
   bots: Record<string, string[]> = {};
 
   // caches to optimize calling
-  namedProjects: Record<string, ProjectSchema> = {};
   namedUsers: Record<string, UserSchema> = {};
   groupCache: GroupCache = {
     _self: null,
     children: {},
     projectTargets: [],
   };
+  projectsCache: ProjectSchema[] = [];
   eclipseProjectCache: Record<string, EclipseProject> = {};
   gMems: Record<number, MemberSchema[]> = {};
 
-  constructor(
-    config: GitlabSyncRunnerConfig = {
-      host: 'http://gitlab.eclipse.org/',
-      provider: 'oauth2_generic',
-      verbose: false,
-      devMode: false,
-      dryRun: false,
-    }
-  ) {
-    this.config = config;
+  /**
+   * Sets the internal config with a few default values and creates the bindings for the APIs that are
+   * accessed during the run of this script.
+   *
+   * @param config the initial script configuration object.
+   */
+  constructor(config: GitlabSyncRunnerConfig) {
+    this.config = Object.assign(
+      {
+        host: 'http://gitlab.eclipse.org/',
+        provider: 'oauth2_generic',
+        verbose: false,
+        devMode: false,
+        dryRun: false,
+        rootGroup: 'eclipse',
+      },
+      config
+    );
+
     this.logger = getLogger(this.config.verbose ? 'debug' : 'info', 'main');
     this._prepareSecret();
 
@@ -76,7 +87,7 @@ export class GitlabSyncRunner {
     this.api = new Gitlab({
       host: this.config.host,
       token: this.accessToken,
-      requesterFn:
+      requesterFn: requesterFn,
     });
     let eclipseAPIConfig: EclipseApiConfig = JSON.parse(this.eclipseToken);
     eclipseAPIConfig.testMode = this.config.devMode;
@@ -84,6 +95,11 @@ export class GitlabSyncRunner {
     this.eApi = new EclipseAPI(eclipseAPIConfig);
   }
 
+  /**
+   * Prepares the secrets required for the script to run. Specifically the eclipseToken used for Eclipse
+   * API access and the accessToken which is used for sudo+api access on the Gitlab instance targeted by
+   * this script.
+   */
   _prepareSecret() {
     // retrieve the secret API file root if set
     var settings = getBaseConfig();
@@ -108,19 +124,23 @@ export class GitlabSyncRunner {
     }
   }
 
-  async run() {
+  /**
+   * Run the full sync script, syncing the PMI to the Gitlab instance targeted by the script. This script
+   * will sync the namespace groups named in projects with the users that are set as members of the project.
+   * It will also clear users added outside of this process with the exception of bot users to maintain more
+   * strict control of the access permissions.
+   *
+   * @returns a promise that is completed once the run completes.
+   */
+  async run(): Promise<void> {
     // prepopulate caches to optimally retrieve info used in sync ops
     await this.prepareCaches();
 
     // fetch org group from results, create if missing
     this.logger.info('Starting sync');
-    var g = await this.getGroup('eclipse');
-    if (g === undefined) {
-      if (this.config.dryRun) {
-        this.logger.error('Unable to start sync of GitLab content. Base Eclipse group could not be found and dryrun is set');
-      } else {
-        this.logger.error('Unable to start sync of GitLab content. Base Eclipse group could not be created');
-      }
+    var g = this.getRootGroup();
+    if (g._self === null) {
+      this.logger.error(`Unable to start sync of GitLab content. Base group (${this.config.rootGroup}) could not be found`);
       return;
     }
 
@@ -142,16 +162,18 @@ export class GitlabSyncRunner {
         let [host, namespace] = this.splitNamespaceUrl(project.gitlab_repos[idx].url);
         // make sure namespace URL is valid
         if (host === null || namespace === null) {
-          continue;
+          this.logger.error(`Could not generate namespace/host from namespace URL: ${project.gitlab_repos[idx].url}`);
         }
         // check if hosts are the same ignoring case, skipping if they are different
-        if (host.localeCompare(this.config.host, undefined, { sensitivity: 'base' }) !== 0) {
+        if (host.localeCompare(new URL(this.config.host).hostname, undefined, { sensitivity: 'base' }) !== 0) {
+          this.logger.error(`Found host '${host}' when processing for '${new URL(this.config.host).hostname}', skipping`);
           continue;
         }
 
         // check group cache to ensure well formed.
         let namespaceGroup = this.getCachedGroup(namespace);
         if (namespaceGroup === null || namespaceGroup._self === null) {
+          this.logger.error(`Could not find group with namespace ${namespace}`);
           continue;
         }
         // update the group to add the users for the current project
@@ -165,13 +187,15 @@ export class GitlabSyncRunner {
 
           await this.addUserToGroup(user, namespaceGroup._self!, userList[uname].accessLevel);
           // if not tracked, track current project for group for post-sync cleanup
-          if (namespaceGroup.projectTargets.indexOf(project.short_project_id) !== -1) {
+          if (namespaceGroup.projectTargets.indexOf(project.short_project_id) === -1) {
             namespaceGroup.projectTargets.push(project.short_project_id);
           }
         }
       }
     }
+    // perform cleanup operations to clean out extra users
     this.cleanupGroups();
+    this.cleanupProjects();
   }
 
   /**
@@ -179,30 +203,31 @@ export class GitlabSyncRunner {
    */
   async prepareCaches() {
     // get raw project data and post process to add additional context
-    let data = await this.eApi.eclipseAPI();
+    try {
+      let data = await this.eApi.eclipseAPI();
 
-    // get the bots for the projects
-    let rawBots = await this.eApi.eclipseBots();
-    this.bots = this.eApi.processBots(rawBots, 'gitlab.eclipse.org');
+      // get the bots for the projects
+      let rawBots = await this.eApi.eclipseBots();
+      this.bots = this.eApi.processBots(rawBots, 'gitlab.eclipse.org');
 
-    // get all current groups for the instance
-    var groups = await this.api.Groups.all();
-    var projects = await this.api.Projects.all();
-    var users = await this.api.Users.all();
+      // get all current groups for the instance
+      this.projectsCache = await this.api.Projects.all();
+      var groups = await this.api.Groups.all();
+      var users = await this.api.Users.all();
 
-    // generates the nested cache
-    this.generateGroupsCache(groups);
-    // maps path + parent to create unique tokens to match projects
-    for (var projectIdx in projects) {
-      this.namedProjects[this.getCompositeProjectKey(projects[projectIdx].name, projects[projectIdx].namespace.id)] = projects[projectIdx];
-    }
-    // map the users to their usernames for easy lookups
-    for (var userIdx in users) {
-      this.namedUsers[users[userIdx].username] = users[userIdx];
-    }
-    for (let projectIdx in data) {
-      let p = data[projectIdx];
-      this.eclipseProjectCache[p.short_project_id] = p;
+      // generates the nested cache
+      this.generateGroupsCache(groups);
+      // map the users to their usernames for easy lookups
+      for (var userIdx in users) {
+        this.namedUsers[users[userIdx].username] = users[userIdx];
+      }
+      for (let projectIdx in data) {
+        let p = data[projectIdx];
+        this.eclipseProjectCache[p.short_project_id] = p;
+      }
+    } catch (e) {
+      this.logger.error(`Cannot fetch resources associated with sync operations, exiting: ${e}`);
+      process.exit(1);
     }
   }
 
@@ -210,7 +235,8 @@ export class GitlabSyncRunner {
    * Iterate through each group, checking self and ancestor project users and comparing against the current groups users to ensure that there are no
    * additional users added with permissions.
    */
-  cleanupGroups(currentLevel: GroupCache = this.groupCache, collectedProjects: string[] = []) {
+  cleanupGroups(currentLevel: GroupCache = this.getRootGroup(), collectedProjects: string[] = []) {
+    this.logger.debug(`cleanupGroups(currentLevel = ${currentLevel._self?.full_path}, collectedProjects = ${collectedProjects})`);
     let self = currentLevel._self;
     if (self === null) {
       this.logger.error('Error encountered during group cleanup process, ending early');
@@ -233,7 +259,11 @@ export class GitlabSyncRunner {
 
   async removeAdditionalUsers(expectedUsers: Record<string, EclipseUser>, group: GroupSchema, ...projectIDs: string[]) {
     if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:removeAdditionalUsers(expectedUsers = ${expectedUsers}, group = ${group}, projectIDs = ${projectIDs})`);
+      this.logger.debug(
+        `GitlabSync:removeAdditionalUsers(expectedUsers = ${JSON.stringify(expectedUsers)}, group = ${
+          group.full_path
+        }, projectIDs = ${projectIDs})`
+      );
     }
     // get the current list of users for the group
     var members = await this.getGroupMembers(group);
@@ -269,7 +299,29 @@ export class GitlabSyncRunner {
     });
   }
 
-  async cleanUpProjectUsers(project: ProjectSchema, projectID: string) {
+  /**
+   * Iterates over the projects cache and cleans out the users and keeps bots for build operations. Skips over projects
+   * outside the scope of the designated root group to avoid over processing groups.
+   */
+  async cleanupProjects() {
+    this.projectsCache.forEach(p => {
+      let group = this.getCachedGroup(p.namespace.full_path);
+      if (group !== null) {
+        this.cleanUpProjectUsers(p, ...group!.projectTargets);
+      } else {
+        this.logger.info(`Skipping processing of project '${p.name}'`);
+      }
+    });
+  }
+
+  /**
+   * Removes any non-owner user that isn't a bot from projects. Membership is managed at the group level, not the direct
+   * project level.
+   *
+   * @param project the Gitlab project to sanitize
+   * @param projectIDs the Eclipse projects that impact the Gitlab project.
+   */
+  async cleanUpProjectUsers(project: ProjectSchema, ...projectIDs: string[]) {
     if (this.config.verbose) {
       this.logger.debug(`GitlabSync:cleanUpProjectUsers(project = ${project.id})`);
     }
@@ -277,7 +329,7 @@ export class GitlabSyncRunner {
     for (var idx in projectMembers) {
       let member = projectMembers[idx];
       // skip bot user or admin users
-      if (this.isBot(member.username, [projectID]) || member.access_level === ADMIN_PERMISSIONS_LEVEL) {
+      if (this.isBot(member.username, projectIDs) || member.access_level === ADMIN_PERMISSIONS_LEVEL) {
         continue;
       }
       if (this.config.dryRun) {
@@ -296,9 +348,18 @@ export class GitlabSyncRunner {
     }
   }
 
+  /**
+   * Ensures that the user exists within the group with the given access level (no more or less). If a user has too high
+   * permissions, the membership is modified to have the given access instead.
+   *
+   * @param user the user that is being given permissions
+   * @param group group that the user should be added to
+   * @param perms the permission set to give the user
+   * @returns the membership information for the user wrt to this Gitlab group.
+   */
   async addUserToGroup(user: UserSchema, group: GroupSchema, perms: AccessLevel): Promise<MemberSchema | null> {
     if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:addUserToGroup(user = ${user}, group = ${group}, perms = ${perms})`);
+      this.logger.debug(`GitlabSync:addUserToGroup(user = ${user?.username}, group = ${group?.full_path}, perms = ${perms})`);
     }
     // get the members for the current group
     var members = await this.getGroupMembers(group);
@@ -308,7 +369,8 @@ export class GitlabSyncRunner {
     }
 
     // check if user is already present
-    members!.forEach(async (member, idx) => {
+    for (let i = 0; i < members.length; i++) {
+      let member = members[i];
       if (member.username === user.username) {
         this.logger.verbose(`User '${user.username}' is already a member of ${group.name}`);
         if (member.access_level !== perms) {
@@ -323,7 +385,7 @@ export class GitlabSyncRunner {
           try {
             var updatedMember = await this.api.GroupMembers.edit(group.id, user.id, perms);
             // update inner array
-            members![idx] = updatedMember;
+            members![i] = updatedMember;
             this.gMems[group.id] = members!;
           } catch (err) {
             if (this.config.verbose) {
@@ -334,9 +396,9 @@ export class GitlabSyncRunner {
           }
         }
         // return a copy of the updated user
-        return members![idx];
+        return members![i];
       }
-    });
+    }
     // check if dry run before updating
     if (this.config.dryRun) {
       this.logger.info(
@@ -363,113 +425,6 @@ export class GitlabSyncRunner {
     return null;
   }
 
-  async getProject(name: string, parent: GroupSchema): Promise<ProjectSchema | null> {
-    if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:getProject(name = ${name}, parent = ${parent})`);
-    }
-    if (name.trim() === '.github') {
-      this.logger.warn("Skipping project with name '.github'. No current equivalent to default repository in GitLab.");
-      return null;
-    }
-
-    var p = this.namedProjects[this.getCompositeProjectKey(name, parent.id)];
-    if (p === undefined) {
-      this.logger.verbose(`Creating new project with name '${name}'`);
-      // create the request options for the new user, any needed for addition of random properties after
-      let opts;
-      if (parent !== undefined) {
-        opts = {
-          path: name,
-          visibility: 'public',
-          namespace_id: parent.id,
-        };
-      } else {
-        opts = {
-          path: name,
-          visibility: 'public',
-        };
-      }
-      // check if dry run before creating new project
-      if (this.config.dryRun) {
-        this.logger.info(`Dryrun flag active, would have created new project '${name}' with options ${JSON.stringify(opts)}`);
-        return null;
-      }
-
-      // create the new project, and track it
-      if (this.config.verbose) {
-        this.logger.debug(`Creating project with options: ${JSON.stringify(opts)}`);
-      }
-      try {
-        p = await this.api.Projects.create(opts);
-      } catch (err) {
-        if (this.config.verbose) {
-          this.logger.error(`${err}`);
-        }
-      }
-      if (p === null || p instanceof Array) {
-        this.logger.warn(`Error while creating project '${name}'`);
-        return null;
-      }
-      if (this.config.verbose) {
-        this.logger.debug(`Created project: ${JSON.stringify(p)}`);
-      }
-      // set it back
-      this.namedProjects[this.getCompositeProjectKey(name, parent.id)] = p;
-    }
-    return p;
-  }
-
-  async getGroup(namespace: string, visibility: string = 'public'): Promise<GroupSchema | null> {
-    if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:getGroup(namespace = ${namespace}, visibility = ${visibility})`);
-    }
-    let g: GroupSchema | null = null;
-    let cachedGroup = this.getCachedGroup(namespace);
-    if (cachedGroup === null) {
-      let parentNamespace = namespace.substring(0, namespace.lastIndexOf('/'));
-      let parent = this.getCachedGroup(parentNamespace);
-      if (parent === null || parent._self === null) {
-        this.logger.error(`Could not find the parent of group to fetch (${parentNamespace}), cannot fetch group ${namespace}`);
-        return null;
-      }
-      let groupName = namespace.substring(namespace.lastIndexOf('/'), namespace.length - 1);
-      this.logger.verbose(`Creating new group with name '${groupName}'`);
-      var opts = {
-        project_creation_level: 'maintainer',
-        visibility: visibility,
-        request_access_enabled: false,
-        parent_id: parent._self.id,
-      };
-      // check if dry run before creating group
-      if (this.config.dryRun) {
-        this.logger.info(`Dryrun flag active, would have created new group '${groupName}' with options ${JSON.stringify(opts)}`);
-        return null;
-      }
-
-      // if verbose is set display user opts
-      if (this.config.verbose) {
-        this.logger.debug(`Creating group with options: ${JSON.stringify(opts)}`);
-      }
-      try {
-        g = await this.api.Groups.create(groupName, this.sanitizeGroupName(groupName), opts);
-      } catch (err) {
-        if (this.config.verbose) {
-          this.logger.error(`${err}`);
-        }
-      }
-      if (g === null || g instanceof Array) {
-        this.logger.warn(`Error while creating group '${groupName}'`);
-        return null;
-      }
-      if (this.config.verbose) {
-        this.logger.debug(`Created group: ${JSON.stringify(g)}`);
-      }
-      // set it back
-      this.addGroup(g);
-    }
-    return g;
-  }
-
   async getUser(uname: string, url: string): Promise<UserSchema | null> {
     if (this.config.verbose) {
       this.logger.debug(`GitlabSync:getUser(uname = ${uname}, url = ${url})`);
@@ -535,7 +490,7 @@ export class GitlabSyncRunner {
 
   async getGroupMembers(group: GroupSchema): Promise<MemberSchema[] | null> {
     if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:getGroupMembers(group = ${group})`);
+      this.logger.debug(`GitlabSync:getGroupMembers(group = ${group?.full_path})`);
     }
     var members = this.gMems[group.id];
     if (members === undefined) {
@@ -574,27 +529,46 @@ export class GitlabSyncRunner {
     }
   }
 
+  getRootGroup(): GroupCache {
+    let rootGroupCache = this.groupCache.children[this.config.rootGroup];
+    if (rootGroupCache === undefined) {
+      this.logger.error(`Could not find root group '${this.config.rootGroup}' for group caching, exiting`);
+      process.exit(1);
+    }
+    return rootGroupCache;
+  }
+
   getCachedGroup(namespace: string): GroupCache | null {
     if (this.config.verbose) {
       this.logger.debug(`GitlabSync:getCachedGroup(${namespace})`);
     }
+    if (!namespace.startsWith(this.config.rootGroup)) {
+      this.logger.info(`Returning null for ${namespace} as it is outside of the root group ${this.config.rootGroup}`);
+      return null;
+    }
     return this.tunnelAndRetrieve(namespace.split('/'), this.groupCache);
   }
 
   addGroup(g: GroupSchema, parent: GroupCache = this.groupCache): GroupCache | null {
+    if (this.config.verbose) {
+      this.logger.debug(`GitlabSync:addGroup(g = ${g.id})`);
+    }
     // type the namespace as it is a raw type, make sure its the type we expect
-    if (typeof g.path_with_namespace !== 'string') {
-      this.logger.error(`Could not cast namespace to string: ${g.path_with_namespace}`);
-      // TODO this should short circuit as not having groups is a non-starter
+    if (typeof g.full_path !== 'string') {
+      this.logger.error(`Could not cast namespace to string: ${g.full_path}`);
       return null;
     }
-    let namespace = g.path_with_namespace as string;
+    let namespace = g.full_path;
     // split into group namespace paths (eclipse/sample/group.path into ['eclipse','sample','group.path'])
     let namespaceParts = namespace.split('/');
     return this.tunnelAndInsert(namespaceParts, g, this.groupCache);
   }
 
   tunnelAndInsert(namespaceParts: string[], g: GroupSchema, parent: GroupCache): GroupCache {
+    if (this.config.verbose) {
+      this.logger.debug(`GitlabSync:tunnelAndInsert(namespaceParts = '${namespaceParts}', g = ${g.id})`);
+    }
+
     let child = parent.children[namespaceParts[0]];
     if (child === undefined) {
       child = {
@@ -606,29 +580,46 @@ export class GitlabSyncRunner {
     }
     // check if we should continue tunneling or insert and finish processing
     if (namespaceParts.length > 1) {
-      return this.tunnelAndInsert(namespaceParts.slice(1, namespaceParts.length - 1), g, child);
+      return this.tunnelAndInsert(namespaceParts.slice(1, namespaceParts.length), g, child);
     } else {
       child._self = g;
       return child;
     }
   }
 
+  /**
+   * Recursive access to the nested group cache. Retrieves the group described by the namespace parts and returns
+   * it, returning null if it can't be found.
+   *
+   * @param namespaceParts the full path for a group namespace split into parts
+   * @param parent the parent to search through for the next part of the recursive call.
+   * @returns The group cache for the designated group, or null if it can't be found.
+   */
   tunnelAndRetrieve(namespaceParts: string[], parent: GroupCache): GroupCache | null {
+    if (this.config.verbose) {
+      this.logger.debug(`GitlabSync:tunnelAndRetrieve(namespaceParts = '${namespaceParts}')`);
+    }
     let child = parent.children[namespaceParts[0]];
     if (child === undefined) {
       return null;
     }
     // check if we should continue tunneling or insert and finish processing
     if (namespaceParts.length > 1) {
-      return this.tunnelAndRetrieve(namespaceParts.slice(1, namespaceParts.length - 1), child);
+      return this.tunnelAndRetrieve(namespaceParts.slice(1, namespaceParts.length), child);
     } else {
       return child;
     }
   }
 
+  /**
+   * Gets list of users with access permissions for the given Eclipse project.
+   *
+   * @param project the Eclipse project to parse user entries for
+   * @returns the mapping of users to access permissions and entity access URL.
+   */
   getUserList(project: EclipseProject): Record<string, EclipseUserAccess> {
     if (this.config.verbose) {
-      this.logger.debug(`GitlabSync:getUserList(project = ${JSON.stringify(project)})`);
+      this.logger.debug(`GitlabSync:getUserList(project = ${project.short_project_id})`);
     }
     var l: Record<string, EclipseUserAccess> = {};
     // add the contributors with reporter access
@@ -665,6 +656,12 @@ export class GitlabSyncRunner {
     return l;
   }
 
+  /**
+   * Sanitizes and normalizes strings for use in creating/accessing groups.
+   *
+   * @param pid the project ID to normalize
+   * @returns normalized group name for value.
+   */
   sanitizeGroupName(pid: string): string {
     if (this.config.verbose) {
       this.logger.debug(`GitlabSync:sanitizeGroupName(pid = ${pid})`);
@@ -675,10 +672,6 @@ export class GitlabSyncRunner {
     return '';
   }
 
-  getCompositeProjectKey(projectName: string, parentId: number): string {
-    return projectName + ':' + parentId;
-  }
-
   /**
    * Uses TS URL type to ingest a namespace URL and return the host and namespace for use downstream
    *
@@ -688,7 +681,7 @@ export class GitlabSyncRunner {
   splitNamespaceUrl(rawUrl: string): [string | null, string | null] {
     try {
       let url = new URL(rawUrl);
-      return [url.host, url.pathname.substring(1, url.pathname.length - 1)];
+      return [url.host, url.pathname.substring(1, url.pathname.length)];
     } catch (e) {
       // cast and message with error
       let message = '';
@@ -701,6 +694,14 @@ export class GitlabSyncRunner {
     }
     return [null, null];
   }
+
+  /**
+   * Checks whether a user is a bot for the given projects.
+   *
+   * @param uname potential bot username
+   * @param projectIDs the projects that the user could be a bot for.
+   * @returns true if the user is a designated bot for the projects, otherwise false.
+   */
   isBot(uname: string, projectIDs: string[]): boolean {
     for (let pidx in projectIDs) {
       var botList = this.bots[projectIDs[pidx]];
diff --git a/tsconfig.json b/tsconfig.json
index 45480bf8b2a2e474d24e758c85faac35bc6bcb01..cfc7ab0c8c1d10e2d742308058554e406e040bf2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,5 @@
 {
     "compilerOptions": {
-        "module": "CommonJS",
         "esModuleInterop": true,
         "sourceMap": true
     },