diff --git a/docs/best-practices.md b/docs/best-practices.md index dc73b226..96bfeef1 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -57,6 +57,4 @@ Plugins _should_ allow all settings to customized for each installation. ### Store configuration in the repository -Any configuration _should_ be stored in the repository. Unless the plugin is using files from an established convention, the configuration _should_ be stored in the `.github` directory. - -For example, the [owners](https://github.com/probot/owners) plugin reads from the `OWNERS` file, which is a convention that existed before the plugin was created, while the [configurer](https://github.com/probot/configurer) plugin reads from `.github/config.yml`. +Any configuration _should_ be stored in the repository. Unless the plugin is using files from an established convention, the configuration _should_ be stored in the `.github` directory. See the [API docs for `context.config`](https://probot.github.io/probot/latest/Context.html#config). diff --git a/lib/context.js b/lib/context.js index 16a7f571..e6c64afb 100644 --- a/lib/context.js +++ b/lib/context.js @@ -1,3 +1,6 @@ +const path = require('path'); +const yaml = require('js-yaml'); + /** * Helpers for extracting information from the webhook event, which can be * passed to GitHub API calls. @@ -58,6 +61,28 @@ class Context { get isBot() { return this.payload.sender.type === 'Bot'; } + + /** + * Reads the plugin configuration from the given YAML file in the `.github` + * directory of the repository. + * + * @example + * + // Load config from .github/myplugin.yml in the repository + const config = await context.config('myplugin.yml'); + + if(config.close) { + context.github.issues.edit(context.issue({state: 'closed'})); + } + * + * @param {string} fileName Name of the YAML file in the `.github` directory + * @return {Promise} Configuration object read from the file + */ + async config(fileName) { + const params = this.repo({path: path.join('.github', fileName)}); + const res = await this.github.repos.getContent(params); + return yaml.safeLoad(Buffer.from(res.data.content, 'base64').toString()) || {}; + } } module.exports = Context; diff --git a/package.json b/package.json index b14867ec..6b273fbf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "github": "^9.2.0", "github-app": "^3.0.0", "github-webhook-handler": "^0.6.0", + "js-yaml": "^3.8.4", "load-plugins": "^2.1.2", "pkg-conf": "^2.0.0", "promise-events": "^0.1.3", diff --git a/test/context.js b/test/context.js index cc5f3b5d..f3172dae 100644 --- a/test/context.js +++ b/test/context.js @@ -1,3 +1,5 @@ +const fs = require('fs'); +const path = require('path'); const expect = require('expect'); const Context = require('../lib/context'); @@ -67,4 +69,96 @@ describe('Context', function () { }); }); }); + + describe('config', function () { + let github; + + function readConfig(fileName) { + const configPath = path.join(__dirname, 'fixtures', 'config', fileName); + const content = fs.readFileSync(configPath, {encoding: 'utf8'}); + return {data: {content: Buffer.from(content).toString('base64')}}; + } + + beforeEach(function () { + github = { + repos: { + getContent: expect.createSpy() + } + }; + + context = new Context(event, github); + }); + + it('gets a valid configuration', async function () { + github.repos.getContent.andReturn(Promise.resolve(readConfig('basic.yml'))); + const config = await context.config('test-file.yml'); + + expect(github.repos.getContent).toHaveBeenCalledWith({ + owner: 'bkeepers', + repo: 'probot', + path: '.github/test-file.yml' + }); + expect(config).toEqual({ + foo: 5, + bar: 7, + baz: 11 + }); + }); + + it('throws when the file is missing', async function () { + github.repos.getContent.andReturn(Promise.reject(new Error('An error occurred'))); + + let e; + let contents; + try { + contents = await context.config('test-file.yml'); + } catch (err) { + e = err; + } + + expect(contents).toNotExist(); + expect(e).toExist(); + expect(e.message).toEqual('An error occurred'); + }); + + it('throws when the configuration file is malformed', async function () { + github.repos.getContent.andReturn(Promise.resolve(readConfig('malformed.yml'))); + + let e; + let contents; + try { + contents = await context.config('test-file.yml'); + } catch (err) { + e = err; + } + + expect(contents).toNotExist(); + expect(e).toExist(); + expect(e.message).toMatch(/^end of the stream or a document separator/); + }); + + it('throws when loading unsafe yaml', async function () { + github.repos.getContent.andReturn(readConfig('evil.yml')); + + let e; + let config; + try { + config = await context.config('evil.yml'); + } catch (err) { + e = err; + } + + expect(config).toNotExist(); + expect(e).toExist(); + expect(e.message).toMatch(/unknown tag/); + }); + + it('returns an empty object when the file is empty', async function () { + github.repos.getContent.andReturn(readConfig('empty.yml')); + + const contents = await context.config('test-file.yml'); + + expect(contents).toEqual({}); + }); + }); }); diff --git a/test/fixtures/config/basic.yml b/test/fixtures/config/basic.yml new file mode 100644 index 00000000..35df5c33 --- /dev/null +++ b/test/fixtures/config/basic.yml @@ -0,0 +1,3 @@ +foo: 5 +bar: 7 +baz: 11 diff --git a/test/fixtures/config/empty.yml b/test/fixtures/config/empty.yml new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/config/evil.yml b/test/fixtures/config/evil.yml new file mode 100644 index 00000000..47cde33c --- /dev/null +++ b/test/fixtures/config/evil.yml @@ -0,0 +1 @@ +evil: ! 'function () {}' diff --git a/test/fixtures/config/malformed.yml b/test/fixtures/config/malformed.yml new file mode 100644 index 00000000..59c227c5 --- /dev/null +++ b/test/fixtures/config/malformed.yml @@ -0,0 +1 @@ +@