blob: 7186b39c90e7948335267ac2b4d846cbcb7b9ee1 [file] [log] [blame]
// Copyright 2019 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:cocoon_service/cocoon_service.dart';
import 'package:cocoon_service/src/request_handlers/check_for_waiting_pull_requests_queries.dart';
import 'package:graphql/client.dart';
import 'package:meta/meta.dart';
import 'package:test/test.dart';
import '../src/datastore/fake_cocoon_config.dart';
import '../src/request_handling/api_request_handler_tester.dart';
import '../src/request_handling/fake_authentication.dart';
import '../src/request_handling/fake_http.dart';
import '../src/request_handling/fake_logging.dart';
import '../src/service/fake_cirrus_graphql_client.dart';
import '../src/service/fake_graphql_client.dart';
const String base64LabelId = 'base_64_label_id';
const String oid = 'deadbeef';
void main() {
group('check for waiting pull requests', () {
CheckForWaitingPullRequests handler;
FakeHttpRequest request;
FakeConfig config;
FakeClientContext clientContext;
FakeAuthenticationProvider auth;
FakeLogging log;
FakeGraphQLClient githubGraphQLClient;
FakeCirrusGraphQLClient cirrusGraphQLClient;
ApiRequestHandlerTester tester;
final List<PullRequestHelper> flutterRepoPRs = <PullRequestHelper>[];
final List<PullRequestHelper> engineRepoPRs = <PullRequestHelper>[];
List<dynamic> statuses = <dynamic>[];
String branch;
setUp(() {
request = FakeHttpRequest();
clientContext = FakeClientContext();
auth = FakeAuthenticationProvider(clientContext: clientContext);
log = FakeLogging();
githubGraphQLClient = FakeGraphQLClient();
cirrusGraphQLClient = FakeCirrusGraphQLClient();
config = FakeConfig(
rollerAccountsValue: <String>{},
cirrusGraphQLClient: cirrusGraphQLClient);
flutterRepoPRs.clear();
engineRepoPRs.clear();
statuses.clear();
PullRequestHelper._counter = 0;
cirrusGraphQLClient.mutateCirrusResultForOptions =
(MutationOptions options) => QueryResult();
cirrusGraphQLClient.queryCirrusResultForOptions = (QueryOptions options) {
return createCirrusQueryResult(statuses, branch);
};
githubGraphQLClient.mutateResultForOptions =
(MutationOptions options) => QueryResult();
githubGraphQLClient.queryResultForOptions = (QueryOptions options) {
expect(options.variables['sOwner'], 'flutter');
expect(options.variables['sLabelName'],
config.waitingForTreeToGoGreenLabelNameValue);
final String repoName = options.variables['sName'] as String;
if (repoName == 'flutter') {
return createQueryResult(flutterRepoPRs);
} else if (repoName == 'engine') {
return createQueryResult(engineRepoPRs);
} else {
fail('unexpected repo $repoName');
}
};
tester = ApiRequestHandlerTester(request: request);
config.waitingForTreeToGoGreenLabelNameValue =
'waiting for tree to go green';
config.githubGraphQLClient = githubGraphQLClient;
handler = CheckForWaitingPullRequests(
config,
auth,
loggingProvider: () => log,
);
});
void _verifyQueries() {
githubGraphQLClient.verifyQueries(
<QueryOptions>[
QueryOptions(
document: labeledPullRequestsWithReviewsQuery,
fetchPolicy: FetchPolicy.noCache,
variables: <String, dynamic>{
'sOwner': 'flutter',
'sName': 'flutter',
'sLabelName': config.waitingForTreeToGoGreenLabelNameValue,
},
),
QueryOptions(
document: labeledPullRequestsWithReviewsQuery,
fetchPolicy: FetchPolicy.noCache,
variables: <String, dynamic>{
'sOwner': 'flutter',
'sName': 'engine',
'sLabelName': config.waitingForTreeToGoGreenLabelNameValue,
},
),
],
);
}
test('Errors can be logged', () async {
flutterRepoPRs.add(PullRequestHelper());
final List<GraphQLError> errors = <GraphQLError>[
GraphQLError(raw: <String, String>{}, message: 'message'),
];
githubGraphQLClient.mutateResultForOptions =
(_) => QueryResult(errors: errors);
await tester.get(handler);
expect(log.records.length, errors.length);
for (int i = 0; i < errors.length; i++) {
expect(log.records[i].message, errors[i].toString());
}
});
test('Merges unapproved PR from autoroller', () async {
config.rollerAccountsValue = <String>{'engine-roller', 'skia-roller'};
flutterRepoPRs.add(PullRequestHelper(
author: 'engine-roller', reviews: const <PullRequestReviewHelper>[]));
engineRepoPRs.add(PullRequestHelper(
author: 'skia-roller', reviews: const <PullRequestReviewHelper>[]));
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs.first.id,
'oid': oid,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': engineRepoPRs.first.id,
'oid': oid,
},
),
],
);
});
test('Does not merge PR with in progress tests', () async {
statuses = <dynamic>[
<String, String>{'id': '1', 'status': 'EXECUTING', 'name': 'test1'},
<String, String>{'id': '2', 'status': 'COMPLETED', 'name': 'test2'}
];
branch = 'pull/0';
flutterRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(<MutationOptions>[]);
});
test('Ignores cirrus tasks statuses when no matched branch', () async {
statuses = <dynamic>[
<String, String>{'id': '1', 'status': 'EXECUTING', 'name': 'test1'},
<String, String>{'id': '2', 'status': 'COMPLETED', 'name': 'test2'}
];
branch = 'flutter-0.0-candidate.0';
flutterRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[0].id,
'oid': oid,
},
),
],
);
});
test('Merge PR with complated tests', () async {
statuses = <dynamic>[
<String, String>{'id': '1', 'status': 'SKIPPED', 'name': 'test1'},
<String, String>{'id': '2', 'status': 'COMPLETED', 'name': 'test2'}
];
branch = 'pull/0';
flutterRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[0].id,
'oid': oid,
},
),
],
);
});
test('Does not merge PR with failed tests', () async {
statuses = <dynamic>[
<String, String>{'id': '1', 'status': 'FAILED', 'name': 'test1'},
<String, String>{'id': '2', 'status': 'COMPLETED', 'name': 'test2'}
];
branch = 'pull/0';
flutterRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[0].id,
'labelId': base64LabelId,
'sBody': '''
This pull request is not suitable for automatic merging in its current state.
- The status or check suite test1 has failed. Please fix the issues identified (or deflake) before re-applying this label.
''',
},
),
],
);
});
test('Does not merge unapproved PR from a hacker', () async {
config.rollerAccountsValue = <String>{'engine-roller', 'skia-roller'};
flutterRepoPRs.add(PullRequestHelper(
author: 'engine-roller-hacker',
reviews: const <PullRequestReviewHelper>[]));
engineRepoPRs.add(PullRequestHelper(
author: 'skia-roller-hacker',
reviews: const <PullRequestReviewHelper>[]));
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs.first.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': engineRepoPRs.first.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
],
);
});
test('Merges first 2 PRs in list, all successful', () async {
flutterRepoPRs.add(PullRequestHelper());
flutterRepoPRs.add(PullRequestHelper());
flutterRepoPRs.add(PullRequestHelper()); // will be ignored.
engineRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[0].id,
'oid': oid,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[1].id,
'oid': oid,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': engineRepoPRs.first.id,
'oid': oid,
},
),
],
);
});
test('Merges 1st and 3rd PR, 2nd failed', () async {
flutterRepoPRs.add(PullRequestHelper());
flutterRepoPRs.add(PullRequestHelper(
author: 'engine-roller-hacker',
reviews: const <PullRequestReviewHelper>[]));
flutterRepoPRs.add(PullRequestHelper());
engineRepoPRs.add(PullRequestHelper());
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[0].id,
'oid': oid,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[1].id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs[2].id,
'oid': oid,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': engineRepoPRs.first.id,
'oid': oid,
},
),
],
);
});
test('Ignores PRs that are too new', () async {
flutterRepoPRs.add(PullRequestHelper(
dateTime:
DateTime.now().add(const Duration(minutes: -50)))); // too new
flutterRepoPRs.add(PullRequestHelper(
dateTime: DateTime.now().add(const Duration(minutes: -70)))); // ok
engineRepoPRs
.add(PullRequestHelper()); // default is two hours for this ctor.
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': flutterRepoPRs.last.id,
'oid': oid,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': engineRepoPRs.first.id,
'oid': oid,
},
),
],
);
});
test('Unlabels red PRs', () async {
statuses = <dynamic>[
<String, String>{'id': '1', 'status': 'FAILED', 'name': 'test1'},
<String, String>{'id': '2', 'status': 'COMPLETED', 'name': 'test2'}
];
branch = 'pull/0';
final PullRequestHelper prRed = PullRequestHelper(
lastCommitStatuses: const <StatusHelper>[
StatusHelper.flutterBuildSuccess,
StatusHelper.otherStatusFailure,
],
);
flutterRepoPRs.add(prRed);
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(<MutationOptions>[
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prRed.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- The status or check suite other status has failed. Please fix the issues identified (or deflake) before re-applying this label.
- The status or check suite test1 has failed. Please fix the issues identified (or deflake) before re-applying this label.
''',
'labelId': base64LabelId,
},
),
]);
});
test('Allows member to change review', () async {
final PullRequestHelper prChangedReview = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
changePleaseChange,
changePleaseApprove,
],
);
flutterRepoPRs.add(prChangedReview);
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': prChangedReview.id,
'oid': oid,
}),
],
);
});
test('Ignores non-member/owner reviews', () async {
final PullRequestHelper prNonMemberApprove = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
nonMemberApprove,
],
);
final PullRequestHelper prNonMemberChangeRequest = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
nonMemberChangeRequest,
],
);
final PullRequestHelper prNonMemberChangeRequestWithMemberApprove =
PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
ownerApprove,
nonMemberChangeRequest,
],
);
// Ignored approval from non-member
flutterRepoPRs.add(prNonMemberApprove);
// Ignored change reuqest from non-member (but still no approval from member)
flutterRepoPRs.add(prNonMemberChangeRequest);
// Ignored change request from non-member with approval from owner/member.
flutterRepoPRs.add(prNonMemberChangeRequestWithMemberApprove);
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prNonMemberApprove.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prNonMemberChangeRequest.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: mergePullRequestMutation,
variables: <String, dynamic>{
'id': prNonMemberChangeRequestWithMemberApprove.id,
'oid': oid,
}),
],
);
});
test('Remove labels', () async {
final PullRequestHelper prOneBadReview = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
changePleaseChange,
],
);
final PullRequestHelper prOneGoodOneBadReview = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[
memberApprove,
changePleaseChange,
],
);
final PullRequestHelper prNoReviews = PullRequestHelper(
reviews: const <PullRequestReviewHelper>[],
);
final PullRequestHelper prEverythingWrong = PullRequestHelper(
lastCommitStatuses: const <StatusHelper>[
StatusHelper.flutterBuildFailure
],
reviews: const <PullRequestReviewHelper>[changePleaseChange],
);
flutterRepoPRs.add(prOneBadReview);
flutterRepoPRs.add(prOneGoodOneBadReview);
flutterRepoPRs.add(prNoReviews);
flutterRepoPRs.add(prEverythingWrong);
await tester.get(handler);
_verifyQueries();
githubGraphQLClient.verifyMutations(
<MutationOptions>[
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prOneBadReview.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- This pull request has changes requested by @change_please. Please resolve those before re-applying the label.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prOneGoodOneBadReview.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- This pull request has changes requested by @change_please. Please resolve those before re-applying the label.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prNoReviews.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- Please get at least one approved review before re-applying this label. __Reviewers__: If you left a comment approving, please use the "approve" review action instead.
''',
'labelId': base64LabelId,
},
),
MutationOptions(
document: removeLabelMutation,
variables: <String, dynamic>{
'id': prEverythingWrong.id,
'sBody':
'''This pull request is not suitable for automatic merging in its current state.
- This pull request has changes requested by @change_please. Please resolve those before re-applying the label.
''',
'labelId': base64LabelId,
},
),
],
);
});
});
}
enum ReviewState {
APPROVED,
CHANGES_REQUESTED,
}
enum MemberType {
OWNER,
MEMBER,
OTHER,
}
@immutable
class PullRequestReviewHelper {
const PullRequestReviewHelper({
@required this.authorName,
@required this.state,
@required this.memberType,
});
final String authorName;
final ReviewState state;
final MemberType memberType;
}
@immutable
class StatusHelper {
const StatusHelper(this.name, this.state);
static const StatusHelper cirrusSuccess =
StatusHelper('Cirrus CI', 'SUCCESS');
static const StatusHelper cirrusFailure =
StatusHelper('Cirrus CI', 'FAILURE');
static const StatusHelper flutterBuildSuccess =
StatusHelper('flutter-build', 'SUCCESS');
static const StatusHelper flutterBuildFailure =
StatusHelper('flutter-build', 'FAILURE');
static const StatusHelper otherStatusFailure =
StatusHelper('other status', 'FAILURE');
static const StatusHelper luciEngineBuildSuccess =
StatusHelper('luci-engine', 'SUCCESS');
static const StatusHelper luciEngineBuildFailure =
StatusHelper('luci-engine', 'FAILURE');
final String name;
final String state;
}
@immutable
class PullRequestHelper {
PullRequestHelper({
this.author = 'some_rando',
this.reviews = const <PullRequestReviewHelper>[
PullRequestReviewHelper(
authorName: 'member',
state: ReviewState.APPROVED,
memberType: MemberType.MEMBER)
],
this.lastCommitHash = oid,
this.lastCommitStatuses = const <StatusHelper>[
StatusHelper.flutterBuildSuccess
],
this.lastCommitCheckRuns = const <StatusHelper>[StatusHelper.cirrusSuccess],
this.dateTime,
}) : _count = _counter++;
static int _counter = 0;
final int _count;
String get id => _count.toString();
final String author;
final List<PullRequestReviewHelper> reviews;
final String lastCommitHash;
final List<StatusHelper> lastCommitStatuses;
final List<StatusHelper> lastCommitCheckRuns;
final DateTime dateTime;
Map<String, dynamic> toEntry() {
return <String, dynamic>{
'author': <String, dynamic>{'login': author},
'id': id,
'number': _count,
'reviews': <String, dynamic>{
'nodes': reviews.map((PullRequestReviewHelper review) {
return <String, dynamic>{
'author': <String, dynamic>{'login': review.authorName},
'authorAssociation':
review.memberType.toString().replaceFirst('MemberType.', ''),
'state': review.state.toString().replaceFirst('ReviewState.', ''),
};
}).toList(),
},
'commits': <String, dynamic>{
'nodes': <dynamic>[
<String, dynamic>{
'commit': <String, dynamic>{
'oid': lastCommitHash,
'pushedDate':
(dateTime ?? DateTime.now().add(const Duration(hours: -2)))
.toUtc()
.toIso8601String(),
'status': <String, dynamic>{
'contexts': lastCommitStatuses.map((StatusHelper status) {
return <String, dynamic>{
'context': status.name,
'state': status.state,
};
}).toList(),
},
},
},
],
},
};
}
}
QueryResult createQueryResult(List<PullRequestHelper> pullRequests) {
assert(pullRequests != null);
return QueryResult(
data: <String, dynamic>{
'repository': <String, dynamic>{
'labels': <String, dynamic>{
'nodes': <dynamic>[
<String, dynamic>{
'id': base64LabelId,
'pullRequests': <String, dynamic>{
'nodes': pullRequests
.map<Map<String, dynamic>>(
(PullRequestHelper pullRequest) => pullRequest.toEntry(),
)
.toList(),
},
},
],
},
},
},
);
}
QueryResult createCirrusQueryResult(List<dynamic> statuses, String branch) {
assert(statuses != null);
if (statuses.isEmpty) {
return QueryResult();
}
return QueryResult(data: <String, dynamic>{
'searchBuilds': <dynamic>[
<String, dynamic>{
'id': '1',
'branch': branch,
'latestGroupTasks':
statuses.map<Map<String, dynamic>>((dynamic status) {
return <String, dynamic>{
'id': status['id'],
'name': status['name'],
'status': status['status'],
};
}).toList(),
}
]
});
}
const PullRequestReviewHelper ownerApprove = PullRequestReviewHelper(
authorName: 'owner',
memberType: MemberType.OWNER,
state: ReviewState.APPROVED,
);
const PullRequestReviewHelper changePleaseChange = PullRequestReviewHelper(
authorName: 'change_please',
memberType: MemberType.MEMBER,
state: ReviewState.CHANGES_REQUESTED,
);
const PullRequestReviewHelper changePleaseApprove = PullRequestReviewHelper(
authorName: 'change_please',
memberType: MemberType.MEMBER,
state: ReviewState.APPROVED,
);
const PullRequestReviewHelper memberApprove = PullRequestReviewHelper(
authorName: 'member',
memberType: MemberType.MEMBER,
state: ReviewState.APPROVED,
);
const PullRequestReviewHelper nonMemberApprove = PullRequestReviewHelper(
authorName: 'random_person',
memberType: MemberType.OTHER,
state: ReviewState.APPROVED,
);
const PullRequestReviewHelper nonMemberChangeRequest = PullRequestReviewHelper(
authorName: 'random_person',
memberType: MemberType.OTHER,
state: ReviewState.CHANGES_REQUESTED,
);