diff --git a/docs/README.md b/docs/README.md index c1f5b96..345cf09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,5 @@ # Configuration -The bot can be configured with a file located at `.github/in-solidarity.yml` in the target repo or at the same path in a repo named `.github` in the organization. +The bot can be configured with a file located at `.github/in-solidarity.yml` in the target repo or at the same path in a repo named `.github` within the organization. The JSON schema is located at [docs/schema.json](https://github.com/jpoehnelt/in-solidarity-bot/blob/main/docs/schema.json). ```yaml rules: @@ -7,26 +7,48 @@ rules: level: off slave: level: failure + foo: + regex: + - /foo/gi + - /foobar/gi + level: failure ignore: - ".github/in-solidarity.yml" # default - "**/*.yml" ``` The possible levels are `['off', 'notice', 'warning', 'failure']`. These correspond to [annotation_level in the GitHub API](https://docs.github.com/en/rest/reference/checks#create-a-check-run). +The default configuration can be ignored with `ignoreDefaults: true` such as in the following. + +```yaml +rules: + foo: + regex: + - /foo/gi + - /foobar/gi + level: failure +ignoreDefaults: true +``` +This will only check the single rule. + +> **Note**: The merging of defaults uses array replacement. This means any array provided by the configuration will be used and default elements ignored. + > **Note**: The bot uses the configuration from the default branch. Therefore any changes to the configuration in a pull request will not be used until merged. +Read more about configuration for organizations at [Probot best practices](https://github.com/probot/probot/blob/master/docs/best-practices.md#store-configuration-in-the-repository). + # Rules The following are the current rules. Additional rules are welcome! | rule | default level | |---|---| -|[master](rules/master) | `warning` | -|[slave](rules/slave) | `warning` | -|[whitelist](rules/whitelist) | `warning` | -|[blacklist](rules/blacklist) | `warning` | -|[grandfathered](rules/grandfathered) | `warning` | -|[sanity_check](rules/sanity_check) | `warning` | -|[man_hours](rules/man_hours) | `warning` | +|[master](rules/master.md) | `warning` | +|[slave](rules/slave.md) | `warning` | +|[whitelist](rules/whitelist.md) | `warning` | +|[blacklist](rules/blacklist.md) | `warning` | +|[grandfathered](rules/grandfathered.md) | `warning` | +|[sanity_check](rules/sanity_check.md) | `warning` | +|[man_hours](rules/man_hours.md) | `warning` | _This document is generated from a template using [rules.ts](https://github.com/jpoehnelt/in-solidarity-bot/blob/main/src/rules.ts) and [docs/index.ts](https://github.com/jpoehnelt/in-solidarity-bot/blob/main/docs/index.ts)._ diff --git a/docs/schema.json b/docs/schema.json new file mode 100644 index 0000000..7d102af --- /dev/null +++ b/docs/schema.json @@ -0,0 +1,210 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "definitions": { + "level": { + "type": "string", + "enum": [ + "off", + "notice", + "warning", + "failure" + ] + }, + "regex": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^/.+/[giu]*$" + } + }, + "alternatives": { + "type": "array", + "items": { + "type": "string", + "minLength": 2 + } + } + }, + "properties": { + "ignore": { + "type": "array", + "minitems": 1, + "items": { + "type": "string" + } + }, + "ignoreDefaults": { + "type": "boolean" + }, + "rules": { + "type": "object", + "properties": { + "master": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "slave": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "whitelist": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "blacklist": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "grandfathered": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "sanity_check": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + }, + "man_hours": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + } + } + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + }, + "required": [ + "level", + "regex" + ] + } + } + }, + "if": { + "required": [ + "ignoreDefaults" + ], + "properties": { + "ignoreDefaults": true + } + }, + "then": { + "required": [ + "rules" + ], + "properties": { + "rules": { + "type": "object", + "properties": {}, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/level" + }, + "alternatives": { + "$ref": "#/definitions/alternatives" + }, + "regex": { + "$ref": "#/definitions/regex" + } + }, + "required": [ + "level", + "regex" + ] + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/in-solidarity.yml b/fixtures/in-solidarity.yml index e45d744..612b642 100644 --- a/fixtures/in-solidarity.yml +++ b/fixtures/in-solidarity.yml @@ -15,6 +15,9 @@ rules: master: level: off + foo: + level: failure + regex: ["/foo/gi"] ignore: - ".github/in-solidarity.yml" - "**/*.yml" diff --git a/package-lock.json b/package-lock.json index 39c5144..f9c6739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7852,6 +7852,11 @@ "safe-regex": "^1.1.0" } }, + "regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==" + }, "regexpp": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", diff --git a/package.json b/package.json index 5acbe3e..a6d4554 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ "license": "Apache 2.0", "author": "Justin Poehnelt ", "scripts": { - "build": "tsc -p tsconfig.json && cp -r src/templates dist/src/templates", + "build": "rm -rf dist && tsc -p tsconfig.json && cp -avr src/templates/ dist/src/templates/", "format": "eslint src/*.ts --fix", "lint": "eslint src/*.ts", "start": "probot run ./dist/src/index.js", "test": "jest", "test:update": "jest --updateSnapshot", - "docs": "node ./dist/src/docs/index.js" + "docs": "rm -rf docs && node ./dist/src/docs/index.js" }, "jest": { "testEnvironment": "node" @@ -38,7 +38,8 @@ "handlebars": "^4.7.6", "js-yaml": "^3.14.0", "minimatch": "^3.0.4", - "probot": "^10.8.0" + "probot": "^10.8.0", + "regex-parser": "^2.2.11" }, "devDependencies": { "@rollup/plugin-node-resolve": "^9.0.0", diff --git a/src/config.test.ts b/src/config.test.ts index 8cd7954..27923b3 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -29,25 +29,197 @@ test("should override default rules", async () => { const config = await getConfig(fakeContext); expect(config.rules.master).toEqual({ level: "off", - regex: [/master/gi], + regex: DEFAULT_CONFIGURATION.rules.master.regex, alternatives: DEFAULT_CONFIGURATION.rules.master.alternatives, }); expect(config.ignore).toEqual([".github/in-solidarity.yml", "**/*.yml"]); }); -test("should throw for invalid config", async () => { +test("should allow changing default regex", async () => { const context = ({ config: async () => { return { rules: { master: { - regex: "MaStEr", + regex: ["/MaStEr/g"], }, }, }; }, } as unknown) as Context; - await expect(getConfig(context)).rejects.toBeInstanceOf(InvalidConfigError); + const config = await getConfig(context); + expect(config.rules.master.regex[0]).toEqual(/MaStEr/g); +}); + +test("should override default alternatives", async () => { + const context = ({ + config: async () => { + return { + rules: { + master: { + alternatives: ["PRIMARY"], + }, + }, + }; + }, + } as unknown) as Context; + const config = await getConfig(context); + expect(config.rules.master.alternatives).toEqual(["PRIMARY"]); +}); + +test("should throw for invalid regex pattern", async () => { + const context = ({ + config: async () => { + return { + rules: { + foo: { + regex: ["foo"], + }, + }, + }; + }, + } as unknown) as Context; + await expect(getConfig(context)).rejects.toMatchInlineSnapshot(` + [Error: configuration is invalid: [ + { + "keyword": "required", + "dataPath": ".rules['foo']", + "schemaPath": "#/properties/rules/additionalProperties/required", + "params": { + "missingProperty": "level" + }, + "message": "should have required property 'level'" + }, + { + "keyword": "pattern", + "dataPath": ".rules['foo'].regex[0]", + "schemaPath": "#/definitions/regex/items/pattern", + "params": { + "pattern": "^/.+/[giu]*$" + }, + "message": "should match pattern \\"^/.+/[giu]*$\\"" + } + ]] + `); +}); + +test("should throw for empty regex array", async () => { + const context = ({ + config: async () => { + return { + rules: { + foo: { + level: "off", + regex: [], + }, + }, + }; + }, + } as unknown) as Context; + await expect(getConfig(context)).rejects.toMatchInlineSnapshot(` + [Error: configuration is invalid: [ + { + "keyword": "minItems", + "dataPath": ".rules['foo'].regex", + "schemaPath": "#/definitions/regex/minItems", + "params": { + "limit": 1 + }, + "message": "should NOT have fewer than 1 items" + } + ]] + `); +}); + +test("should ignore defaults", async () => { + const context = ({ + config: async () => { + return { + rules: { + foo: { + level: "failure", + regex: ["/foo/gi"], + }, + }, + ignoreDefaults: true, + }; + }, + } as unknown) as Context; + const config = await getConfig(context); + expect(config).toMatchInlineSnapshot(` + Object { + "ignore": Array [], + "ignoreDefaults": true, + "rules": Object { + "foo": Object { + "level": "failure", + "regex": Array [ + /foo/gi, + ], + }, + }, + } + `); +}); + +test("should throw if ignoring defaults without rules", async () => { + const context = ({ + config: async () => { + return { + ignoreDefaults: true, + }; + }, + } as unknown) as Context; + await expect(getConfig(context)).rejects.toMatchInlineSnapshot(` + [Error: configuration is invalid: [ + { + "keyword": "required", + "dataPath": "", + "schemaPath": "#/then/required", + "params": { + "missingProperty": "rules" + }, + "message": "should have required property 'rules'" + }, + { + "keyword": "if", + "dataPath": "", + "schemaPath": "#/if", + "params": { + "failingKeyword": "then" + }, + "message": "should match \\"then\\" schema" + } + ]] + `); +}); + +test("should throw for invalid flags", async () => { + const context = ({ + config: async () => { + return { + rules: { + foo: { + level: "failure", + regex: ["/master/m"], + }, + }, + }; + }, + } as unknown) as Context; + await expect(getConfig(context)).rejects.toMatchInlineSnapshot(` + [Error: configuration is invalid: [ + { + "keyword": "pattern", + "dataPath": ".rules['foo'].regex[0]", + "schemaPath": "#/definitions/regex/items/pattern", + "params": { + "pattern": "^/.+/[giu]*$" + }, + "message": "should match pattern \\"^/.+/[giu]*$\\"" + } + ]] + `); }); test("should throw for invalid config having level at top", async () => { diff --git a/src/config.ts b/src/config.ts index 45f31f5..1356595 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,17 +14,31 @@ * limitations under the License. */ -import { DEFAULT_RULES, Rule } from "./rules"; +import { DEFAULT_RULES, Level, Rule } from "./rules"; +import { ajv, schema } from "./schema"; -import Ajv from "ajv"; import { Context } from "probot"; -import { Level } from "./rules"; import deepmerge from "deepmerge"; +import regexParser from "regex-parser"; export interface Configuration { rules: { [key: string]: Rule }; ignore: string[]; + ignoreDefaults?: boolean; } + +export interface RepoRule { + regex?: (string | RegExp)[]; + level?: Level; + alternatives?: string[]; +} + +export interface RepoConfiguration { + rules?: { [key: string]: RepoRule }; + ignore?: string[]; + ignoreDefaults?: boolean; +} + export class InvalidConfigError extends Error {} export const DEFAULT_CONFIGURATION: Configuration = { @@ -34,49 +48,42 @@ export const DEFAULT_CONFIGURATION: Configuration = { const CONFIG_FILE = "in-solidarity.yml"; -const ajv = new Ajv({ allErrors: true }); - -const rulesPropertiesSchema = Object.keys(DEFAULT_RULES).reduce((obj, k) => { - obj[k] = { - type: "object", - additionalProperties: false, - properties: { - level: { type: "string", enum: Object.values(Level) }, - }, - }; - return obj; -}, {}); - -const schema = { - type: "object", - additionalProperties: false, - properties: { - rules: { - type: "object", - additionalProperties: false, - properties: rulesPropertiesSchema, - }, - ignore: { - type: "array", - items: { type: "string" }, - }, - }, -}; - export const getConfig = async (context: Context): Promise => { const validate = ajv.compile(schema); - const repoConfig = await context.config(CONFIG_FILE); + const repoConfig = (await context.config(CONFIG_FILE)) as RepoConfiguration; + if (repoConfig) { if (!validate(repoConfig)) { throw new InvalidConfigError( - "configuration is invalid: " + JSON.stringify(validate.errors) + "configuration is invalid: " + JSON.stringify(validate.errors, null, 2) ); } + // parse all strings into regexp + for (const k in repoConfig.rules) { + if (repoConfig.rules[k].regex) { + repoConfig.rules[k].regex = repoConfig.rules[k].regex!.map( + (pattern) => { + try { + return regexParser(pattern as string); + } catch (e) { + throw new InvalidConfigError( + `configuration is invalid: unable to parse ${pattern} as regex` + ); + } + } + ); + } + } + if (repoConfig.ignoreDefaults) { + return { rules: {} as any, ignore: [], ...repoConfig }; + } return deepmerge(DEFAULT_CONFIGURATION, repoConfig as Configuration, { + // overwrite from repo config arrayMerge: (_, b) => b, }); } + return DEFAULT_CONFIGURATION; }; diff --git a/src/docs/index.ts b/src/docs/index.ts index 9a2595e..052e762 100644 --- a/src/docs/index.ts +++ b/src/docs/index.ts @@ -20,6 +20,7 @@ import { DEFAULT_RULES, Level } from "../rules"; import fs from "fs"; import handlebars from "handlebars"; import path from "path"; +import { schema } from "../schema"; /// Write individual rule pages const RULE_TEMPLATE = handlebars.compile( @@ -57,3 +58,10 @@ fs.writeFileSync( }), "utf8" ); + +/// Write schema +fs.writeFileSync( + path.join(__dirname, `../../../docs/schema.json`), + JSON.stringify(schema, null, 2), + "utf8" +); diff --git a/src/schema.test.ts b/src/schema.test.ts new file mode 100644 index 0000000..f122284 --- /dev/null +++ b/src/schema.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ajv, schema } from "./schema"; + +const validate = ajv.compile(schema); + +test("should validate", () => { + validate({ + rules: { + master: { level: "off" }, + foo: { level: "failure", regex: ["/foo/gi"] }, + }, + }); + expect(validate.errors).toBeFalsy(); +}); + +// mosts tests are in config.test.ts diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..2031d5b --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DEFAULT_RULES, Level } from "./rules"; + +import Ajv from "ajv"; + +const regex = { + type: "array", + minItems: 1, + items: { + type: "string", + pattern: "^/.+/[giu]*$", + }, +}; + +const level = { type: "string", enum: Object.values(Level) }; +const alternatives = { + type: "array", + items: { + type: "string", + minLength: 2, + }, +}; + +const defaultRule = { + type: "object", + additionalProperties: false, + properties: { + level: { $ref: "#/definitions/level" }, + alternatives: { $ref: "#/definitions/alternatives" }, + regex: { $ref: "#/definitions/regex" }, + }, +}; + +const defaultRules = Object.keys(DEFAULT_RULES).reduce((obj, k) => { + obj[k] = defaultRule; + return obj; +}, {}); + +const customRules = { + ...defaultRule, + required: ["level", "regex"], +}; + +export const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + additionalProperties: false, + definitions: { level, regex, alternatives }, + properties: { + ignore: { + type: "array", + minitems: 1, + items: { type: "string" }, + }, + ignoreDefaults: { + type: "boolean", + }, + rules: { + type: "object", + properties: defaultRules, + additionalProperties: customRules, + }, + }, + if: { + required: ["ignoreDefaults"], + properties: { ignoreDefaults: true }, + }, + then: { + required: ["rules"], + properties: { + rules: { + type: "object", + properties: {}, // all rules must now match customRules + additionalProperties: customRules, + }, + }, + }, +}; + +export const ajv = new Ajv({ allErrors: true }); diff --git a/src/templates/README.hbs b/src/templates/README.hbs index 83589ca..2048881 100644 --- a/src/templates/README.hbs +++ b/src/templates/README.hbs @@ -1,5 +1,5 @@ # Configuration -The bot can be configured with a file located at `.github/in-solidarity.yml` in the target repo or at the same path in a repo named `.github` in the organization. +The bot can be configured with a file located at `.github/in-solidarity.yml` in the target repo or at the same path in a repo named `.github` within the organization. The JSON schema is located at [docs/schema.json](https://github.com/jpoehnelt/in-solidarity-bot/blob/main/docs/schema.json). ```yaml rules: @@ -7,14 +7,36 @@ rules: level: off slave: level: failure + foo: + regex: + - /foo/gi + - /foobar/gi + level: failure ignore: - ".github/in-solidarity.yml" # default - "**/*.yml" ``` The possible levels are `['off', 'notice', 'warning', 'failure']`. These correspond to [annotation_level in the GitHub API](https://docs.github.com/en/rest/reference/checks#create-a-check-run). +The default configuration can be ignored with `ignoreDefaults: true` such as in the following. + +```yaml +rules: + foo: + regex: + - /foo/gi + - /foobar/gi + level: failure +ignoreDefaults: true +``` +This will only check the single rule. + +> **Note**: The merging of defaults uses array replacement. This means any array provided by the configuration will be used and default elements ignored. + > **Note**: The bot uses the configuration from the default branch. Therefore any changes to the configuration in a pull request will not be used until merged. +Read more about configuration for organizations at [Probot best practices](https://github.com/probot/probot/blob/master/docs/best-practices.md#store-configuration-in-the-repository). + # Rules The following are the current rules. Additional rules are welcome!