├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── gitlab-search.js ├── bsconfig.json ├── package-lock.json ├── package.json └── src ├── Commander.re ├── Config.re ├── GitLab.re ├── Main.re └── Print.re /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 14.x 15 | - run: node --version 16 | - run: npm --version 17 | - name: npm install and build 18 | run: | 19 | npm ci 20 | npm run build 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .merlin 3 | .bsb.lock 4 | .vscode 5 | npm-debug.log 6 | /lib/ 7 | /node_modules/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Phillip Johnsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitLab Search ![CI Build Status](https://github.com/phillipj/gitlab-search/workflows/CI/badge.svg) 2 | 3 | This is a command line tool that allows you to search for contents across all your GitLab repositories. 4 | That's something GitLab doesn't provide out of the box for non-enterprise users, but is extremely valuable 5 | when needed. 6 | 7 | ## Prerequisites 8 | 9 | 1. Install [Node.js](https://nodejs.org) 10 | 2. Create a [personal GitLab access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#creating-a-personal-access-token) with the `read_api` scope. 11 | 12 | ## Installation 13 | 14 | ``` 15 | $ npm install -g gitlab-search 16 | ``` 17 | 18 | To finish the installation you need to configure the personal access token you've created previously: 19 | 20 | ``` 21 | $ gitlab-search setup 22 | ``` 23 | 24 | That will create a `.gitlabsearchrc` file in the current directory. That configuration file can be placed 25 | in different places on your machine, valid locations are described in the [rc package's README](https://www.npmjs.com/package/rc#standards). 26 | You can decide where that file is saved when invoking the setup command, see more details in its help: 27 | 28 | ``` 29 | $ gitlab-search setup --help 30 | ``` 31 | 32 | ## Usage 33 | 34 | Searching through all the repositories you've got access to: 35 | 36 | ``` 37 | $ gitlab-search [options] [command] 38 | 39 | Options: 40 | -V, --version output the version number 41 | -g, --groups group(s) to find repositories in (separated with comma) 42 | -f, --filename only search for contents in given a file, glob matching with wildcards (*) 43 | -e, --extension only search for contents in files with given extension 44 | -p, --path only search in files in the given path 45 | -a, --archive [all,only,exclude] search only in archived projects, exclude archived projects, search in all projects (default is all) 46 | -h, --help output usage information 47 | 48 | Commands: 49 | setup [options] create configuration file 50 | ``` 51 | 52 | ## Use with Self-Managed GitLab 53 | 54 | To search a self-hosted installation of GitLab, `setup` has options for, among other things, setting a custom domain: 55 | 56 | ``` 57 | $ gitlab-search setup --help 58 | 59 | Usage: setup [options] 60 | 61 | create configuration file 62 | 63 | Options: 64 | --ignore-ssl ignore invalid SSL certificate from the GitLab API server 65 | --api-domain domain name or root URL of GitLab API server, 66 | specify root URL (without trailing slash) to use HTTP instead of HTTPS (default: "gitlab.com") 67 | --dir path to directory to save configuration file in (default: ".") 68 | --concurrency limit the amount of concurrent HTTPS requests sent to GitLab when searching, 69 | useful when *many* projects are hosted on a small GitLab instance 70 | to avoid overwhelming the instance resulting in 502 errors (default: 25) 71 | -h, --help display help for command 72 | ``` 73 | 74 | ## Debugging 75 | 76 | If something seems fishy or you're just curious what `gitlab-search` does under the hood, enabling debug logging helps: 77 | 78 | ``` 79 | $ DEBUG=1 gitlab-search here-is-my-search-term 80 | Requesting: GET https://gitlab.com/api/v4/groups?per_page=100 81 | Using groups: name-of-group1, name-of-group2 82 | Requesting: GET https://gitlab.com/api/v4/groups/42/projects?per_page=100 83 | Requesting: GET https://gitlab.com/api/v4/groups/1337/projects?per_page=100 84 | Using projects: hello-world, my-awesome-website.com 85 | Requesting: GET https://gitlab.com/api/v4/projects/666/search?scope=blobs&search=here-is-my-search-term 86 | Requesting: GET https://gitlab.com/api/v4/projects/999/search?scope=blobs&search=here-is-my-search-term 87 | ``` 88 | 89 | ## License 90 | 91 | MIT 92 | -------------------------------------------------------------------------------- /bin/gitlab-search.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // This file is a wrapper around the Main.bs.js file to add the shebang above 4 | // which is required for command line tools written in JavaScript/executed with Node.js 5 | 6 | require("../dist/index.js"); 7 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-repo-search", 3 | "version": "0.1.0", 4 | "sources": { 5 | "dir" : "src", 6 | "subdirs" : true 7 | }, 8 | "package-specs": { 9 | "module": "commonjs", 10 | }, 11 | "suffix": ".bs.js", 12 | "bs-dependencies": [ 13 | "bs-chalk", 14 | "bs-axios", 15 | "@glennsl/bs-json" 16 | ], 17 | "warnings": { 18 | "error": "+A-3-44-102", 19 | "number": "+A-48-40-42" 20 | }, 21 | "refmt": 3 22 | } 23 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-search", 3 | "version": "1.5.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@glennsl/bs-json": { 8 | "version": "5.0.2", 9 | "resolved": "https://registry.npmjs.org/@glennsl/bs-json/-/bs-json-5.0.2.tgz", 10 | "integrity": "sha512-vVlHJNrhmwvhyea14YiV4L5pDLjqw1edE3GzvMxlbPPQZVhzgO3sTWrUxCpQd2gV+CkMfk4FHBYunx9nWtBoDg==", 11 | "dev": true 12 | }, 13 | "@zeit/ncc": { 14 | "version": "0.22.0", 15 | "resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.22.0.tgz", 16 | "integrity": "sha512-zaS6chwztGSLSEzsTJw9sLTYxQt57bPFBtsYlVtbqGvmDUsfW7xgXPYofzFa1kB9ur2dRop6IxCwPnWLBVCrbQ==", 17 | "dev": true 18 | }, 19 | "ansi-styles": { 20 | "version": "3.2.1", 21 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", 22 | "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", 23 | "dev": true, 24 | "requires": { 25 | "color-convert": "^1.9.0" 26 | } 27 | }, 28 | "axios": { 29 | "version": "0.19.2", 30 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", 31 | "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", 32 | "dev": true, 33 | "requires": { 34 | "follow-redirects": "1.5.10" 35 | } 36 | }, 37 | "balanced-match": { 38 | "version": "1.0.0", 39 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 40 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 41 | "dev": true 42 | }, 43 | "brace-expansion": { 44 | "version": "1.1.11", 45 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 46 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 47 | "dev": true, 48 | "requires": { 49 | "balanced-match": "^1.0.0", 50 | "concat-map": "0.0.1" 51 | } 52 | }, 53 | "bs-axios": { 54 | "version": "0.0.43", 55 | "resolved": "https://registry.npmjs.org/bs-axios/-/bs-axios-0.0.43.tgz", 56 | "integrity": "sha512-TI+LZ3L4KurI/D6O60Ao04OYoknla7EJuCRBRQvWohxKO7ZMYThelYEEeChIVjjqZxukIVAag3JQ3+/WCJvwrg==", 57 | "dev": true, 58 | "requires": { 59 | "axios": "^0.19.2" 60 | } 61 | }, 62 | "bs-chalk": { 63 | "version": "0.2.1", 64 | "resolved": "https://registry.npmjs.org/bs-chalk/-/bs-chalk-0.2.1.tgz", 65 | "integrity": "sha512-wyy8l8CuZRUeKD2DHgiD1osOX6RdLKTwLuUazWhlaNqTvgQ3j6wTWaKpfGP+e5JhgF+vmKCreDMvFAWTSZFK4g==", 66 | "dev": true, 67 | "requires": { 68 | "chalk": "^2.3.0" 69 | } 70 | }, 71 | "bs-platform": { 72 | "version": "5.2.1", 73 | "resolved": "https://registry.npmjs.org/bs-platform/-/bs-platform-5.2.1.tgz", 74 | "integrity": "sha512-3ISP+RBC/NYILiJnphCY0W3RTYpQ11JGa2dBBLVug5fpFZ0qtSaL3ZplD8MyjNeXX2bC7xgrWfgBSn8Tc9om7Q==", 75 | "dev": true 76 | }, 77 | "chalk": { 78 | "version": "2.4.2", 79 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", 80 | "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", 81 | "dev": true, 82 | "requires": { 83 | "ansi-styles": "^3.2.1", 84 | "escape-string-regexp": "^1.0.5", 85 | "supports-color": "^5.3.0" 86 | } 87 | }, 88 | "color-convert": { 89 | "version": "1.9.3", 90 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", 91 | "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", 92 | "dev": true, 93 | "requires": { 94 | "color-name": "1.1.3" 95 | } 96 | }, 97 | "color-name": { 98 | "version": "1.1.3", 99 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", 100 | "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", 101 | "dev": true 102 | }, 103 | "commander": { 104 | "version": "5.0.0", 105 | "resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz", 106 | "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==", 107 | "dev": true 108 | }, 109 | "concat-map": { 110 | "version": "0.0.1", 111 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 112 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 113 | "dev": true 114 | }, 115 | "debug": { 116 | "version": "3.1.0", 117 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 118 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 119 | "dev": true, 120 | "requires": { 121 | "ms": "2.0.0" 122 | } 123 | }, 124 | "deep-extend": { 125 | "version": "0.6.0", 126 | "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 127 | "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 128 | "dev": true 129 | }, 130 | "escape-string-regexp": { 131 | "version": "1.0.5", 132 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 133 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 134 | "dev": true 135 | }, 136 | "follow-redirects": { 137 | "version": "1.5.10", 138 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", 139 | "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", 140 | "dev": true, 141 | "requires": { 142 | "debug": "=3.1.0" 143 | } 144 | }, 145 | "fs.realpath": { 146 | "version": "1.0.0", 147 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 148 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 149 | "dev": true 150 | }, 151 | "glob": { 152 | "version": "7.1.6", 153 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", 154 | "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", 155 | "dev": true, 156 | "requires": { 157 | "fs.realpath": "^1.0.0", 158 | "inflight": "^1.0.4", 159 | "inherits": "2", 160 | "minimatch": "^3.0.4", 161 | "once": "^1.3.0", 162 | "path-is-absolute": "^1.0.0" 163 | } 164 | }, 165 | "has-flag": { 166 | "version": "3.0.0", 167 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 168 | "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", 169 | "dev": true 170 | }, 171 | "inflight": { 172 | "version": "1.0.6", 173 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 174 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 175 | "dev": true, 176 | "requires": { 177 | "once": "^1.3.0", 178 | "wrappy": "1" 179 | } 180 | }, 181 | "inherits": { 182 | "version": "2.0.4", 183 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 184 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 185 | "dev": true 186 | }, 187 | "ini": { 188 | "version": "1.3.7", 189 | "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", 190 | "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", 191 | "dev": true 192 | }, 193 | "minimatch": { 194 | "version": "3.0.4", 195 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 196 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 197 | "dev": true, 198 | "requires": { 199 | "brace-expansion": "^1.1.7" 200 | } 201 | }, 202 | "minimist": { 203 | "version": "1.2.6", 204 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", 205 | "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", 206 | "dev": true 207 | }, 208 | "ms": { 209 | "version": "2.0.0", 210 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 211 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", 212 | "dev": true 213 | }, 214 | "once": { 215 | "version": "1.4.0", 216 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 217 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 218 | "dev": true, 219 | "requires": { 220 | "wrappy": "1" 221 | } 222 | }, 223 | "path-is-absolute": { 224 | "version": "1.0.1", 225 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 226 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 227 | "dev": true 228 | }, 229 | "rc": { 230 | "version": "1.2.8", 231 | "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 232 | "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 233 | "dev": true, 234 | "requires": { 235 | "deep-extend": "^0.6.0", 236 | "ini": "~1.3.0", 237 | "minimist": "^1.2.0", 238 | "strip-json-comments": "~2.0.1" 239 | } 240 | }, 241 | "rimraf": { 242 | "version": "3.0.2", 243 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 244 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 245 | "dev": true, 246 | "requires": { 247 | "glob": "^7.1.3" 248 | } 249 | }, 250 | "strip-json-comments": { 251 | "version": "2.0.1", 252 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 253 | "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", 254 | "dev": true 255 | }, 256 | "supports-color": { 257 | "version": "5.5.0", 258 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", 259 | "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", 260 | "dev": true, 261 | "requires": { 262 | "has-flag": "^3.0.0" 263 | } 264 | }, 265 | "wrappy": { 266 | "version": "1.0.2", 267 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 268 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 269 | "dev": true 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-search", 3 | "version": "1.5.0", 4 | "scripts": { 5 | "build": "rimraf dist && bsb -make-world && ncc build lib/js/src/Main.bs.js --out dist", 6 | "start": "bsb -make-world -w", 7 | "clean": "bsb -clean-world", 8 | "prepublish": "npm run build" 9 | }, 10 | "keywords": [ 11 | "GitLab" 12 | ], 13 | "author": "Phillip Johnsen ", 14 | "license": "MIT", 15 | "repository": "github:phillipj/gitlab-search", 16 | "files": [ 17 | "bin", 18 | "dist" 19 | ], 20 | "bin": "bin/gitlab-search.js", 21 | "devDependencies": { 22 | "@glennsl/bs-json": "^5.0.2", 23 | "@zeit/ncc": "^0.22.0", 24 | "bs-axios": "0.0.43", 25 | "bs-chalk": "^0.2.1", 26 | "bs-platform": "^5.2.1", 27 | "commander": "^5.0.0", 28 | "rc": "^1.2.8", 29 | "rimraf": "^3.0.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Commander.re: -------------------------------------------------------------------------------- 1 | /** 2 | * This internal ReasonML module is used to wrap the 3rd party commander.js package 3 | * and mostly consists of interop with JavaScript via bucklescript bindings. 4 | * 5 | * Useful resources to learn about ReasonML/bucklescript's interop with JavaScript: 6 | * - https://medium.com/@Hehk/binding-a-library-in-reasonml-e33b6a58b1b3 7 | * - https://github.com/glennsl/bucklescript-ffi-cheatsheet 8 | * - https://bucklescript.github.io/bucklescript/Manual.html#_binding_to_a_value_from_a_module_code_bs_module_code 9 | */ 10 | 11 | /* t == commander.js being used for commander during its execution */ 12 | type t; 13 | type actionFnOptions; 14 | 15 | /* this basically does a require("commander") behind the scenes and assigns the result to the type t */ 16 | [@bs.module] external commander: t = "commander"; 17 | 18 | /* an idiomatic make() function to get a hold of commander.js */ 19 | let make = () => commander; 20 | 21 | [@bs.send.pipe: t] 22 | external action: (unit /* in reality variadic arguments */ => unit) => t = 23 | "action"; 24 | 25 | /** 26 | This overrides the Commander.action() function for users of this module, needed because 27 | commander.js invokes the callback provided with variadic (read dynamic) arguments when invoking it. 28 | That doesn't play well with a strongly typed language like ReasonML, where every argument into a 29 | function has to be explicitly declared. 30 | 31 | Therefore doing some raw bucklescript tricks here, to convert all the dynamic arguments provided by 32 | commander.js into *one* array of strings before we invoke the callback provided by the user of this 33 | Commander ReasonML module. 34 | 35 | Worth mentioning [@bs.variadic] does something similar, but as far as I've understood that's only 36 | viable for method calls done from ReasonML / our application code. This is the other way around though, 37 | as this is for making arguments provided *from* JavaScript to ReasonML. 38 | */ 39 | let action: ((array(string), actionFnOptions) => unit, t) => t = 40 | (fn, t) => { 41 | // action() below means the external action() definition above, in other words we're not calling 42 | // ourselfs in a recursive manner here 43 | action( 44 | () => { 45 | // Handling arguments of a callback invoked by JavaScript with variadic arguments isn't 46 | // straight forward to handling in ReasonML/OCaml's type system. The trick we do below is 47 | // to grab all the arguments provided by JavaScript, extract the latest argument and treat it 48 | // as "options", then put all preceding arguments into a string array 49 | let arguments = [%bs.raw {| arguments |}]; 50 | let argumentsCount = Js.Array.length(arguments); 51 | let options: actionFnOptions = 52 | Js.Array.unsafe_get(arguments, argumentsCount - 1); 53 | let allArgsExceptLast: array(string) = 54 | Belt.Array.slice(arguments, ~offset=0, ~len=argumentsCount - 1); 55 | 56 | // this it what ends up invoking the callback provided by the code using Commander.action() 57 | fn(allArgsExceptLast, options); 58 | }, 59 | t, 60 | ); 61 | }; 62 | 63 | // bs.get gets a specific field from a JavaScript object 64 | [@bs.get] external getArgs: t => array(string) = "args"; 65 | // bs.get_index gets a user specified field from a JavaScript object, string argument decides which field to get 66 | [@bs.get_index] 67 | external getOption: (actionFnOptions, string) => option(string) = ""; 68 | 69 | [@bs.get_index] 70 | external getOptionAsInt: (actionFnOptions, string) => option(int) = ""; 71 | 72 | [@bs.get_index] 73 | external getOptionAsBoolean: (actionFnOptions, string) => option(bool) = ""; 74 | 75 | /** 76 | This overrides the Commander.getOptionAsBoolean() function for users of this module because commander.js does 77 | return an *actual* boolean value when an option exist that doesn't have an argument. That will make commander.js 78 | return the CLI option's value as a boolean, rather than a string as it does for other options that accepts an argument. 79 | 80 | Since returning an optional value wrapping a boolean to users of this module, feels a bit clunky, it converts that 81 | optional value that's returned from commander.js into an concrete bool value instead. 82 | */ 83 | let getOptionAsBoolean = (actionFnOptions, optionName): bool => { 84 | let maybeBoolValue = getOptionAsBoolean(actionFnOptions, optionName); 85 | 86 | Belt.Option.getWithDefault(maybeBoolValue, false); 87 | }; 88 | 89 | [@bs.send.pipe: t] external arguments: string => t = "arguments"; 90 | [@bs.send.pipe: t] external command: string => t = "command"; 91 | [@bs.send.pipe: t] external description: string => t = "description"; 92 | [@bs.send.pipe: t] external help: unit = "help"; 93 | [@bs.send.pipe: t] external option: (string, string) => t = "option"; 94 | [@bs.send.pipe: t] 95 | external optionWithDefault: (string, string, string) => t = "option"; 96 | [@bs.send.pipe: t] external parse: array(string) => t = "parse"; 97 | [@bs.send.pipe: t] external version: string => t = "version"; 98 | 99 | // commander.js' .option() also accepts a validator-function provided as the third argument, 100 | // if so, the forth argument is the default value, not the third as usual 101 | [@bs.send.pipe: t] 102 | external optionWithIntDefault: (string, string, string => int, int) => t = 103 | "option"; 104 | 105 | let optionWithIntDefault = (name, description, defaultValue) => { 106 | optionWithIntDefault( 107 | name, 108 | description, 109 | valueProvidedByEndUser => 110 | try (int_of_string(valueProvidedByEndUser)) { 111 | | Failure(_) => 112 | Js.log( 113 | "Invalid number value (" 114 | ++ valueProvidedByEndUser 115 | ++ ") provided to " 116 | ++ name 117 | ++ ", will be using " 118 | ++ string_of_int(defaultValue) 119 | ++ " instead.", 120 | ); 121 | defaultValue; 122 | }, 123 | defaultValue, 124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/Config.re: -------------------------------------------------------------------------------- 1 | open Belt; 2 | 3 | module Protocol = { 4 | type t = 5 | | HTTP 6 | | HTTPS; 7 | 8 | let toString = protocol => 9 | switch (protocol) { 10 | | HTTPS => "https" 11 | | HTTP => "http" 12 | }; 13 | 14 | let fromString = protocol => { 15 | switch (protocol) { 16 | | "http" => HTTP 17 | | _ => HTTPS 18 | }; 19 | }; 20 | }; 21 | 22 | type t = { 23 | domain: string, 24 | token: string, 25 | ignoreSSL: bool, 26 | protocol: Protocol.t, 27 | concurrency: int, 28 | }; 29 | 30 | // As the type below is passed to JavaScript as a configuration object, 31 | // it can't be a native ReasonML type, but rather a derived type so that 32 | // the bucklescript compiler creates a proper JavaScript object of it 33 | // with field names set as expected etc -- native ReasonML record types 34 | // won't work because they're internally made with JavaScript arrays 35 | [@bs.deriving abstract] 36 | type serialisedConfig = { 37 | [@bs.optional] 38 | domain: string, 39 | [@bs.optional] 40 | token: string, 41 | [@bs.optional] 42 | config: string, 43 | [@bs.optional] 44 | ignoreSSL: bool, 45 | [@bs.optional] 46 | concurrency: int, 47 | }; 48 | 49 | // https://www.npmjs.com/package/rc 50 | [@bs.module] external rc: string => serialisedConfig = "rc"; 51 | 52 | let defaultDomain = "gitlab.com"; 53 | let defaultDirectory = "."; 54 | let defaultConcurrency = 25; 55 | let defaultArchive = "all"; 56 | 57 | let parseProtocolAndDomain = rootApiUriOrOnlyDomain => { 58 | let splitOnScheme = 59 | rootApiUriOrOnlyDomain |> Js.String.toLowerCase |> Js.String.split("://"); 60 | 61 | switch (splitOnScheme) { 62 | | [|protocol, domain|] => (Protocol.fromString(protocol), domain) 63 | | [|domain|] => (Protocol.HTTPS, domain) 64 | | [||] => (Protocol.HTTPS, defaultDomain) 65 | | _ => 66 | raise( 67 | Js.Exn.raiseError( 68 | "Configured API domain does not look like a valid domain or root GitLab API URI, please double check your configuration of: " 69 | ++ rootApiUriOrOnlyDomain, 70 | ), 71 | ) 72 | }; 73 | }; 74 | 75 | let loadFromFile = (): Belt.Result.t(t, string) => { 76 | let result = rc("gitlabsearch"); 77 | let ignoreSSL = Option.getWithDefault(ignoreSSLGet(result), false); 78 | let concurrency = 79 | Option.getWithDefault(concurrencyGet(result), defaultConcurrency); 80 | let (protocol, domain) = 81 | domainGet(result) 82 | ->Option.getWithDefault(defaultDomain) 83 | ->parseProtocolAndDomain; 84 | 85 | switch (configGet(result)) { 86 | | Some(configPath) => 87 | Option.mapWithDefault( 88 | tokenGet(result), 89 | Result.Error( 90 | "No personal access token was found in " 91 | ++ configPath 92 | ++ ", please run setup again!", 93 | ), 94 | token => 95 | Result.Ok({domain, concurrency, token, ignoreSSL, protocol}) 96 | ) 97 | | None => 98 | Result.Error( 99 | "Could not find .gitlabsearchrc configuration file anywhere, have you run setup yet?", 100 | ) 101 | }; 102 | }; 103 | 104 | let writeToFile = 105 | ( 106 | ~domainOrRootUri: string, 107 | ~ignoreSSL: bool, 108 | ~token: string, 109 | ~directory, 110 | ~concurrency, 111 | ) => { 112 | let filePath = Node.Path.join2(directory, ".gitlabsearchrc"); 113 | let domain = 114 | domainOrRootUri == defaultDomain ? None : Some(domainOrRootUri); 115 | let ignoreSSL = ignoreSSL ? Some(true) : None; 116 | let concurrency = 117 | concurrency == defaultConcurrency ? None : Some(concurrency); 118 | let content = 119 | serialisedConfig(~domain?, ~ignoreSSL?, ~token, ~concurrency?, ()); 120 | 121 | Node.Fs.writeFileSync( 122 | filePath, 123 | Js.Option.getExn(Js.Json.stringifyAny(content)), 124 | `utf8, 125 | ); 126 | 127 | filePath; 128 | }; 129 | -------------------------------------------------------------------------------- /src/GitLab.re: -------------------------------------------------------------------------------- 1 | open Belt; 2 | 3 | [@bs.val] external debugEnv: Js.Nullable.t(string) = "process.env.DEBUG"; 4 | 5 | type group = { 6 | id: string, 7 | name: string, 8 | }; 9 | 10 | type project = { 11 | id: int, 12 | name: string, 13 | web_url: string, 14 | archived: bool, 15 | }; 16 | 17 | type searchFilter = 18 | | Filename(option(string)) 19 | | Extension(option(string)) 20 | | Path(option(string)); 21 | 22 | type searchCriterias = { 23 | term: string, 24 | filters: array(searchFilter), 25 | }; 26 | 27 | type searchResult = { 28 | data: string, 29 | filename: string, 30 | ref: string, 31 | startline: int, 32 | }; 33 | 34 | module Decode = { 35 | open Json.Decode; 36 | 37 | let group = json => { 38 | id: json |> field("id", int) |> string_of_int, 39 | name: json |> field("name", string), 40 | }; 41 | let groups = json => json |> array(group); 42 | 43 | let project = json => { 44 | id: json |> field("id", int), 45 | name: json |> field("name", string), 46 | web_url: json |> field("web_url", string), 47 | archived: json |> field("archived", bool), 48 | }; 49 | let projects = json => json |> array(project); 50 | 51 | let searchResult = json => { 52 | data: json |> field("data", string), 53 | filename: json |> field("filename", string), 54 | ref: json |> field("ref", string), 55 | startline: json |> field("startline", int), 56 | }; 57 | let searchResults = (project, json) => ( 58 | project, 59 | array(searchResult, json), 60 | ); 61 | }; 62 | 63 | let configResult = Config.loadFromFile(); 64 | 65 | let httpsAgent = 66 | switch (configResult) { 67 | | Belt.Result.Ok(config) => 68 | switch (config.protocol) { 69 | | HTTP => None 70 | | HTTPS => 71 | Axios.Agent.Https.config( 72 | ~rejectUnauthorized=!config.ignoreSSL, 73 | ~maxSockets=config.concurrency, 74 | (), 75 | ) 76 | ->Axios.Agent.Https.create 77 | ->Some 78 | } 79 | | Belt.Result.Error(_) => None 80 | }; 81 | 82 | let debugLog = (text): unit => { 83 | let isDebugEnabled = !Js.Nullable.isNullable(debugEnv); 84 | 85 | if (isDebugEnabled) { 86 | Js.log(text); 87 | }; 88 | }; 89 | 90 | let request = (relativeUrl, decoder) => { 91 | let config = 92 | switch (configResult) { 93 | | Belt.Result.Ok(value) => value 94 | | Belt.Result.Error(failureReason) => 95 | raise(Js.Exn.raiseError(failureReason)) 96 | }; 97 | 98 | let headers = Axios.Headers.fromObj({"Private-Token": config.token}); 99 | let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); 100 | let scheme = Config.Protocol.toString(config.protocol) ++ "://"; 101 | let url = scheme ++ config.domain ++ "/api/v4" ++ relativeUrl; 102 | 103 | debugLog("Requesting: GET " ++ url); 104 | 105 | Js.Promise.( 106 | Axios.getc(url, options) 107 | |> then_(response => resolve(response##data)) 108 | |> then_(json => resolve(decoder(json))) 109 | ); 110 | }; 111 | 112 | // Helpful when parsing hypermedia Link header values while paginating where URLs will be provided like: 113 | // 114 | let urlWithoutAngleBrackets = url => 115 | Js.String.substring(~from=1, ~to_=Js.String.length(url) - 1, url); 116 | 117 | let getNextPaginationUrl = response => { 118 | // Example from the docs: 119 | // link: ; rel="prev", ; rel="next", ; rel="first", ; rel="last" 120 | // 121 | // Refs https://docs.gitlab.com/ee/api/README.html#pagination 122 | let linkHeader: option(string) = response##headers##link; 123 | 124 | let nextLinkUrl = 125 | Option.flatMap( 126 | linkHeader, 127 | header => { 128 | let linkEntries = Js.String.split(",", header); 129 | let links = 130 | Array.map( 131 | linkEntries, 132 | linkEntry => { 133 | let parts = 134 | Js.String.split(";", linkEntry)->Array.map(Js.String.trim); 135 | 136 | let linkUrl = urlWithoutAngleBrackets(Array.getExn(parts, 0)); 137 | let linkRel = Array.getExn(parts, 1); 138 | 139 | (linkUrl, linkRel); 140 | }, 141 | ); 142 | 143 | links 144 | ->Array.keepMap(link => { 145 | let (url, rel) = link; 146 | 147 | rel == "rel=\"next\"" ? Some(url) : None; 148 | }) 149 | ->Array.get(0); 150 | }, 151 | ); 152 | 153 | nextLinkUrl; 154 | }; 155 | 156 | type requestUrl = 157 | | RelativeUrl(string) // provided initially when kicking off a paginated request 158 | | AbsoluteUrl(string); // provided when more pages of results has to be fetched when paginating 159 | 160 | let rec paginatedRequest = (url: requestUrl, decoder: Js.Json.t => array('a)) => { 161 | let config = 162 | switch (configResult) { 163 | | Belt.Result.Ok(value) => value 164 | | Belt.Result.Error(failureReason) => 165 | raise(Js.Exn.raiseError(failureReason)) 166 | }; 167 | 168 | let headers = Axios.Headers.fromObj({"Private-Token": config.token}); 169 | let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); 170 | let scheme = Config.Protocol.toString(config.protocol) ++ "://"; 171 | let urlToRequest = 172 | switch (url) { 173 | | RelativeUrl(path) => scheme ++ config.domain ++ "/api/v4" ++ path 174 | | AbsoluteUrl(url) => url 175 | }; 176 | 177 | debugLog("Requesting: GET " ++ urlToRequest); 178 | 179 | Js.Promise.( 180 | Axios.getc(urlToRequest, options) 181 | |> then_(response => { 182 | let nextUrl = getNextPaginationUrl(response); 183 | let json = response##data; 184 | let entities = decoder(json); 185 | 186 | switch (nextUrl) { 187 | | Some(url) => 188 | paginatedRequest(AbsoluteUrl(url), decoder) 189 | |> then_(entitesOnNextPage => 190 | resolve(Array.concat(entities, entitesOnNextPage)) 191 | ) 192 | 193 | | None => resolve(entities) 194 | }; 195 | }) 196 | ); 197 | }; 198 | 199 | let groupsFromStringNames = namesAsString => { 200 | let names = Js.String.split(",", namesAsString); 201 | let groups = Array.map(names, name => {id: name, name}); 202 | 203 | Js.Promise.resolve(groups); 204 | }; 205 | 206 | // https://docs.gitlab.com/ee/api/groups.html#list-groups 207 | let fetchGroups = (groupsNames: option(string)) => { 208 | let groupsResult = 209 | switch (groupsNames) { 210 | | Some(names) => groupsFromStringNames(names) 211 | | None => 212 | paginatedRequest(RelativeUrl("/groups?per_page=100"), Decode.groups) 213 | }; 214 | 215 | Js.Promise.( 216 | groupsResult 217 | |> then_(groups => { 218 | let resolvedNames = 219 | Array.map(groups, (group: group) => group.name) 220 | |> Js.Array.joinWith(", "); 221 | 222 | debugLog("Using groups: " ++ resolvedNames); 223 | 224 | resolve(groups); 225 | }) 226 | ); 227 | }; 228 | 229 | // https://docs.gitlab.com/ee/api/groups.html#list-a-groups-projects 230 | let fetchProjectsInGroups = (archiveArgument: option(string), groups: array(group)) => { 231 | let archiveQueryParam = switch (archiveArgument) { 232 | | Some("only") => "&archived=true"; 233 | | Some("exclude") => "&archived=false"; 234 | | _ => ""; 235 | }; 236 | let requests = 237 | Array.map( 238 | groups, 239 | // Very surprised this had to be annotated to be a group, cause or else it would be 240 | // inferred as a project -- why on earth would that happen when the compiler gets very 241 | // explicit information about the incoming function argument is a list of groups 242 | (group: group) => 243 | paginatedRequest( 244 | RelativeUrl("/groups/" ++ group.id ++ "/projects?per_page=100" ++ archiveQueryParam), 245 | Decode.projects, 246 | ) 247 | ); 248 | 249 | // this list <-> array is quite a pain in the backside, but don't have much choice 250 | // since Promise.all() takes an array and list is the structure that has built-in flattening 251 | Js.Promise.( 252 | all(requests) 253 | // concatMany == what usually is called flatten 254 | |> then_(projects => resolve(Array.concatMany(projects))) 255 | |> then_(allProjects => { 256 | let resolvedNames = 257 | Array.map(allProjects, (project: project) => project.name) 258 | |> Js.Array.joinWith(", "); 259 | 260 | debugLog("Using projects: " ++ resolvedNames); 261 | 262 | resolve(allProjects); 263 | }) 264 | ); 265 | }; 266 | 267 | let searchUrlParameter = (criterias: searchCriterias): string => { 268 | let filters = 269 | Array.( 270 | criterias.filters 271 | ->map(filter => 272 | switch (filter) { 273 | | Filename(value) => ("filename", value) 274 | | Extension(value) => ("extension", value) 275 | | Path(value) => ("path", value) 276 | } 277 | ) 278 | ->keepMap(((parameterName, optionalValue)) => 279 | Option.map(optionalValue, value => 280 | parameterName ++ ":" ++ Js.Global.encodeURIComponent(value) 281 | ) 282 | ) 283 | ); 284 | 285 | "&search=" 286 | ++ Js.Global.encodeURIComponent(criterias.term) 287 | ++ " " 288 | ++ Js.Array.joinWith(" ", filters); 289 | }; 290 | 291 | // https://docs.gitlab.com/ee/api/search.html#scope-blobs-2 292 | let searchInProjects = 293 | (criterias: searchCriterias, projects: array(project)) 294 | : Js.Promise.t(array((project, array(searchResult)))) => { 295 | let requests = 296 | Array.map(projects, project => 297 | request( 298 | "/projects/" 299 | ++ string_of_int(project.id) 300 | ++ "/search?scope=blobs" 301 | ++ searchUrlParameter(criterias), 302 | Decode.searchResults(project), 303 | ) 304 | ); 305 | 306 | Js.Promise.( 307 | all(requests) 308 | |> then_(results => 309 | resolve( 310 | // keep == filter which is only available on List for some reason 311 | Array.keep(results, ((_, searchResults)) => 312 | Array.length(searchResults) > 0 313 | ), 314 | ) 315 | ) 316 | ); 317 | }; 318 | -------------------------------------------------------------------------------- /src/Main.re: -------------------------------------------------------------------------------- 1 | let packageJson = [%bs.raw {| require("../../../package.json") |}]; 2 | 3 | let program = Commander.(make() |> version(packageJson##version)); 4 | 5 | let main = (args, options) => { 6 | let getOption = optionName => Commander.getOption(options, optionName); 7 | let groups = getOption("groups"); 8 | let criterias = 9 | GitLab.{ 10 | // daring to do an unsafe get operation below because commander.js *should* have 11 | // ensured the search term argument is available before invoking this function 12 | term: Belt.Array.getUnsafe(args, 0), 13 | filters: [| 14 | Filename(getOption("filename")), 15 | Extension(getOption("extension")), 16 | Path(getOption("path")), 17 | |], 18 | }; 19 | 20 | Js.Promise.( 21 | GitLab.fetchGroups(groups) 22 | |> then_(GitLab.fetchProjectsInGroups(getOption("archive"))) 23 | |> then_(GitLab.searchInProjects(criterias)) 24 | |> then_(results => 25 | resolve(Print.searchResults(criterias.term, results)) 26 | ) 27 | |> catch(err => resolve(Js.log2("Something exploded!", err))) 28 | |> ignore 29 | ); 30 | }; 31 | 32 | let setup = (args, options) => { 33 | // token is said to be a required argument so commander.js ensures it's present before executing this function 34 | let token = Belt.Array.getExn(args, 0); 35 | 36 | // options below has default values set in their definition so they always has a value 37 | let directory = Belt.Option.getExn(Commander.getOption(options, "dir")); 38 | let domainOrRootUri = 39 | Belt.Option.getExn(Commander.getOption(options, "apiDomain")); 40 | let concurrency = 41 | Belt.Option.getExn(Commander.getOptionAsInt(options, "concurrency")); 42 | let ignoreSSL = Commander.getOptionAsBoolean(options, "ignoreSsl"); 43 | 44 | let configPath = 45 | Config.writeToFile( 46 | ~domainOrRootUri, 47 | ~ignoreSSL, 48 | ~token, 49 | ~directory, 50 | ~concurrency, 51 | ); 52 | Print.successful( 53 | "Successfully wrote config to " 54 | ++ configPath 55 | ++ ", gitlab-search is now ready to be used", 56 | ); 57 | }; 58 | 59 | Commander.( 60 | program 61 | |> arguments("") 62 | |> option( 63 | "-g, --groups ", 64 | "group(s) to find repositories in (separated with comma)", 65 | ) 66 | |> option( 67 | "-f, --filename ", 68 | "only search for contents in given a file, glob matching with wildcards (*)", 69 | ) 70 | |> option( 71 | "-e, --extension ", 72 | "only search for contents in files with given extension", 73 | ) 74 | |> option("-p, --path ", "only search in files in the given path") 75 | |> optionWithDefault( 76 | "-a, --archive [all,only,exclude]", 77 | "to only search on archived repositories, or to exclude them, by default the search will be apply to all repositories", 78 | Config.defaultArchive, 79 | ) 80 | |> action(main) 81 | ); 82 | 83 | Commander.( 84 | program 85 | |> command("setup") 86 | |> description("create configuration file") 87 | |> arguments("") 88 | |> option( 89 | "--ignore-ssl", 90 | "ignore invalid SSL certificate from the GitLab API server", 91 | ) 92 | |> optionWithDefault( 93 | "--api-domain ", 94 | "domain name or root URL of GitLab API server,\nspecify root URL (without trailing slash) to use HTTP instead of HTTPS", 95 | Config.defaultDomain, 96 | ) 97 | |> optionWithDefault( 98 | "--dir ", 99 | "path to directory to save configuration file in", 100 | Config.defaultDirectory, 101 | ) 102 | |> optionWithIntDefault( 103 | "--concurrency ", 104 | "limit the amount of concurrent HTTPS requests sent to GitLab when searching,\nuseful when *many* projects are hosted on a small GitLab instance\nto avoid overwhelming the instance resulting in 502 errors", 105 | Config.defaultConcurrency, 106 | ) 107 | |> action(setup) 108 | ); 109 | 110 | Commander.parse(Node.Process.argv, program); 111 | 112 | // commander.js doesn't display help when no arguments are provided by default, 113 | // so we've gotta do that check ourselfs 114 | let args = Commander.getArgs(program); 115 | if (Array.length(args) == 0) { 116 | Commander.help(program); 117 | }; 118 | -------------------------------------------------------------------------------- /src/Print.re: -------------------------------------------------------------------------------- 1 | open Chalk; 2 | open Belt; 3 | 4 | let urlToLineInFile = (project: GitLab.project, result: GitLab.searchResult) => { 5 | project.web_url 6 | ++ "/blob/" 7 | ++ result.ref 8 | ++ "/" 9 | ++ result.filename 10 | ++ "#L" 11 | ++ string_of_int(result.startline); 12 | }; 13 | 14 | let indentPreview = preview => 15 | Js.String.replaceByRe([%re "/\\n/g"], "\n\t\t", preview); 16 | 17 | let highlightMatchedTerm = (term, data) => 18 | Js.String.replaceByRe( 19 | Js.Re.fromStringWithFlags("(" ++ term ++ ")", ~flags="gi"), 20 | // by using a regex catch group, we ensure to keep the original capitalization of 21 | // the matched source code, rather than the search term entered by the end-user 22 | red("$1"), 23 | data, 24 | ); 25 | 26 | let searchResults = 27 | ( 28 | term: string, 29 | results: array((GitLab.project, array(GitLab.searchResult))), 30 | ) => { 31 | Array.forEach( 32 | results, 33 | result => { 34 | let (project: GitLab.project, searchResults) = result; 35 | let formattedResults = 36 | Array.reduce(searchResults, "", (sum, current) => 37 | sum 38 | ++ "\n\t" 39 | ++ underline(urlToLineInFile(project, current)) 40 | ++ "\n\n\t\t" 41 | ++ highlightMatchedTerm(term, indentPreview(current.data)) 42 | ); 43 | 44 | let archivedInfo = project.archived ? bold(red(" (archived)")) : ""; 45 | 46 | Js.log(bold(green(project.name ++ archivedInfo ++ ":"))); 47 | Js.log(formattedResults); 48 | }, 49 | ); 50 | }; 51 | 52 | let successful = message => Js.log(green({js|✔|js}) ++ " " ++ message); 53 | --------------------------------------------------------------------------------