Skip to content

Commit 2800183

Browse files
authored
Added required score grouping to fellows reviews (#113)
Added new optional field to the config named `FellowScores` that has the score of each fellow per dan. It was done as an object (and not a collection) because it will never change in the foreseeable future (added up to dan IX). When the rule of type `fellows` has the optional field of `minTotalScore` it will check that the score of the fellows who approved the PR sums up to that number. If it doesn't sum to that number it will fail with a custom error listing the score of each fellow for reference (and saying how many points are missing). Added `FellowMissingScoreFailure` class which handles the formatting of errors when the score of fellows is not high enough. This resolves #110 Downgraded JOI as there was a version mismatch that made the test fails. This also updated the version to `2.4.0`
1 parent 5371807 commit 2800183

24 files changed

+377
-71
lines changed

README.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -411,21 +411,37 @@ Meaning that if a user belongs to both `team-abc` and `team-example` their appro
411411

412412
This rule is useful when you need to make sure that at leasts two sets of eyes of two different teams review a Pull Request.
413413
#### Fellows rule
414-
The fellows rule has a slight difference to all of the rules:
414+
The fellows rule requires an extra collection to be added to the configuration and distinguishes itself from the other rules with some new fields:
415415
```yaml
416-
- name: Fellows review
417-
condition:
418-
include:
419-
- '.*'
420-
exclude:
421-
- 'example'
422-
type: fellows
423-
minRank: 2
424-
minApprovals: 2
416+
rules:
417+
- name: Fellows review
418+
condition:
419+
include:
420+
- '.*'
421+
exclude:
422+
- 'example'
423+
type: fellows
424+
minRank: 2
425+
minApprovals: 2
426+
minTotalScore: 4
427+
scores:
428+
dan1: 1
429+
dan2: 2
430+
dan3: 4
431+
dan4: 8
432+
dan5: 12
433+
dan6: 15
434+
dan7: 20
435+
dan8: 25
436+
dan9: 30
425437
```
426-
The biggest difference is that it doesn’t have a reviewers type (it doesn’t have a `teams` or `users` field); instead, it has a `minRank` field.
438+
The biggest difference in the rule configuration is that it doesn’t have a reviewers type (it doesn’t have a `teams` or `users` field); instead, it has a `minRank` field and `minTotalScore` field.
439+
440+
The `minRank` field receives a number, which will be the lowest rank required to evaluate the PR, and then it fetches [all the fellows from the chain data](https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fkusama.api.onfinality.io%2Fpublic-ws#/fellowship), filters only the one to belong to that rank or above and then [looks into their metadata for a field name `github` and the handle there](https://github.com/polkadot-fellows/runtimes/issues/7).
441+
442+
The `minTotalScore` field relies on the `scores` collection. In this collection, each rank (dan) receives a score, so, in the example, a fellow dan 3 will have a score of 4. If the field `minTotalScore` is enabled, the conditions to approve a pull request, aside from requiring a given amount of reviews from a given rank, will be that _the total sum of all the scores from the fellows that have approved the pull request are equal or greater to the `minTotalScore` field_. This field is optional.
427443

428-
This field receives a number, which will be the lowest rank required to evaluate the PR, and then it fetches [all the fellows from the chain data](https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fkusama.api.onfinality.io%2Fpublic-ws#/fellowship), filters only the one to belong to that rank or above and then [looks into their metadata for a field name `github` and the handle there](https://github.com/polkadot-fellows/runtimes/issues/7).
444+
For *every dan that has not been attributed a score, their score will default to 0*.
429445

430446
After this is done, the resulting handles will be treated like a normal list of required users.
431447

@@ -439,6 +455,8 @@ It also has any other field from the [`basic rule`](#basic-rule) (with the excep
439455
- **Optional**: Defaults to `false`.
440456
- **minRank**: Must be a number.
441457
- **Required**
458+
- **minTotalScore**: Must be a number
459+
- **Optional**: Defaults to 0.
442460

443461
##### Note
444462
The fellows rule will never request reviewers, even if `request-reviewers` rule is enabled.

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ outputs:
3232

3333
runs:
3434
using: 'docker'
35-
image: 'Dockerfile'
35+
image: 'docker://ghcr.io/paritytech/review-bot/action:2.4.0'

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "review-bot",
3-
"version": "2.3.1",
3+
"version": "2.4.0",
44
"description": "Have custom review rules for PRs with auto assignment",
55
"main": "src/index.ts",
66
"scripts": {
@@ -39,7 +39,7 @@
3939
"@actions/github": "^6.0.0",
4040
"@eng-automation/js": "^1.0.2",
4141
"@polkadot/api": "^10.11.2",
42-
"joi": "^17.12.0",
42+
"joi": "^17.6.4",
4343
"yaml": "^2.3.4"
4444
}
4545
}

src/failures/fellowsRules.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,64 @@ export class FellowMissingRankFailure extends ReviewFailure {
4242
return { users: [], teams: [] };
4343
}
4444
}
45+
46+
export class FellowMissingScoreFailure extends ReviewFailure {
47+
public readonly currentScore: number;
48+
constructor(
49+
ruleInfo: Omit<RuleFailedSummary, "missingUsers" | "countingReviews" | "missingReviews">,
50+
public readonly requiredScore: number,
51+
approvalsWithScores: [string, number][],
52+
missingFellowsWithScore: [string, number][],
53+
) {
54+
const unifyFellowWithScore = ([handle, score]: [string, number]) => `${handle} -> <b>${score}</b>`;
55+
super({
56+
...ruleInfo,
57+
countingReviews: approvalsWithScores.map(unifyFellowWithScore),
58+
missingUsers: missingFellowsWithScore.map(unifyFellowWithScore),
59+
missingReviews: 1,
60+
});
61+
62+
this.currentScore = approvalsWithScores.reduce((n, [_, score]) => n + score, 0);
63+
}
64+
65+
generateSummary(): typeof summary {
66+
let text = summary
67+
.emptyBuffer()
68+
.addHeading(this.name, 2)
69+
.addHeading("Missing minimum required score from Fellows", 4)
70+
.addDetails(
71+
"Rule explanation",
72+
"Rule 'Fellows' gives every fellow a score based on their rank, and required that the sum of all the scores is greater than the required score." +
73+
"\n\n" +
74+
"For more info found out how the rules work in [Review-bot types](https://github.com/paritytech/review-bot#types)",
75+
);
76+
77+
text = text
78+
.addHeading(`Missing a score of ${this.requiredScore}`, 3)
79+
.addEOL()
80+
.addRaw(`Missing ${this.requiredScore - this.currentScore} in the required score.`)
81+
.addEOL()
82+
.addRaw(`Current score is ${this.currentScore}/${this.requiredScore}`);
83+
if (this.missingUsers.length > 0)
84+
text = text.addDetails(
85+
"GitHub users whose approval counts",
86+
`This is a list of all the Fellows that have not reviewed with their current scores:\n\n - ${this.missingUsers
87+
.map(toHandle)
88+
.join("\n - ")}`,
89+
);
90+
91+
if (this.countingReviews.length > 0) {
92+
text = text
93+
.addHeading("Users approvals that counted towards this rule with their scores", 3)
94+
.addEOL()
95+
.addList(this.countingReviews.map(toHandle))
96+
.addEOL();
97+
}
98+
99+
return text;
100+
}
101+
102+
getRequestLogins(): { users: string[]; teams: string[] } {
103+
return { users: [], teams: [] };
104+
}
105+
}

src/polkadot/fellows.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class PolkadotFellows implements TeamApi {
1010

1111
constructor(private readonly logger: ActionLogger) {}
1212

13-
async fetchAllFellows(): Promise<Map<string, number>> {
13+
private async fetchAllFellows(): Promise<Map<string, number>> {
1414
let api: ApiPromise;
1515
this.logger.debug("Connecting to collective parachain");
1616
// we connect to the collective rpc node
@@ -91,6 +91,18 @@ export class PolkadotFellows implements TeamApi {
9191
}
9292
}
9393

94+
/** Returns all the fellows with their rankings */
95+
async listFellows(): Promise<[string, number][]> {
96+
this.logger.info("Fetching all fellows with their ranks");
97+
98+
if (this.fellowsCache.size < 1) {
99+
this.logger.debug("Cache not found. Fetching fellows.");
100+
this.fellowsCache = await this.fetchAllFellows();
101+
}
102+
103+
return Array.from(this.fellowsCache.entries());
104+
}
105+
94106
async getTeamMembers(ranking: string): Promise<string[]> {
95107
const requiredRank = Number(ranking);
96108
this.logger.info(`Fetching members of rank '${requiredRank}' or higher`);

src/rules/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ export interface FellowsRule extends Rule {
3838
type: RuleTypes.Fellows;
3939
minRank: number;
4040
minApprovals: number;
41+
minTotalScore?: number;
42+
}
43+
44+
export interface FellowsScore {
45+
dan1: number;
46+
dan2: number;
47+
dan3: number;
48+
dan4: number;
49+
dan5: number;
50+
dan6: number;
51+
dan7: number;
52+
dan8: number;
53+
dan9: number;
4154
}
4255

4356
export interface ConfigurationFile {
@@ -49,4 +62,5 @@ export interface ConfigurationFile {
4962
teams?: string[];
5063
users?: string[];
5164
};
65+
score?: FellowsScore;
5266
}

src/rules/validator.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { validate } from "@eng-automation/js";
22
import Joi from "joi";
33

44
import { ActionLogger } from "../github/types";
5-
import { AndRule, BasicRule, ConfigurationFile, FellowsRule, Reviewers, Rule, RuleTypes } from "./types";
5+
import { AndRule, BasicRule, ConfigurationFile, FellowsRule, FellowsScore, Reviewers, Rule, RuleTypes } from "./types";
66

77
/** For the users or team schema. Will be recycled A LOT
88
* Remember to add `.or("users", "teams")` to force at least one of the two to be defined
@@ -30,13 +30,27 @@ const ruleSchema = Joi.object<Rule & { type: string }>().keys({
3030
.required(),
3131
});
3232

33+
/** Schema for ensuring that all the dan ranks are set properly */
34+
export const fellowScoreSchema = Joi.object<FellowsScore>().keys({
35+
dan1: Joi.number().default(0),
36+
dan2: Joi.number().default(0),
37+
dan3: Joi.number().default(0),
38+
dan4: Joi.number().default(0),
39+
dan5: Joi.number().default(0),
40+
dan6: Joi.number().default(0),
41+
dan7: Joi.number().default(0),
42+
dan8: Joi.number().default(0),
43+
dan9: Joi.number().default(0),
44+
});
45+
3346
/** General Configuration schema.
3447
* Evaluates all the upper level field plus the generic rules fields.
3548
* Remember to evaluate the rules with their custom rules
3649
*/
3750
export const generalSchema = Joi.object<ConfigurationFile>().keys({
3851
rules: Joi.array<ConfigurationFile["rules"]>().items(ruleSchema).unique("name").required(),
3952
preventReviewRequests: Joi.object().keys(reviewersObj).optional().or("users", "teams"),
53+
score: fellowScoreSchema,
4054
});
4155

4256
/** Basic rule schema
@@ -59,6 +73,7 @@ export const fellowsRuleSchema = Joi.object<FellowsRule>().keys({
5973
countAuthor: Joi.boolean().default(false),
6074
minRank: Joi.number().required().min(1).empty(null),
6175
minApprovals: Joi.number().min(1).default(1),
76+
minTotalScore: Joi.number().min(1).optional(),
6277
});
6378

6479
/**

src/runner.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Inputs } from ".";
55
import {
66
CommonRuleFailure,
77
FellowMissingRankFailure,
8+
FellowMissingScoreFailure,
89
RequiredReviewersData,
910
ReviewFailure,
1011
RuleFailedReport,
@@ -13,9 +14,18 @@ import {
1314
import { GitHubChecksApi } from "./github/check";
1415
import { PullRequestApi } from "./github/pullRequest";
1516
import { ActionLogger, CheckData, TeamApi } from "./github/types";
16-
import { AndDistinctRule, ConfigurationFile, FellowsRule, Reviewers, Rule, RuleTypes } from "./rules/types";
17+
import { PolkadotFellows } from "./polkadot/fellows";
18+
import {
19+
AndDistinctRule,
20+
ConfigurationFile,
21+
FellowsRule,
22+
FellowsScore,
23+
Reviewers,
24+
Rule,
25+
RuleTypes,
26+
} from "./rules/types";
1727
import { validateConfig, validateRegularExpressions } from "./rules/validator";
18-
import { concatArraysUniquely } from "./util";
28+
import { concatArraysUniquely, rankToScore } from "./util";
1929

2030
type BaseRuleReport = RuleFailedReport & RequiredReviewersData;
2131

@@ -31,7 +41,7 @@ export class ActionRunner {
3141
constructor(
3242
private readonly prApi: PullRequestApi,
3343
private readonly teamApi: TeamApi,
34-
private readonly polkadotApi: TeamApi,
44+
private readonly polkadotApi: PolkadotFellows,
3545
private readonly checks: GitHubChecksApi,
3646
private readonly logger: ActionLogger,
3747
) {}
@@ -61,7 +71,7 @@ export class ActionRunner {
6171
* The action evaluates if the rules requirements are meet for a PR
6272
* @returns an array of error reports for each failed rule. An empty array means no errors
6373
*/
64-
async validatePullRequest({ rules }: ConfigurationFile): Promise<PullRequestReport> {
74+
async validatePullRequest({ rules, score }: ConfigurationFile): Promise<PullRequestReport> {
6575
const modifiedFiles = await this.prApi.listModifiedFiles();
6676

6777
const errorReports: ReviewFailure[] = [];
@@ -150,7 +160,7 @@ export class ActionRunner {
150160
break;
151161
}
152162
case RuleTypes.Fellows: {
153-
const fellowReviewError = await this.fellowsEvaluation(rule);
163+
const fellowReviewError = await this.fellowsEvaluation(rule, score);
154164
if (fellowReviewError) {
155165
this.logger.error(`Missing the reviews from ${JSON.stringify(fellowReviewError.missingReviews)}`);
156166
// errorReports.push({ ...missingData, name: rule.name, type: rule.type });
@@ -440,7 +450,7 @@ export class ActionRunner {
440450
}
441451
}
442452

443-
async fellowsEvaluation(rule: FellowsRule): Promise<ReviewFailure | null> {
453+
async fellowsEvaluation(rule: FellowsRule, scores?: FellowsScore): Promise<ReviewFailure | null> {
444454
// This is a list of all the users that need to approve a PR
445455
const requiredUsers: string[] = await this.polkadotApi.getTeamMembers(rule.minRank.toString());
446456

@@ -472,9 +482,10 @@ export class ActionRunner {
472482
}
473483
}
474484

485+
const author = this.prApi.getAuthor();
486+
475487
// Now we verify if we have any remaining missing review.
476488
if (missingReviews > 0) {
477-
const author = this.prApi.getAuthor();
478489
this.logger.warn(`${missingReviews} reviews are missing.`);
479490
// If we have at least one missing review, we return an object with the list of missing reviewers, and
480491
// which users/teams we should request to review
@@ -487,11 +498,54 @@ export class ActionRunner {
487498
},
488499
rule.minRank,
489500
);
490-
} else {
491-
this.logger.info("Rule requirements fulfilled");
492-
// If we don't have any missing reviews, we return no error
493-
return null;
501+
// Then we verify if we need to have a minimum score
502+
} else if (rule.minTotalScore && scores) {
503+
this.logger.debug("Validating required minimum score");
504+
// We get all the fellows with their ranks and convert them to their score
505+
const fellows: [string, number][] = (await this.polkadotApi.listFellows()).map(([handle, rank]) => [
506+
handle,
507+
rankToScore(rank, scores),
508+
]);
509+
510+
const maximumScore = fellows.reduce((a, [_, score]) => a + score, 0);
511+
if (rule.minTotalScore > maximumScore) {
512+
throw new Error(
513+
`Minimum score of ${rule.minTotalScore} is higher that the obtainable score of ${maximumScore}!`,
514+
);
515+
}
516+
517+
let score = 0;
518+
519+
const countingFellows: [string, number][] = [];
520+
521+
// We iterate over all the approvals and convert their rank to their score
522+
for (const [handle, fellowScore] of fellows) {
523+
// We filter fellows whose score is 0
524+
if (approvals.indexOf(handle) > -1 && fellowScore > 0) {
525+
score += fellowScore;
526+
countingFellows.push([handle, fellowScore]);
527+
}
528+
}
529+
530+
this.logger.debug(`Current score is ${score} and the minimum required score is ${rule.minTotalScore}`);
531+
532+
if (rule.minTotalScore > score) {
533+
const missingUsers = fellows
534+
// Remove all the fellows who score is worth 0
535+
.filter(([_, fellowScore]) => fellowScore > 0)
536+
// Remove the author
537+
.filter(([handle]) => handle != author)
538+
// Remove the approvals
539+
.filter(([handle]) => approvals.indexOf(handle) < 0);
540+
541+
this.logger.warn(`Missing score of ${rule.minTotalScore} by ${score - rule.minTotalScore}`);
542+
543+
return new FellowMissingScoreFailure(rule, rule.minTotalScore, countingFellows, missingUsers);
544+
}
494545
}
546+
this.logger.info("Rule requirements fulfilled");
547+
// If we don't have any missing reviews, we return no error
548+
return null;
495549
}
496550

497551
/** Using the include and exclude condition, it returns a list of all the files in a PR that matches the criteria */

src/test/fellows.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mock, mockClear, MockProxy } from "jest-mock-extended";
44
import { ActionLogger, TeamApi } from "../github/types";
55
import { PolkadotFellows } from "../polkadot/fellows";
66

7-
const timeout = 15_000;
7+
const timeout = 25_000;
88

99
describe("CAPI test", () => {
1010
let fellows: TeamApi;

0 commit comments

Comments
 (0)