Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Created 'OR' rule #48

Merged
merged 8 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ jobs:
run: docker build . --file Dockerfile
validate-action:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v3.3.0
# This checks that .github/workflows/review-bot.yml is pointing towards the main branch
# as, during development, we change this to use the code from the test branch and
# we may forget to set it back to main
- name: Validate that action points to main branch
if: github.event_name == 'pull_request'
run: |
BRANCH=$(yq '.jobs.review-approvals.steps[1].uses' $FILE_NAME | cut -d "@" -f2)
# If the branch is not the main branch
Expand Down
15 changes: 9 additions & 6 deletions src/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ export enum RuleTypes {
Basic = "basic",
Debug = "debug",
And = "and",
Or = "or",
}

export type Reviewers = { users?: string[]; teams?: string[] };
export type Reviewers = { users?: string[]; teams?: string[]; min_approvals: number };

export interface Rule {
name: string;
Expand All @@ -19,21 +20,23 @@ export interface DebugRule extends Rule {

export interface BasicRule extends Rule, Reviewers {
type: RuleTypes.Basic;
min_approvals: number;
}

export interface AndRule extends Rule {
type: RuleTypes.And;
reviewers: ({
min_approvals: number;
} & Reviewers)[];
reviewers: Reviewers[];
}

export interface OrRule extends Rule {
type: RuleTypes.Or;
reviewers: Reviewers[];
}

export interface ConfigurationFile {
/** Based on the `type` parameter, Typescript converts the object to the correct type
* @see {@link Rules}
*/
rules: (BasicRule | DebugRule | AndRule)[];
rules: (BasicRule | DebugRule | AndRule | OrRule)[];
preventReviewRequests?: {
teams?: string[];
users?: string[];
Expand Down
9 changes: 5 additions & 4 deletions src/rules/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const ruleSchema = Joi.object<Rule & { type: string }>().keys({
include: Joi.array().items(Joi.string()).required(),
exclude: Joi.array().items(Joi.string()).optional().allow(null),
}),
type: Joi.string().valid(RuleTypes.Basic, RuleTypes.Debug, RuleTypes.And).required(),
type: Joi.string().valid(RuleTypes.Basic, RuleTypes.Debug, RuleTypes.And, RuleTypes.Or).required(),
});

/** General Configuration schema.
Expand All @@ -41,7 +41,8 @@ export const basicRuleSchema = Joi.object<BasicRule>()
.keys({ min_approvals: Joi.number().min(1).default(1), ...reviewersObj })
.or("users", "teams");

export const andRuleSchema = Joi.object<AndRule>().keys({
/** As, with the exception of basic, every other schema has the same structure, we can recycle this */
export const otherRulesSchema = Joi.object<AndRule>().keys({
reviewers: Joi.array<AndRule["reviewers"]>().items(basicRuleSchema).min(2).required(),
});

Expand All @@ -66,8 +67,8 @@ export const validateConfig = (config: ConfigurationFile): ConfigurationFile | n
validatedConfig.rules[i] = validate<BasicRule>(rule, basicRuleSchema, { message });
} else if (type === "debug") {
validatedConfig.rules[i] = validate<DebugRule>(rule, ruleSchema, { message });
} else if (type === "and") {
validatedConfig.rules[i] = validate<AndRule>(rule, andRuleSchema, { message });
} else if (type === "and" || type === "or") {
validatedConfig.rules[i] = validate<AndRule>(rule, otherRulesSchema, { message });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda feels like these rules can be joined so the validation would work in one execution

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am afraid I don't follow. What do you mean by joined? Could you please show me an example?

I want to keep the "else" to throw if the rule is invalid, but overall, I don't worry much about removing the hardcoded code as, maybe with one or two exception, I don't think we will be adding more cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, you validate config, and then validate each rule independently.
Can't we just join it all in a single schema to validate in one call?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, I see your point.

It is because the "Basic" rule has different parameters, so I thought it would be easier to do:

if (rule.type === "basic")
    basicCheck();
else if (rule.type !== "basic")
    notBasicCheck();

And that's is simply because I don't know how to make a conditional rule with JOI in which:

  1. If the type string has the value basic it should use a particular schema type
  2. if the type string does not have the value basic it needs to have a different schema validating it.

I don't know how to do a conditional check like that. If you do know, please let me know, as I have spend some time researching that and decided to go for the simplest solution instead of fighting for the most elegant one.

I'll be merging this PR as the union should be handled in a different PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean like Joi.alternatives()?

const BasicSchema = Joi.object({
   type: "basic",  
   data: Joi.object({a: Joi.string()})
  });

const NonBasicSchema = Joi.object({
    // I'm personally more inclined to have positive checks instead
    type: Joi.any().not("basic"), 
    data: Joi.object({b: Joi.string()})
  });

Joi.alternatives().try(BasicSchema, NonBasicSchema);

} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Rule ${name} has an invalid type: ${type}`);
Expand Down
47 changes: 39 additions & 8 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class ActionRunner {
*/
async validatePullRequest({ rules }: ConfigurationFile): Promise<RuleReport[]> {
const errorReports: RuleReport[] = [];
for (const rule of rules) {
ruleCheck: for (const rule of rules) {
try {
this.logger.info(`Validating rule '${rule.name}'`);
// We get all the files that were modified and match the rules condition
Expand Down Expand Up @@ -87,16 +87,37 @@ export class ActionRunner {
}
}
if (reports.length > 0) {
const finalReport: RuleReport = {
missingReviews: reports.reduce((a, b) => a + b.missingReviews, 0),
missingUsers: [...new Set(reports.flatMap((r) => r.missingUsers))],
teamsToRequest: [...new Set(reports.flatMap((r) => r.teamsToRequest ?? []))],
usersToRequest: [...new Set(reports.flatMap((r) => r.usersToRequest ?? []))],
name: rule.name,
};
const finalReport = unifyReport(reports, rule.name);
this.logger.error(`Missing the reviews from ${JSON.stringify(finalReport.missingUsers)}`);
errorReports.push(finalReport);
}
} else if (rule.type === "or") {
const reports: ReviewReport[] = [];
for (const reviewer of rule.reviewers) {
const [result, missingData] = await this.evaluateCondition(reviewer);
if (result) {
// This is a OR condition, so with ONE positive result
// we can continue the loop to check the following rule
continue ruleCheck;
}
// But, until we get a positive case we add all the failed cases
reports.push(missingData);
}

// If the loop was not skipped it means that we have errors
if (reports.length > 0) {
// We get the lowest amount of reviews needed to fulfill one of the reviews
const lowerAmountOfReviewsNeeded = reports
.map((r) => r.missingReviews)
.reduce((a, b) => (a < b ? a : b), 999);
// We unify the reports
const finalReport = unifyReport(reports, rule.name);
// We set the value to the minimum neccesary
finalReport.missingReviews = lowerAmountOfReviewsNeeded;
this.logger.error(`Missing the reviews from ${JSON.stringify(finalReport.missingUsers)}`);
// We unify the reports and push them for handling
errorReports.push(finalReport);
}
}
} catch (error: unknown) {
// We only throw if there was an unexpected error, not if the check fails
Expand Down Expand Up @@ -291,3 +312,13 @@ export class ActionRunner {
return checkRunData;
}
}

const unifyReport = (reports: ReviewReport[], name: string): RuleReport => {
return {
missingReviews: reports.reduce((a, b) => a + b.missingReviews, 0),
missingUsers: [...new Set(reports.flatMap((r) => r.missingUsers))],
teamsToRequest: [...new Set(reports.flatMap((r) => r.teamsToRequest ?? []))],
usersToRequest: [...new Set(reports.flatMap((r) => r.usersToRequest ?? []))],
name,
};
};
5 changes: 4 additions & 1 deletion src/test/rules/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mock, MockProxy } from "jest-mock-extended";

import { PullRequestApi } from "../../github/pullRequest";
import { TeamApi } from "../../github/teams";
import { RuleTypes } from "../../rules/types";
import { ActionRunner } from "../../runner";
import { TestLogger } from "../logger";

Expand Down Expand Up @@ -71,8 +72,10 @@ describe("Config Parsing", () => {
teams:
- team-example
`);
// prettifies string to [basic, debug, and, or...]
const types = JSON.stringify(Object.values(RuleTypes)).replace(/"/g, "").replace(/,/g, ", ");
await expect(runner.getConfigFile("")).rejects.toThrowError(
'Configuration file is invalid: "rules[0].type" must be one of [basic, debug, and]',
'Configuration file is invalid: "rules[0].type" must be one of ' + types,
);
});

Expand Down
200 changes: 200 additions & 0 deletions src/test/rules/or.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { mock, MockProxy } from "jest-mock-extended";

import { PullRequestApi } from "../../github/pullRequest";
import { TeamApi } from "../../github/teams";
import { OrRule } from "../../rules/types";
import { ActionRunner } from "../../runner";
import { TestLogger } from "../logger";

describe("'Or' rule parsing", () => {
let api: MockProxy<PullRequestApi>;
let runner: ActionRunner;
let teamsApi: MockProxy<TeamApi>;
let logger: TestLogger;
beforeEach(() => {
logger = new TestLogger();
api = mock<PullRequestApi>();
runner = new ActionRunner(api, teamsApi, logger);
});
test("should get minimal config", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- teams:
- team-example
- teams:
- team-abc
`);
const config = await runner.getConfigFile("");
expect(config.rules[0].name).toEqual("Test review");
expect(config.rules[0].type).toEqual("or");
});

describe("reviewers", () => {
test("should require teams", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- teams:
- team-example
- teams:
- team-abc
`);
const config = await runner.getConfigFile("");
const rule = config.rules[0] as OrRule;
expect(rule.reviewers).toHaveLength(2);
expect(rule.reviewers[0].teams).toContainEqual("team-example");
expect(rule.reviewers[0].users).toBeUndefined();
expect(rule.reviewers[1].teams).toContainEqual("team-abc");
expect(rule.reviewers[1].users).toBeUndefined();
});
test("should require users", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- users:
- user-example
- users:
- user-special
`);
const config = await runner.getConfigFile("");
const rule = config.rules[0] as OrRule;
expect(rule.reviewers[0].users).toContainEqual("user-example");
expect(rule.reviewers[0].teams).toBeUndefined();
expect(rule.reviewers[1].users).toContainEqual("user-special");
expect(rule.reviewers[1].teams).toBeUndefined();
});

test("should fail without reviewers", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
`);
await expect(runner.getConfigFile("")).rejects.toThrowError('"reviewers" is required');
});

test("should fill the reviewers array", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- teams:
- team-example
- min_approvals: 2
users:
- abc
teams:
- xyz
`);
const config = await runner.getConfigFile("");
const rule = config.rules[0] as OrRule;
expect(rule.reviewers).toHaveLength(2);
expect(rule.reviewers[0].teams).toContainEqual("team-example");
expect(rule.reviewers[0].users).toBeUndefined();
expect(rule.reviewers[1].min_approvals).toEqual(2);
expect(rule.reviewers[1].users).toContainEqual("abc");
expect(rule.reviewers[1].teams).toContainEqual("xyz");
});
});

test("should default min_approvals to 1", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- users:
- user-example
- teams:
- team-example
`);
const config = await runner.getConfigFile("");
const [rule] = config.rules;
if (rule.type === "or") {
expect(rule.reviewers[0].min_approvals).toEqual(1);
} else {
throw new Error(`Rule type ${rule.type} is invalid`);
}
});

test("should fail with min_approvals in negative", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- min_approvals: -99
users:
- user-example
`);
await expect(runner.getConfigFile("")).rejects.toThrowError(
'"reviewers[0].min_approvals" must be greater than or equal to 1',
);
});

test("should fail with min_approvals in 0", async () => {
api.getConfigFile.mockResolvedValue(`
rules:
- name: Test review
condition:
include:
- '.*'
exclude:
- 'example'
type: or
reviewers:
- min_approvals: 0
users:
- user-example
`);
await expect(runner.getConfigFile("")).rejects.toThrowError(
'"reviewers[0].min_approvals" must be greater than or equal to 1',
);
});
});
Loading