Skip to content

Commit 412fb9f

Browse files
authored
chore(prlint): don't mark PRs as 'not ready' on community comment (#27819)
Community reviewers have the ability to choose any of the approve/comment/request changes buttons that are available in the review tab. Prior to this change, `prlint` would consider a comment from a community reviewer as being equivalent to requesting changes (and in fact, didn't consider the requesting changes case). This would remove the `pr/needs-community-review` label which surprised some reviewers. With this change, `prlint` will only remove the `pr/needs-community-review` label when a community reviewer specifically chooses "request changes". Additionally, reviewers are now able to switch from approving to requesting changes (this doesn't override any other reviewers' approvals, just that reviewer's own). Additionally, this adds mocks for the `getTrustedCommunityMembers` method and avoids hardcoding the logins of multiple community reviewers into the tests. As a side effect, the tests also run faster since `curl` isn't being invoked so frequently. Here is a screenshot of what I see when reviewing on this repo: ![image](https://github.com/aws/aws-cdk/assets/850893/03d3b5e5-2798-47bf-951d-b72964f974aa) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4083ce8 commit 412fb9f

File tree

2 files changed

+162
-21
lines changed

2 files changed

+162
-21
lines changed

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

+34-13
Original file line numberDiff line numberDiff line change
@@ -382,20 +382,41 @@ export class PullRequestLinter {
382382
&& review.state === 'APPROVED',
383383
);
384384

385-
const communityApproved = reviews.data.some(
386-
review => this.getTrustedCommunityMembers().includes(review.user?.login ?? '')
387-
&& review.state === 'APPROVED',
388-
);
385+
// NOTE: community reviewers may approve, comment, or request changes; however, it
386+
// is possible for the same member to perform any combination of those actions on
387+
// a single PR. We solve this by:
388+
// 1. Filtering reviews to those by trusted community members
389+
// 2. Filtering out reviews that only leave comments (without approving or requesting changes).
390+
// This allows a reviewer to participate in a conversation about their review without
391+
// effectively dismissing their review. While GitHub does not allow community reviewers
392+
// to dismiss their reviews (which requires privileges on the repo), they can leave a
393+
// new review with the opposite approve/request state to update their review.
394+
// 3. Mapping reviewers to only their newest review
395+
// 4. Checking if any reviewers' most recent review is an approval
396+
// -> If so, the PR is considered community approved; the approval can always
397+
// be dismissed by a maintainer to respect another reviewer's requested changes.
398+
// 5. Checking if any reviewers' most recent review requested changes
399+
// -> If so, the PR is considered to still need changes to meet community review.
400+
const reviewsByTrustedCommunityMembers = reviews.data
401+
.filter(review => this.getTrustedCommunityMembers().includes(review.user?.login ?? ''))
402+
.filter(review => review.state !== 'PENDING' && review.state !== 'COMMENTED')
403+
.reduce((grouping, review) => {
404+
// submitted_at is not present for PENDING comments but is present for other states.
405+
// Because of that, it is optional on the type but sure to be present here. Likewise,
406+
// review.user is sure to be defined because we're operating on reviews by trusted
407+
// community members
408+
let newest = grouping[review.user!.login] ?? review;
409+
if (review.submitted_at! > newest.submitted_at!) {
410+
newest = review;
411+
}
389412

390-
// NOTE: community members can only approve or comment, but it is possible
391-
// for the same member to have both an approved review and a commented review.
392-
// we solve this issue by turning communityRequestedChanges to false if
393-
// communityApproved is true. We can always dismiss an approved review if we want
394-
// to respect someone else's requested changes.
395-
const communityRequestedChanges = communityApproved ? false : reviews.data.some(
396-
review => this.getTrustedCommunityMembers().includes(review.user?.login ?? '')
397-
&& review.state === 'COMMENTED',
398-
);
413+
return {
414+
...grouping,
415+
[review.user!.login]: newest,
416+
};
417+
}, {} as Record<string, typeof reviews.data[0]>);
418+
const communityApproved = Object.values(reviewsByTrustedCommunityMembers).some(({state}) => state === 'APPROVED');
419+
const communityRequestedChanges = !communityApproved && Object.values(reviewsByTrustedCommunityMembers).some(({state}) => state === 'CHANGES_REQUESTED')
399420

400421
const prLinterFailed = reviews.data.find((review) => review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') as Review;
401422
const userRequestsExemption = pr.labels.some(label => (label.name === Exemption.REQUEST_EXEMPTION || label.name === Exemption.REQUEST_CLARIFICATION));

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

+128-8
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ let mockAddLabel = jest.fn();
77
let mockListReviews = jest.fn().mockImplementation((_props: { _owner: string, _repo: string, _pull_number: number }) => {
88
return { data: [{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' }] };
99
});
10+
1011
beforeAll(() => {
1112
jest.spyOn(console, 'log').mockImplementation();
13+
jest.spyOn(linter.PullRequestLinter.prototype as any, 'getTrustedCommunityMembers').mockImplementation(() => ['trusted1', 'trusted2', 'trusted3'])
1214
process.env.REPO_ROOT = path.join(__dirname, '..', '..', '..', '..');
1315
});
1416

@@ -514,8 +516,8 @@ describe('integration tests required on features', () => {
514516
test('with label no error', async () => {
515517
labels.push({ name: 'pr-linter/cli-integ-tested' });
516518
const prLinter = configureMock(issue, files);
517-
await prLinter.validatePullRequestTarget(SHA);
518519
// THEN: no exception
520+
expect(async () => await prLinter.validatePullRequestTarget(SHA)).resolves;
519521
});
520522

521523
test('with aws-cdk-automation author', async () => {
@@ -653,8 +655,8 @@ describe('integration tests required on features', () => {
653655
mockListReviews.mockImplementation(() => {
654656
return {
655657
data: [
656-
{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' },
657-
{ id: 1111122223, user: { login: 'someuser' }, author_association: 'MEMBER', state: 'CHANGES_REQUESTED' },
658+
{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z'},
659+
{ id: 1111122223, user: { login: 'someuser' }, author_association: 'MEMBER', state: 'CHANGES_REQUESTED', submitted_at: '2019-11-18T17:43:43Z' },
658660
],
659661
};
660662
});
@@ -723,7 +725,7 @@ describe('integration tests required on features', () => {
723725
mockListReviews.mockImplementation(() => {
724726
return {
725727
data: [
726-
{ id: 1111122223, user: { login: 'pahud' }, state: 'APPROVED' },
728+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED' },
727729
],
728730
};
729731
});
@@ -761,8 +763,9 @@ describe('integration tests required on features', () => {
761763
mockListReviews.mockImplementation(() => {
762764
return {
763765
data: [
764-
{ id: 1111122223, user: { login: 'pahud' }, state: 'COMMENTED' },
765-
{ id: 1111122223, user: { login: 'pahud' }, state: 'APPROVED' },
766+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-18T17:43:43Z' },
767+
{ id: 1111122224, user: { login: 'trusted2' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T18:43:43Z' },
768+
{ id: 1111122225, user: { login: 'trusted3' }, state: 'COMMENTED', submitted_at: '2019-11-17T19:43:43Z' },
766769
],
767770
};
768771
});
@@ -795,12 +798,12 @@ describe('integration tests required on features', () => {
795798
});
796799
});
797800

798-
test('trusted community member can "request changes" on p2 PR by commenting', async () => {
801+
test('trusted community member can "request changes" on p2 PR by requesting changes', async () => {
799802
// GIVEN
800803
mockListReviews.mockImplementation(() => {
801804
return {
802805
data: [
803-
{ id: 1111122223, user: { login: 'pahud' }, state: 'COMMENTED' },
806+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
804807
],
805808
};
806809
});
@@ -828,6 +831,123 @@ describe('integration tests required on features', () => {
828831
expect(mockAddLabel.mock.calls).toEqual([]);
829832
});
830833

834+
test('trusted community member can comment after requesting changes without dismissing', async () => {
835+
// GIVEN
836+
mockListReviews.mockImplementation(() => {
837+
return {
838+
data: [
839+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
840+
{ id: 1111122224, user: { login: 'trusted1' }, state: 'COMMENTED', submitted_at: '2019-11-18T17:43:43Z' },
841+
],
842+
};
843+
});
844+
(pr as any).labels = [];
845+
846+
// WHEN
847+
const prLinter = configureMock(pr);
848+
await prLinter.validateStatusEvent(pr as any, {
849+
sha: SHA,
850+
context: linter.CODE_BUILD_CONTEXT,
851+
state: 'success',
852+
} as any);
853+
854+
// THEN
855+
expect(mockRemoveLabel.mock.calls).toEqual([]);
856+
expect(mockAddLabel.mock.calls).toEqual([]);
857+
});
858+
859+
test('trusted community member comments dont mark as "changes requested"', async () => {
860+
// GIVEN
861+
mockListReviews.mockImplementation(() => {
862+
return {
863+
data: [
864+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'COMMENTED', submitted_at: '2019-11-17T17:43:43Z' },
865+
],
866+
};
867+
});
868+
(pr as any).labels = [
869+
{
870+
name: 'pr/needs-community-review',
871+
},
872+
];
873+
874+
// WHEN
875+
const prLinter = configureMock(pr);
876+
await prLinter.validateStatusEvent(pr as any, {
877+
sha: SHA,
878+
context: linter.CODE_BUILD_CONTEXT,
879+
state: 'success',
880+
} as any);
881+
882+
// THEN
883+
expect(mockRemoveLabel.mock.calls).toEqual([]);
884+
expect(mockAddLabel.mock.calls).toEqual([]);
885+
});
886+
887+
test('trusted community members can change own review from approval to requesting changes', async () => {
888+
// GIVEN
889+
mockListReviews.mockImplementation(() => {
890+
return {
891+
data: [
892+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-17T17:43:43Z' },
893+
{ id: 1111122224, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-18T17:43:43Z' },
894+
]
895+
}
896+
});
897+
(pr as any).labels = [
898+
{
899+
name: 'pr/needs-maintainer-review',
900+
}
901+
];
902+
903+
// WHEN
904+
const prLinter = configureMock(pr);
905+
await prLinter.validateStatusEvent(pr as any, {
906+
sha: SHA,
907+
context: linter.CODE_BUILD_CONTEXT,
908+
state: 'success',
909+
} as any);
910+
911+
// THEN
912+
expect(mockRemoveLabel.mock.calls[0][0]).toEqual({
913+
issue_number: 1234,
914+
name: 'pr/needs-maintainer-review',
915+
owner: 'aws',
916+
repo: 'aws-cdk',
917+
});
918+
expect(mockAddLabel.mock.calls).toEqual([]);
919+
});
920+
921+
test('trusted community members can change own review from requesting changes to approval', async () => {
922+
// GIVEN
923+
mockListReviews.mockImplementation(() => {
924+
return {
925+
data: [
926+
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
927+
{ id: 1111122224, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-18T17:43:43Z' },
928+
]
929+
}
930+
});
931+
(pr as any).labels = [];
932+
933+
// WHEN
934+
const prLinter = configureMock(pr);
935+
await prLinter.validateStatusEvent(pr as any, {
936+
sha: SHA,
937+
context: linter.CODE_BUILD_CONTEXT,
938+
state: 'success',
939+
} as any);
940+
941+
// THEN
942+
expect(mockRemoveLabel.mock.calls).toEqual([]);
943+
expect(mockAddLabel.mock.calls[0][0]).toEqual({
944+
issue_number: 1234,
945+
labels: ['pr/needs-maintainer-review'],
946+
owner: 'aws',
947+
repo: 'aws-cdk',
948+
});
949+
});
950+
831951
test('untrusted community member approval has no affect', async () => {
832952
// GIVEN
833953
mockListReviews.mockImplementation(() => {

0 commit comments

Comments
 (0)