Skip to content

Commit

Permalink
chore(prlint): don't mark PRs as 'not ready' on community comment
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
laurelmay committed Nov 3, 2023
1 parent 12fe361 commit 1e24d2e
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 21 deletions.
47 changes: 34 additions & 13 deletions tools/@aws-cdk/prlint/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,20 +382,41 @@ export class PullRequestLinter {
&& review.state === 'APPROVED',
);

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

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

const prLinterFailed = reviews.data.find((review) => review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') as Review;
const userRequestsExemption = pr.labels.some(label => (label.name === Exemption.REQUEST_EXEMPTION || label.name === Exemption.REQUEST_CLARIFICATION));
Expand Down
136 changes: 128 additions & 8 deletions tools/@aws-cdk/prlint/test/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ let mockAddLabel = jest.fn();
let mockListReviews = jest.fn().mockImplementation((_props: { _owner: string, _repo: string, _pull_number: number }) => {
return { data: [{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' }] };
});

beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(linter.PullRequestLinter.prototype as any, 'getTrustedCommunityMembers').mockImplementation(() => ['trusted1', 'trusted2', 'trusted3'])
process.env.REPO_ROOT = path.join(__dirname, '..', '..', '..', '..');
});

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

test('with aws-cdk-automation author', async () => {
Expand Down Expand Up @@ -653,8 +655,8 @@ describe('integration tests required on features', () => {
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' },
{ id: 1111122223, user: { login: 'someuser' }, author_association: 'MEMBER', state: 'CHANGES_REQUESTED' },
{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z'},
{ id: 1111122223, user: { login: 'someuser' }, author_association: 'MEMBER', state: 'CHANGES_REQUESTED', submitted_at: '2019-11-18T17:43:43Z' },
],
};
});
Expand Down Expand Up @@ -723,7 +725,7 @@ describe('integration tests required on features', () => {
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'pahud' }, state: 'APPROVED' },
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED' },
],
};
});
Expand Down Expand Up @@ -761,8 +763,9 @@ describe('integration tests required on features', () => {
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'pahud' }, state: 'COMMENTED' },
{ id: 1111122223, user: { login: 'pahud' }, state: 'APPROVED' },
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-18T17:43:43Z' },
{ id: 1111122224, user: { login: 'trusted2' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T18:43:43Z' },
{ id: 1111122225, user: { login: 'trusted3' }, state: 'COMMENTED', submitted_at: '2019-11-17T19:43:43Z' },
],
};
});
Expand Down Expand Up @@ -795,12 +798,12 @@ describe('integration tests required on features', () => {
});
});

test('trusted community member can "request changes" on p2 PR by commenting', async () => {
test('trusted community member can "request changes" on p2 PR by requesting changes', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'pahud' }, state: 'COMMENTED' },
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
],
};
});
Expand Down Expand Up @@ -828,6 +831,123 @@ describe('integration tests required on features', () => {
expect(mockAddLabel.mock.calls).toEqual([]);
});

test('trusted community member can comment after requesting changes without dismissing', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
{ id: 1111122224, user: { login: 'trusted1' }, state: 'COMMENTED', submitted_at: '2019-11-18T17:43:43Z' },
],
};
});
(pr as any).labels = [];

// WHEN
const prLinter = configureMock(pr);
await prLinter.validateStatusEvent(pr as any, {
sha: SHA,
context: linter.CODE_BUILD_CONTEXT,
state: 'success',
} as any);

// THEN
expect(mockRemoveLabel.mock.calls).toEqual([]);
expect(mockAddLabel.mock.calls).toEqual([]);
});

test('trusted community member comments dont mark as "changes requested"', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'trusted1' }, state: 'COMMENTED', submitted_at: '2019-11-17T17:43:43Z' },
],
};
});
(pr as any).labels = [
{
name: 'pr/needs-community-review',
},
];

// WHEN
const prLinter = configureMock(pr);
await prLinter.validateStatusEvent(pr as any, {
sha: SHA,
context: linter.CODE_BUILD_CONTEXT,
state: 'success',
} as any);

// THEN
expect(mockRemoveLabel.mock.calls).toEqual([]);
expect(mockAddLabel.mock.calls).toEqual([]);
});

test('trusted community members can change own review from approval to requesting changes', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-17T17:43:43Z' },
{ id: 1111122224, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-18T17:43:43Z' },
]
}
});
(pr as any).labels = [
{
name: 'pr/needs-maintainer-review',
}
];

// WHEN
const prLinter = configureMock(pr);
await prLinter.validateStatusEvent(pr as any, {
sha: SHA,
context: linter.CODE_BUILD_CONTEXT,
state: 'success',
} as any);

// THEN
expect(mockRemoveLabel.mock.calls[0][0]).toEqual({
issue_number: 1234,
name: 'pr/needs-maintainer-review',
owner: 'aws',
repo: 'aws-cdk',
});
expect(mockAddLabel.mock.calls).toEqual([]);
});

test('trusted community members can change own review from requesting changes to approval', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
return {
data: [
{ id: 1111122223, user: { login: 'trusted1' }, state: 'CHANGES_REQUESTED', submitted_at: '2019-11-17T17:43:43Z' },
{ id: 1111122224, user: { login: 'trusted1' }, state: 'APPROVED', submitted_at: '2019-11-18T17:43:43Z' },
]
}
});
(pr as any).labels = [];

// WHEN
const prLinter = configureMock(pr);
await prLinter.validateStatusEvent(pr as any, {
sha: SHA,
context: linter.CODE_BUILD_CONTEXT,
state: 'success',
} as any);

// THEN
expect(mockRemoveLabel.mock.calls).toEqual([]);
expect(mockAddLabel.mock.calls[0][0]).toEqual({
issue_number: 1234,
labels: ['pr/needs-maintainer-review'],
owner: 'aws',
repo: 'aws-cdk',
});
});

test('untrusted community member approval has no affect', async () => {
// GIVEN
mockListReviews.mockImplementation(() => {
Expand Down

0 comments on commit 1e24d2e

Please sign in to comment.