Skip to content

Commit 128b7a8

Browse files
authored
chore(prlint): check PR size (#33288)
Add a new prlint rule that checks whether the size of the PR exceeds the predefined threshold. The PR size is measured by how many lines of code were added or removed (as reported by Git). If either the number of additions or deletions exceeds 1000 lines, the rule reports an error. Only two packages are subject to this check: `aws-cdk` and `@aws-cdk-testing/cli-integ`. Markdown files and `THIRD_PARTY_LICENSES` are also excluded. Prior art: [amplify-backend](https://github.com/aws-amplify/amplify-backend/blob/main/scripts/check_pr_size.ts). ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 5160796 commit 128b7a8

File tree

5 files changed

+172
-16
lines changed

5 files changed

+172
-16
lines changed

tools/@aws-cdk/prlint/README.md

+13-11
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,31 @@
22

33
This package includes a GitHub Action that does the following:
44
- Checks pull requests around PR titles, description and other metadata.
5-
- Assesses whether or not the PR is ready for review and attaches the correct label to the PR.
5+
- Assesses whether the PR is ready for review and attaches the correct label to the PR.
66

77
# Rules
88

9-
The first part of the GitHub Action validates whether or not the pull request adheres
9+
The first part of the GitHub Action validates whether the pull request adheres
1010
(or has been exempted from) to the following rules:
1111

1212
1. `feat` requires a change to a `README.md` (exemption is the label `pr-linter/exempt-readme`).
1313
2. Both `feat` and `fix` PRs require a change to a unit test file and integration test files (exemption is the label `pr-linter/exempt-unit-test` or `pr-linter/exempt-integ-test`).
14-
4. `BREAKING CHANGE` section is formatted correctly, per the [conventional commits] spec.
15-
5. No breaking changes announced for stable modules.
16-
6. Title prefix and scope is formatted correctly.
17-
7. The PR is not opened from the main branch of the author's fork.
18-
8. Changes to the cli have been run through the test pipeline where cli integ tests are run (indicated by the label `pr-linter/cli-integ-tested`).
19-
9. No manual changes to `packages/aws-cdk-lib/region-info/build-tools/metadata.ts` file.
14+
3. `BREAKING CHANGE` section is formatted correctly, per the [conventional commits] spec.
15+
4. No breaking changes announced for stable modules.
16+
5. Title prefix and scope is formatted correctly.
17+
6. The PR is not opened from the main branch of the author's fork.
18+
7. Changes to the cli have been run through the test pipeline where cli integ tests are run (indicated by the label `pr-linter/cli-integ-tested`).
19+
8. No manual changes to `packages/aws-cdk-lib/region-info/build-tools/metadata.ts` file.
20+
9. The size of the PR (number of lines added or removed) does not exceed the
21+
pre-defined threshold of 1000.
2022

2123
> These rules are currently hard coded, in the future, we should consider using [danger.js](https://danger.systems/js/).
2224
2325
[conventional commits]: https://www.conventionalcommits.org
2426

2527
# Evaluation and Assigning Labels
2628

27-
The GitHub Action also handles whether or not the PR is ready for review, and by whom.
29+
The GitHub Action also handles whether the PR is ready for review, and by whom.
2830
A PR is ready for review when:
2931

3032
1. It is not a draft
@@ -33,7 +35,7 @@ A PR is ready for review when:
3335
4. A maintainer has not requested changes
3436
5. A maintainer has not approved
3537

36-
If the PR is ready for review, we also differentiate whether or not it is ready for a
38+
If the PR is ready for review, we also differentiate whether it is ready for a
3739
maintainer review (`pr/needs-maintainer-review`) or if we are soliciting help from our
3840
pool of trusted reviewers (`pr/needs-community-review`).
3941

@@ -54,7 +56,7 @@ yarn install
5456

5557
# Usage
5658

57-
The steps for your Github action would look something like this -
59+
The steps for your GitHub action would look something like this -
5860

5961
```yaml
6062
steps:

tools/@aws-cdk/prlint/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export enum Exemption {
1616
REQUEST_CLARIFICATION = 'pr/reviewer-clarification-requested',
1717
REQUEST_EXEMPTION = 'pr-linter/exemption-requested',
1818
CODECOV = "pr-linter/exempt-codecov",
19+
SIZE_CHECK = "pr-linter/exempt-size-check",
1920
}
2021

2122
const CODECOV_PREFIX = 'codecov/';

tools/@aws-cdk/prlint/github.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,31 @@ export interface GitHubLabel {
3030
readonly name: string;
3131
}
3232

33-
export interface GitHubFile {
33+
export interface Delta {
34+
readonly additions: number;
35+
readonly deletions: number;
36+
}
37+
38+
export interface GitHubFile extends Delta {
3439
readonly filename: string;
3540
}
3641

42+
export function sumChanges(files: GitHubFile[]) {
43+
function add(d1: Delta, d2: Delta): Delta {
44+
return {
45+
additions: d1.additions + d2.additions,
46+
deletions: d1.deletions + d2.deletions,
47+
};
48+
}
49+
50+
const identity = {
51+
additions: 0,
52+
deletions: 0
53+
};
54+
55+
return files.reduce(add, identity);
56+
}
57+
3758

3859
/**
3960
* Combine all check run conclusions

tools/@aws-cdk/prlint/lint.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from 'path';
22
import { findModulePath, moduleStability } from './module';
33
import { breakingModules } from './parser';
4-
import { CheckRun, GitHubFile, GitHubPr, Review, summarizeRunConclusions } from "./github";
4+
import { CheckRun, GitHubFile, GitHubPr, Review, sumChanges, summarizeRunConclusions } from "./github";
55
import { TestResult, ValidationCollector } from './results';
66
import { CODE_BUILD_CONTEXT, CODECOV_CHECKS, Exemption } from './constants';
77
import { StatusEvent } from '@octokit/webhooks-definitions/schema';
@@ -257,6 +257,13 @@ export class PullRequestLinter extends PullRequestLinterBase {
257257
],
258258
});
259259

260+
validationCollector.validateRuleSet({
261+
exemption: shouldExemptSizeCheck,
262+
testRuleSet: [
263+
{ test: prIsSmall },
264+
],
265+
})
266+
260267
if (pr.base.ref === 'main') {
261268
// Only check CodeCov for PRs targeting 'main'
262269
const runs = await this.checkRuns(sha);
@@ -421,6 +428,10 @@ function shouldExemptCliIntegTested(pr: GitHubPr): boolean {
421428
return (hasLabel(pr, Exemption.CLI_INTEG_TESTED) || pr.user?.login === 'aws-cdk-automation');
422429
}
423430

431+
function shouldExemptSizeCheck(pr: GitHubPr): boolean {
432+
return hasLabel(pr, Exemption.SIZE_CHECK);
433+
}
434+
424435
function shouldExemptAnalyticsMetadataChange(pr: GitHubPr): boolean {
425436
return (hasLabel(pr, Exemption.ANALYTICS_METADATA_CHANGE) || pr.user?.login === 'aws-cdk-automation');
426437
}
@@ -570,6 +581,31 @@ function noAnalyticsEnumsChanges(_pr: GitHubPr, files: GitHubFile[]): TestResult
570581
return result;
571582
}
572583

584+
function prIsSmall(_pr: GitHubPr, files: GitHubFile[]): TestResult {
585+
const folders = ['packages/aws-cdk/', 'packages/@aws-cdk-testing/cli-integ/'];
586+
const exclude = [/THIRD_PARTY_LICENSES/, /.*\.md/, /.*\.test\.ts/];
587+
const maxLinesAdded = 1000;
588+
const maxLinesRemoved = 1000;
589+
590+
const filesToCheck: GitHubFile[] = files
591+
.filter(r => folders.some(folder => r.filename.startsWith(folder)))
592+
.filter(r => exclude.every(re => !re.test(r.filename)));
593+
594+
const sum = sumChanges(filesToCheck);
595+
596+
const result = new TestResult();
597+
result.assessFailure(
598+
sum.additions > maxLinesAdded,
599+
`The number of lines added (${sum.additions}) is greater than ${maxLinesAdded}`
600+
);
601+
result.assessFailure(
602+
sum.deletions > maxLinesRemoved,
603+
`The number of lines removed (${sum.deletions}) is greater than ${maxLinesAdded}`
604+
);
605+
606+
return result;
607+
}
608+
573609
require('make-runnable/custom')({
574610
printOutputFrame: false,
575611
});

tools/@aws-cdk/prlint/test/lint.test.ts

+99-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { PullRequestLinter } from '../lint';
55
import { StatusEvent } from '@octokit/webhooks-definitions/schema';
66
import { createOctomock, OctoMock } from './octomock';
77

8+
type GitHubFileName = Omit<GitHubFile, 'deletions' | 'additions'>;
9+
810
let mockRemoveLabel = jest.fn();
911
let mockAddLabel = jest.fn();
1012

@@ -1138,7 +1140,7 @@ describe('integration tests required on features', () => {
11381140
});
11391141

11401142
describe('metadata file changed', () => {
1141-
const files: GitHubFile[] = [{
1143+
const files: GitHubFileName[] = [{
11421144
filename: 'packages/aws-cdk-lib/region-info/build-tools/metadata.ts',
11431145
}];
11441146

@@ -1170,6 +1172,100 @@ describe('integration tests required on features', () => {
11701172
await expect(legacyValidatePullRequestTarget(prLinter)).rejects.toThrow();
11711173
});
11721174
});
1175+
1176+
describe('PR size check', () => {
1177+
const pr = {
1178+
title: 'chore: update regions',
1179+
number: 1234,
1180+
labels: [],
1181+
user: {
1182+
login: 'aws-cdk-automation',
1183+
},
1184+
};
1185+
1186+
test('PR too large', async () => {
1187+
const files: GitHubFile[] = [{
1188+
filename: 'packages/aws-cdk/foo.ts',
1189+
additions: 1001,
1190+
deletions: 1002,
1191+
}];
1192+
1193+
const prLinter = configureMock(pr, files);
1194+
1195+
await expect(prLinter.validatePullRequestTarget()).resolves.toEqual(expect.objectContaining({
1196+
requestChanges: {
1197+
exemptionRequest: false,
1198+
failures: [
1199+
'The number of lines added (1001) is greater than 1000',
1200+
'The number of lines removed (1002) is greater than 1000',
1201+
]
1202+
}
1203+
}));
1204+
});
1205+
1206+
test('PR size within bounds', async () => {
1207+
const files: GitHubFile[] = [{
1208+
filename: 'packages/aws-cdk/foo.ts',
1209+
additions: 1000,
1210+
deletions: 1000,
1211+
}];
1212+
1213+
const prLinter = configureMock(pr, files);
1214+
1215+
await expect(prLinter.validatePullRequestTarget()).resolves.toEqual(expect.objectContaining({
1216+
requestChanges: undefined,
1217+
}));
1218+
});
1219+
1220+
test('PR exempt from size check', async () => {
1221+
const files: GitHubFile[] = [{
1222+
filename: 'packages/aws-cdk/foo.ts',
1223+
additions: 2000,
1224+
deletions: 111,
1225+
}];
1226+
1227+
const exemptPr = {
1228+
...pr,
1229+
labels: [
1230+
{ name: 'pr-linter/exempt-size-check' },
1231+
],
1232+
}
1233+
1234+
const prLinter = configureMock(exemptPr, files);
1235+
1236+
await expect(prLinter.validatePullRequestTarget()).resolves.toEqual(expect.objectContaining({
1237+
requestChanges: undefined,
1238+
}));
1239+
});
1240+
1241+
test('Only CLI is subject to verification', async () => {
1242+
const files: GitHubFile[] = [{
1243+
filename: 'packages/aws-cdk-lib/foo.ts',
1244+
additions: 1001,
1245+
deletions: 1002,
1246+
}];
1247+
1248+
const prLinter = configureMock(pr, files);
1249+
1250+
await expect(prLinter.validatePullRequestTarget()).resolves.toEqual(expect.objectContaining({
1251+
requestChanges: undefined
1252+
}));
1253+
});
1254+
1255+
test('Tests are exempt', async () => {
1256+
const files: GitHubFile[] = [{
1257+
filename: 'packages/aws-cdk/test/bootstrap.test.ts',
1258+
additions: 1001,
1259+
deletions: 1002,
1260+
}];
1261+
1262+
const prLinter = configureMock(pr, files);
1263+
1264+
await expect(prLinter.validatePullRequestTarget()).resolves.toEqual(expect.objectContaining({
1265+
requestChanges: undefined
1266+
}));
1267+
});
1268+
});
11731269
});
11741270

11751271
describe('for any PR', () => {
@@ -1182,7 +1278,7 @@ describe('for any PR', () => {
11821278
},
11831279
};
11841280

1185-
const ARBITRARY_FILES: GitHubFile[] = [{
1281+
const ARBITRARY_FILES: GitHubFileName[] = [{
11861282
filename: 'README.md',
11871283
}];
11881284

@@ -1247,7 +1343,7 @@ describe('for any PR', () => {
12471343
});
12481344
});
12491345

1250-
function configureMock(pr: Subset<GitHubPr>, prFiles?: GitHubFile[], existingComments?: Array<{ id: number, login: string, body: string }>): PullRequestLinter & { octomock: OctoMock } {
1346+
function configureMock(pr: Subset<GitHubPr>, prFiles?: GitHubFileName[], existingComments?: Array<{ id: number, login: string, body: string }>): PullRequestLinter & { octomock: OctoMock } {
12511347
const octomock = createOctomock();
12521348

12531349
octomock.pulls.get.mockImplementation((_props: { _owner: string, _repo: string, _pull_number: number, _user: { _login: string} }) => ({

0 commit comments

Comments
 (0)