├── .editorconfig ├── .eslintrc ├── .github └── release.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── index.d.ts ├── jest.config.js ├── lib ├── index.js └── index.test.js ├── package-lock.json └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | store: zeus 2 | targets: 3 | - github 4 | - npm 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: npm 3 | 4 | branches: 5 | only: 6 | - master 7 | - beta # semantic-release preview releases 8 | - next # semantic-release @next releases 9 | - /^\d+\.x$/ # semantic-release maintenance releases 10 | - /^greenkeeper.*$/ # Greenkeeper updates 11 | 12 | stages: 13 | - test 14 | - name: release 15 | if: branch =~ /^(\d+\.x|master|next|beta)$/ AND type IN (push) 16 | 17 | jobs: 18 | include: 19 | - stage: test 20 | node_js: 12 21 | - node_js: 10 22 | - node_js: 8 23 | - stage: release 24 | node_js: lts/* 25 | env: semantic-release 26 | script: npx semantic-release 27 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "EditorConfig.editorconfig", 7 | "esbenp.prettier-vscode", 8 | "Orta.vscode-jest" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnType": true, 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "editor.rulers": [80], 6 | "files.autoSave": "onWindowChange", 7 | "files.trimTrailingWhitespace": true, 8 | "files.insertFinalNewline": true, 9 | 10 | // Plugin Settings 11 | "eslint.autoFixOnSave": true 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Sentry (https://sentry.io/) and individual contributors. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project has been moved to Probot core 2 | 3 | This project has been merged into [Probot](https://github.com/probot/probot) itself as part of the core `context.config` helper. Any future development takes place there. 4 | 5 | # Probot: Config 6 | 7 | [![Downloads][npm-downloads]][npm-url] [![version][npm-version]][npm-url] 8 | [![License][npm-license]][license-url] 9 | [![Build Status][travis-status]][travis-url] 10 | 11 | A [Probot](https://probot.github.io) extension to easily share configs between 12 | repositories. 13 | 14 | ## Setup 15 | 16 | Just put common configuration keys in a common repository within your 17 | organization. Then reference this repository from config files with the same 18 | name. 19 | 20 | ```yaml 21 | # octocat/probot-settings:.github/test.yaml 22 | shared1: will be merged 23 | shared2: will also be merged 24 | 25 | # octocat/repo1:.github/test.yaml 26 | _extends: probot-settings 27 | other: AAA 28 | 29 | # octocat/repo2:.github/test.yaml 30 | _extends: probot-settings 31 | shared2: overrides shared2 32 | other: BBB 33 | 34 | # octocat/repo3:.github/test.yaml 35 | other: CCC # standalone, does not extend other configs 36 | ``` 37 | 38 | Configs are deeply-merged. Nested objects do not have to be redefined 39 | completely. This is accomplished using [deepmerge](https://github.com/KyleAMathews/deepmerge). When using probot-config in an app, you can pass [options](https://github.com/KyleAMathews/deepmerge#options) through to `deepmerge`. 40 | 41 | You can also reference configurations from other organizations: 42 | 43 | ```yaml 44 | _extends: other/probot-settings 45 | other: DDD 46 | ``` 47 | 48 | Additionally, you can specify a specific path for the configuration by 49 | appending a colon after the project. 50 | 51 | ```yaml 52 | _extends: probot-settings:.github/other_test.yaml 53 | other: FFF 54 | ``` 55 | 56 | Inherited configurations are in the **exact same location** within the 57 | repositories. 58 | 59 | ```yaml 60 | # octocat/repo1:.github/test.yaml 61 | _extends: .github 62 | other: GGG 63 | 64 | # octocat/.github:test.yaml 65 | other: HHH 66 | ``` 67 | 68 | Additionally, if there is no config file, but there is a repo in the org named 69 | `.github`, it will be used as a base repository. 70 | 71 | ```yaml 72 | # octocat/repo1:.github/test.yaml <-- missing! 73 | # octocat/.github:.github/test.yaml 74 | other: III 75 | ``` 76 | 77 | ## Recipes 78 | 79 | These recipes are specific to usage of the .github repo name, which is the 80 | recommended place to store your configuration files. Within the .github repository, 81 | your configuration must live in a `.github/` folder. 82 | 83 | ### An opt-in pattern 84 | 85 | You may want to create a configuration that other projects in your org inherit 86 | from on an explicit opt-in basis. Example: 87 | 88 | ```yaml 89 | # octocat/.github:.github/_test.yaml 90 | shared1: Will be inherited by repo1 and not repo2 91 | 92 | # octocat/repo1:.github/test.yaml 93 | # Inherits from octocat/.github:_test.yaml 94 | _extends: .github:_test.yaml 95 | # octocat/repo3:.github/test.yaml <--missing! 96 | # Is not merged with another config. 97 | ``` 98 | 99 | ### An opt-out pattern 100 | 101 | Alternatively, you may want to default to the config in your `.github` project 102 | and occasionally opt-out. Example: 103 | 104 | ```yaml 105 | # octocat/.github:.github/test.yaml 106 | shared1: Will be inherited by repo1 and not repo2 107 | # octocat/repo1:.github/test.yaml <-- missing! 108 | # Uses octocat/.github:test.yaml instead 109 | 110 | # octocat/repo3:.github/test.yaml <-- either empty or populated 111 | # Will not inherit shared1, since no _extends field is specified 112 | ``` 113 | 114 | ## Usage 115 | 116 | ```js 117 | const getConfig = require('probot-config'); 118 | 119 | module.exports = robot => { 120 | robot.on('push', async context => { 121 | // Will look for 'test.yml' inside the '.github' folder 122 | const config = await getConfig(context, 'test.yml'); 123 | }); 124 | }; 125 | ``` 126 | 127 | ## Development 128 | 129 | ```sh 130 | # Install dependencies 131 | npm install 132 | 133 | # Run the bot 134 | npm start 135 | 136 | # Run test watchers 137 | npm run test:watch 138 | ``` 139 | 140 | We use [prettier](https://prettier.io/) for auto-formatting and 141 | [eslint](https://eslint.org/) as linter. Both tools can automatically fix a lot 142 | of issues for you. To invoke them, simply run: 143 | 144 | ```sh 145 | npm run fix 146 | ``` 147 | 148 | It is highly recommended to use VSCode and install the suggested extensions. 149 | They will configure your IDE to match the coding style, invoke auto formatters 150 | every time you save and run tests in the background for you. No need to run the 151 | watchers manually. 152 | 153 | [license-url]: https://github.com/getsentry/probot-config/blob/master/LICENSE 154 | [npm-url]: https://www.npmjs.com/package/probot-config 155 | [npm-license]: https://img.shields.io/npm/l/probot-config.svg?style=flat 156 | [npm-version]: https://img.shields.io/npm/v/probot-config.svg?style=flat 157 | [npm-downloads]: https://img.shields.io/npm/dm/probot-config.svg?style=flat 158 | [travis-url]: https://travis-ci.org/getsentry/probot-config 159 | [travis-status]: https://travis-ci.org/getsentry/probot-config.svg?branch=master 160 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'probot-config' { 2 | import { Context } from 'probot'; 3 | import { Options as DeepMergeOptions } from 'deepmerge'; 4 | 5 | export default function getConfig( 6 | context: Context, 7 | fileName: string, 8 | defaultConfig?: T, 9 | deepMergeOptions?: DeepMergeOptions 10 | ): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const yaml = require('js-yaml'); 3 | const merge = require('deepmerge'); 4 | 5 | const CONFIG_PATH = '.github'; 6 | const BASE_KEY = '_extends'; 7 | const BASE_REGEX = new RegExp( 8 | '^' + 9 | '(?:([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/)?' + // org 10 | '([-_.\\w\\d]+)' + // project 11 | '(?::([-_./\\w\\d]+\\.ya?ml))?' + // filename 12 | '$', 13 | 'i' 14 | ); 15 | const DEFAULT_BASE = '.github'; 16 | 17 | /** 18 | * Decodes and parses a YAML config file 19 | * 20 | * @param {string} content Base64 encoded YAML contents 21 | * @returns {object} The parsed YAML file as native object 22 | */ 23 | function parseConfig(content) { 24 | return yaml.safeLoad(Buffer.from(content, 'base64').toString()) || {}; 25 | } 26 | 27 | /** 28 | * Merges an array of configs 29 | * 30 | * @param {array} configs The configs to merge 31 | * @param {object} options https://github.com/KyleAMathews/deepmerge#options 32 | * @returns {object} The merged configuration 33 | */ 34 | function deepMergeConfigs(configs, options) { 35 | return merge.all(configs.filter(config => config), options); 36 | } 37 | 38 | /** 39 | * Loads a file from GitHub 40 | * 41 | * @param {Context} context A Probot context 42 | * @param {object} params Params to fetch the file with 43 | * @returns {Promise} The parsed YAML file 44 | * @async 45 | */ 46 | async function loadYaml(context, params) { 47 | try { 48 | const response = await context.github.repos.getContents(params); 49 | return parseConfig(response.data.content); 50 | } catch (e) { 51 | if (e.code === 404) { 52 | return null; 53 | } 54 | 55 | throw e; 56 | } 57 | } 58 | 59 | /** 60 | * Computes parameters for the repository specified in base 61 | * 62 | * Base can either be the name of a repository in the same organization or 63 | * a full slug "organization/repo". 64 | * 65 | * @param {object} params An object containing owner, repo and path 66 | * @param {string} base A string specifying the base repository 67 | * @returns {object} The params of the base configuration 68 | */ 69 | function getBaseParams(params, base) { 70 | if (typeof base !== 'string') { 71 | throw new Error(`Invalid repository name in key "${BASE_KEY}"`); 72 | } 73 | 74 | const match = base.match(BASE_REGEX); 75 | if (match == null) { 76 | throw new Error(`Invalid repository name in key "${BASE_KEY}": ${base}`); 77 | } 78 | 79 | return { 80 | owner: match[1] || params.owner, 81 | repo: match[2], 82 | path: match[3] || params.path, 83 | }; 84 | } 85 | 86 | /** 87 | * Loads the specified config file from the context's repository 88 | * 89 | * If the config file contains a top-level key "_extends", it is merged 90 | * with a config of the same name in the repository specified by the 91 | * _extends key. The repository of the base configuration can either be 92 | * given as "repository" or as "organization/repository". 93 | * 94 | * If the config file does not exist in the context's repository, `null` 95 | * is returned. If the base repository does not exist or does not contain 96 | * the config file, it is ignored. 97 | * 98 | * If a default config is given, it is merged with the config from the 99 | * repository, if it exists. 100 | * 101 | * @param {Context} context A Probot context 102 | * @param {string} fileName Name of the config file 103 | * @param {object} defaultConfig A default config that is merged in 104 | * @param {object} deepMergeOptions from deepmerge 105 | * @returns {object} The merged configuration 106 | * @async 107 | */ 108 | async function getConfig(context, fileName, defaultConfig, deepMergeOptions) { 109 | const filePath = path.posix.join(CONFIG_PATH, fileName); 110 | const params = context.repo({ 111 | path: filePath, 112 | }); 113 | 114 | const config = await loadYaml(context, params); 115 | let baseRepo; 116 | if (config == null) { 117 | baseRepo = DEFAULT_BASE; 118 | } else if (config != null && BASE_KEY in config) { 119 | baseRepo = config[BASE_KEY]; 120 | delete config[BASE_KEY]; 121 | } 122 | 123 | let baseConfig; 124 | if (baseRepo) { 125 | const baseParams = getBaseParams(params, baseRepo); 126 | baseConfig = await loadYaml(context, baseParams); 127 | } 128 | 129 | if (config == null && baseConfig == null && !defaultConfig) { 130 | return null; 131 | } 132 | 133 | return deepMergeConfigs( 134 | [defaultConfig, baseConfig, config], 135 | deepMergeOptions 136 | ); 137 | } 138 | 139 | module.exports = getConfig; 140 | -------------------------------------------------------------------------------- /lib/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | const getConfig = require('./index'); 3 | 4 | const { fn } = jest; 5 | 6 | function mockContext(getContents) { 7 | return { 8 | repo(params) { 9 | return Object.assign( 10 | { 11 | owner: 'owner', 12 | repo: 'repo', 13 | }, 14 | params 15 | ); 16 | }, 17 | 18 | github: { 19 | repos: { 20 | async getContents(params) { 21 | const content = Buffer.from(getContents(params)).toString('base64'); 22 | return { 23 | data: { 24 | content, 25 | }, 26 | }; 27 | }, 28 | }, 29 | }, 30 | }; 31 | } 32 | 33 | function mockError(code) { 34 | const err = new Error('Not found'); 35 | err.code = code; 36 | throw err; 37 | } 38 | 39 | test('returns null on 404', async () => { 40 | const spy = fn() 41 | .mockImplementationOnce(() => mockError(404)) 42 | .mockImplementationOnce(() => mockError(404)) 43 | .mockImplementationOnce(() => mockError(500)); 44 | 45 | const config = await getConfig(mockContext(spy), 'test.yml'); 46 | expect(config).toEqual(null); 47 | 48 | expect(spy).toHaveBeenCalledTimes(2); 49 | expect(spy).toHaveBeenCalledWith({ 50 | owner: 'owner', 51 | repo: 'repo', 52 | path: '.github/test.yml', 53 | }); 54 | expect(spy).toHaveBeenLastCalledWith({ 55 | owner: 'owner', 56 | repo: '.github', 57 | path: '.github/test.yml', 58 | }); 59 | }); 60 | 61 | test('loads a direct config', async () => { 62 | const spy = fn() 63 | .mockImplementationOnce(() => 'foo: foo') 64 | .mockImplementationOnce(() => mockError(500)); 65 | 66 | const config = await getConfig(mockContext(spy), 'test.yml'); 67 | expect(config).toEqual({ 68 | foo: 'foo', 69 | }); 70 | 71 | expect(spy).toHaveBeenCalledTimes(1); 72 | expect(spy).toHaveBeenLastCalledWith({ 73 | owner: 'owner', 74 | repo: 'repo', 75 | path: '.github/test.yml', 76 | }); 77 | }); 78 | 79 | test('merges the default config', async () => { 80 | const spy = fn() 81 | .mockImplementationOnce(() => 'foo: foo') 82 | .mockImplementationOnce(() => mockError(500)); 83 | 84 | const config = await getConfig(mockContext(spy), 'test.yml', { 85 | def: true, 86 | }); 87 | expect(config).toEqual({ 88 | foo: 'foo', 89 | def: true, 90 | }); 91 | 92 | expect(spy).toHaveBeenCalledTimes(1); 93 | expect(spy).toHaveBeenLastCalledWith({ 94 | owner: 'owner', 95 | repo: 'repo', 96 | path: '.github/test.yml', 97 | }); 98 | }); 99 | 100 | test('merges a base config', async () => { 101 | const spy = fn() 102 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: base') 103 | .mockImplementationOnce(() => 'bar: bar2\nbaz: baz'); 104 | 105 | const config = await getConfig(mockContext(spy), 'test.yml'); 106 | expect(config).toEqual({ 107 | foo: 'foo', 108 | bar: 'bar', 109 | baz: 'baz', 110 | }); 111 | 112 | expect(spy).toHaveBeenCalledTimes(2); 113 | expect(spy).toHaveBeenCalledWith({ 114 | owner: 'owner', 115 | repo: 'repo', 116 | path: '.github/test.yml', 117 | }); 118 | expect(spy).toHaveBeenLastCalledWith({ 119 | owner: 'owner', 120 | repo: 'base', 121 | path: '.github/test.yml', 122 | }); 123 | }); 124 | 125 | test('merges the base and default config', async () => { 126 | const spy = fn() 127 | .mockImplementationOnce(() => 'foo: foo\n_extends: base') 128 | .mockImplementationOnce(() => 'bar: bar'); 129 | 130 | const config = await getConfig(mockContext(spy), 'test.yml', { 131 | def: true, 132 | }); 133 | expect(config).toEqual({ 134 | foo: 'foo', 135 | bar: 'bar', 136 | def: true, 137 | }); 138 | 139 | expect(spy).toHaveBeenCalledTimes(2); 140 | expect(spy).toHaveBeenCalledWith({ 141 | owner: 'owner', 142 | repo: 'repo', 143 | path: '.github/test.yml', 144 | }); 145 | expect(spy).toHaveBeenLastCalledWith({ 146 | owner: 'owner', 147 | repo: 'base', 148 | path: '.github/test.yml', 149 | }); 150 | }); 151 | 152 | test('merges a base config from another organization', async () => { 153 | const spy = fn() 154 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: other/base') 155 | .mockImplementationOnce(() => 'bar: bar2\nbaz: baz'); 156 | 157 | const config = await getConfig(mockContext(spy), 'test.yml'); 158 | expect(config).toEqual({ 159 | foo: 'foo', 160 | bar: 'bar', 161 | baz: 'baz', 162 | }); 163 | 164 | expect(spy).toHaveBeenCalledTimes(2); 165 | expect(spy).toHaveBeenCalledWith({ 166 | owner: 'owner', 167 | repo: 'repo', 168 | path: '.github/test.yml', 169 | }); 170 | expect(spy).toHaveBeenLastCalledWith({ 171 | owner: 'other', 172 | repo: 'base', 173 | path: '.github/test.yml', 174 | }); 175 | }); 176 | 177 | test('merges a base config with a custom path', async () => { 178 | const spy = fn() 179 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: base:test.yml') 180 | .mockImplementationOnce(() => 'bar: bar2\nbaz: baz'); 181 | 182 | const config = await getConfig(mockContext(spy), 'test.yml'); 183 | expect(config).toEqual({ 184 | foo: 'foo', 185 | bar: 'bar', 186 | baz: 'baz', 187 | }); 188 | 189 | expect(spy).toHaveBeenCalledTimes(2); 190 | expect(spy).toHaveBeenCalledWith({ 191 | owner: 'owner', 192 | repo: 'repo', 193 | path: '.github/test.yml', 194 | }); 195 | expect(spy).toHaveBeenLastCalledWith({ 196 | owner: 'owner', 197 | repo: 'base', 198 | path: 'test.yml', 199 | }); 200 | }); 201 | 202 | test('ignores a missing base config', async () => { 203 | const spy = fn() 204 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: base') 205 | .mockImplementationOnce(() => mockError(404)); 206 | 207 | const config = await getConfig(mockContext(spy), 'test.yml'); 208 | expect(config).toEqual({ 209 | foo: 'foo', 210 | bar: 'bar', 211 | }); 212 | 213 | expect(spy).toHaveBeenCalledTimes(2); 214 | expect(spy).toHaveBeenCalledWith({ 215 | owner: 'owner', 216 | repo: 'repo', 217 | path: '.github/test.yml', 218 | }); 219 | expect(spy).toHaveBeenLastCalledWith({ 220 | owner: 'owner', 221 | repo: 'base', 222 | path: '.github/test.yml', 223 | }); 224 | }); 225 | 226 | test('loads an empty config', async () => { 227 | const spy = fn() 228 | .mockImplementationOnce(() => '') 229 | .mockImplementationOnce(() => mockError(500)); 230 | 231 | const config = await getConfig(mockContext(spy), 'test.yml'); 232 | expect(config).toEqual({}); 233 | 234 | expect(spy).toHaveBeenCalledTimes(1); 235 | expect(spy).toHaveBeenCalledWith({ 236 | owner: 'owner', 237 | repo: 'repo', 238 | path: '.github/test.yml', 239 | }); 240 | }); 241 | 242 | test('throws on error', async () => { 243 | try { 244 | expect.assertions(1); 245 | const spy = jest.fn().mockImplementation(() => mockError(500)); 246 | await getConfig(mockContext(spy), 'test.yml'); 247 | } catch (e) { 248 | expect(e.code).toBe(500); 249 | } 250 | }); 251 | 252 | test('throws on a non-string base', async () => { 253 | const spy = fn() 254 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: { nope }') 255 | .mockImplementationOnce(() => mockError(500)); 256 | 257 | try { 258 | expect.assertions(1); 259 | await getConfig(mockContext(spy), 'test.yml'); 260 | } catch (e) { 261 | expect(e.message).toMatch(/invalid/i); 262 | } 263 | }); 264 | 265 | test('throws on an invalid base', async () => { 266 | const spy = fn() 267 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: "nope:"') 268 | .mockImplementationOnce(() => mockError(500)); 269 | 270 | try { 271 | expect.assertions(1); 272 | await getConfig(mockContext(spy), 'test.yml'); 273 | } catch (e) { 274 | expect(e.message).toMatch(/nope:/); 275 | } 276 | }); 277 | 278 | test('uses the .github directory on a .github repo', async () => { 279 | const spy = fn() 280 | .mockImplementationOnce(() => 'foo: foo\nbar: bar\n_extends: .github') 281 | .mockImplementationOnce(() => 'bar: bar2\nbaz: baz'); 282 | 283 | const config = await getConfig(mockContext(spy), 'test.yml'); 284 | expect(config).toEqual({ 285 | foo: 'foo', 286 | bar: 'bar', 287 | baz: 'baz', 288 | }); 289 | 290 | expect(spy).toHaveBeenCalledTimes(2); 291 | expect(spy).toHaveBeenCalledWith({ 292 | owner: 'owner', 293 | repo: 'repo', 294 | path: '.github/test.yml', 295 | }); 296 | expect(spy).toHaveBeenLastCalledWith({ 297 | owner: 'owner', 298 | repo: '.github', 299 | path: '.github/test.yml', 300 | }); 301 | }); 302 | 303 | test('defaults to .github repo if no config found', async () => { 304 | const spy = fn() 305 | .mockImplementationOnce(() => mockError(404)) 306 | .mockImplementationOnce(() => 'foo: foo\nbar: bar'); 307 | 308 | const config = await getConfig(mockContext(spy), 'test.yml'); 309 | expect(config).toEqual({ 310 | foo: 'foo', 311 | bar: 'bar', 312 | }); 313 | 314 | expect(spy).toHaveBeenCalledTimes(2); 315 | expect(spy).toHaveBeenCalledWith({ 316 | owner: 'owner', 317 | repo: 'repo', 318 | path: '.github/test.yml', 319 | }); 320 | expect(spy).toHaveBeenLastCalledWith({ 321 | owner: 'owner', 322 | repo: '.github', 323 | path: '.github/test.yml', 324 | }); 325 | }); 326 | 327 | test('deep merges the base config', async () => { 328 | const spy = fn() 329 | .mockImplementationOnce( 330 | () => 'obj:\n foo:\n - name: master\n_extends: .github' 331 | ) 332 | .mockImplementationOnce(() => 'obj:\n foo:\n - name: develop'); 333 | 334 | const config = await getConfig(mockContext(spy), 'test.yml'); 335 | 336 | expect(config).toEqual({ 337 | obj: { 338 | foo: [ 339 | { 340 | name: 'develop', 341 | }, 342 | { 343 | name: 'master', 344 | }, 345 | ], 346 | }, 347 | }); 348 | }); 349 | 350 | test('accepts deepmerge options', async () => { 351 | const spy = fn() 352 | .mockImplementationOnce( 353 | () => 354 | 'foo:\n - name: master\n shouldChange: changed\n_extends: .github' 355 | ) 356 | .mockImplementationOnce( 357 | () => 358 | 'foo:\n - name: develop\n - name: master\n shouldChange: should' 359 | ); 360 | 361 | const customMerge = fn().mockImplementationOnce(); 362 | await getConfig( 363 | mockContext(spy), 364 | 'test.yml', 365 | {}, 366 | { 367 | arrayMerge: customMerge, 368 | } 369 | ); 370 | expect(customMerge).toHaveBeenCalled(); 371 | }); 372 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "probot-config", 3 | "version": "0.0.0-development", 4 | "description": "A Probot extension that manages shared configs", 5 | "main": "lib/index.js", 6 | "repository": "https://github.com/getsentry/probot-config", 7 | "author": "Jan Michael Auer ", 8 | "license": "BSD-3-Clause", 9 | "private": false, 10 | "scripts": { 11 | "fix:eslint": "eslint --fix lib", 12 | "fix:prettier": "prettier --write 'lib/**/*.js'", 13 | "fix": "npm-run-all fix:eslint fix:prettier", 14 | "start": "probot run ./index.js", 15 | "test:jest": "jest", 16 | "test:eslint": "eslint lib", 17 | "test:prettier": "prettier-check 'lib/**/*.js'", 18 | "test": "npm-run-all test:jest test:eslint test:prettier", 19 | "test:watch": "jest --watch --notify" 20 | }, 21 | "dependencies": { 22 | "deepmerge": "^2.2.1", 23 | "js-yaml": "^3.10.0" 24 | }, 25 | "devDependencies": { 26 | "eslint": "^4.11.0", 27 | "eslint-config-airbnb-base": "^12.1.0", 28 | "eslint-config-prettier": "^2.8.0", 29 | "eslint-plugin-import": "^2.8.0", 30 | "jest": "^21.2.1", 31 | "npm-run-all": "^4.1.5", 32 | "prettier": "^1.8.2", 33 | "prettier-check": "^2.0.0", 34 | "probot": "^9.2.15", 35 | "semantic-release": "^15.13.18" 36 | }, 37 | "peerDependencies": { 38 | "probot": ">= 0.11.0" 39 | }, 40 | "engines": { 41 | "node": ">=7.7.0", 42 | "npm": ">= 2.0.0" 43 | } 44 | } 45 | --------------------------------------------------------------------------------