| // Copyright 2020 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 'dart:async'; | |
| import 'dart:convert'; | |
| import 'package:cocoon_server/logging.dart'; | |
| import 'package:github/github.dart'; | |
| import 'package:googleapis/bigquery/v2.dart'; | |
| import 'package:retry/retry.dart'; | |
| import 'package:yaml/yaml.dart'; | |
| import '../../cocoon_service.dart'; | |
| import '../../protos.dart' as pb; | |
| import '../foundation/typedefs.dart'; | |
| import '../model/ci_yaml/ci_yaml.dart'; | |
| import '../model/ci_yaml/target.dart'; | |
| import '../request_handlers/flaky_handler_utils.dart'; | |
| import '../request_handling/exceptions.dart'; | |
| import '../service/get_files_changed.dart'; | |
| const String kCiYamlPath = '.ci.yaml'; | |
| const String kCiYamlFusionEnginePath = 'engine/src/flutter/$kCiYamlPath'; | |
| const String kTestOwnerPath = 'TESTOWNERS'; | |
| /// Attempts to parse the github merge queue branch into its constituent parts to be returned as a record. | |
| ({bool parsed, String branch, int pullRequestNumber}) | |
| tryParseGitHubMergeQueueBranch(String branch) { | |
| final match = _githubMqBranch.firstMatch(branch); | |
| if (match == null) { | |
| return notGitHubMergeQueueBranch; | |
| } | |
| return ( | |
| parsed: true, | |
| branch: match.group(1)!, | |
| pullRequestNumber: int.parse(match.group(2)!), | |
| ); | |
| } | |
| const notGitHubMergeQueueBranch = ( | |
| parsed: false, | |
| branch: '', | |
| pullRequestNumber: -1, | |
| ); | |
| final _githubMqBranch = RegExp( | |
| r'^gh-readonly-queue\/([^/]+)\/pr-(\d+)-([a-fA-F0-9]+)$', | |
| ); | |
| const _githubTimeout = Duration(seconds: 5); | |
| const _githubRetryOptions = RetryOptions( | |
| maxAttempts: 3, | |
| delayFactor: Duration(seconds: 3), | |
| ); | |
| /// Get content of [filePath] from GitHub CDN. | |
| Future<String> githubFileContent( | |
| RepositorySlug slug, | |
| String filePath, { | |
| required HttpClientProvider httpClientProvider, | |
| required String ref, | |
| Duration timeout = _githubTimeout, | |
| RetryOptions retryOptions = _githubRetryOptions, | |
| bool useGitOnBorgFallback = true, | |
| }) async { | |
| final githubUrl = Uri.https( | |
| 'raw.githubusercontent.com', | |
| '${slug.fullName}/$ref/$filePath', | |
| ); | |
| // git-on-borg has a different path for shas and refs to github | |
| final gobRef = (ref.length < 40) ? 'refs/heads/$ref' : ref; | |
| final gobUrl = Uri.https( | |
| 'flutter.googlesource.com', | |
| 'mirrors/${slug.name}/+/$gobRef/$filePath', | |
| <String, String>{'format': 'text'}, | |
| ); | |
| late final String content; | |
| try { | |
| await retryOptions.retry( | |
| () async => content = await _getUrl( | |
| githubUrl, | |
| httpClientProvider, | |
| timeout: timeout, | |
| ), | |
| retryIf: (Exception e) => e is HttpException || e is NotFoundException, | |
| ); | |
| } on Exception catch (e) { | |
| // A logical error (i.e. something like an ArgumentError) should not be | |
| // used as a retry signal. For example, 'TestFailure' (package:test) is an | |
| // exception but is not recoverable. | |
| if (e is! HttpException && e is! NotFoundException) { | |
| rethrow; | |
| } | |
| log.warn('Failed to fetch $githubUrl', e); | |
| if (useGitOnBorgFallback) { | |
| log.info('Falling back to $gobUrl'); | |
| await retryOptions.retry(() async { | |
| content = String.fromCharCodes( | |
| base64Decode( | |
| await _getUrl(gobUrl, httpClientProvider, timeout: timeout), | |
| ), | |
| ); | |
| }, retryIf: (e) => e is HttpException); | |
| } else { | |
| rethrow; | |
| } | |
| } | |
| return content; | |
| } | |
| /// Return [String] of response from [url] if status is [HttpStatus.ok]. | |
| /// | |
| /// If [url] returns [HttpStatus.notFound] throw [NotFoundException]. | |
| /// Otherwise, throws [HttpException]. | |
| FutureOr<String> _getUrl( | |
| Uri url, | |
| HttpClientProvider httpClientProvider, { | |
| Duration timeout = const Duration(seconds: 5), | |
| }) async { | |
| log.info('Making HTTP GET request for $url'); | |
| final client = httpClientProvider(); | |
| try { | |
| final response = await client.get(url).timeout(timeout); | |
| if (response.statusCode == HttpStatus.ok) { | |
| return response.body; | |
| } else if (response.statusCode == HttpStatus.notFound) { | |
| throw NotFoundException('HTTP ${response.statusCode}: $url'); | |
| } else { | |
| log.warn('HTTP ${response.statusCode}: $url'); | |
| throw HttpException('HTTP ${response.statusCode}: $url'); | |
| } | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| /// Returns a LUCI [builder] list that covers changed [files]. | |
| /// | |
| /// [builders]: enabled luci builders. | |
| /// [files]: changed files in corresponding PRs. | |
| /// | |
| /// [builder] format with run_if: | |
| /// { | |
| /// "name":"yyy", | |
| /// "repo":"flutter", | |
| /// "taskName":"zzz", | |
| /// "enabled":true, | |
| /// "run_if":["a/b/", "c/d_e/**", "f", "g*h/"] | |
| /// } | |
| /// [builder] format with run_if_not: | |
| /// { | |
| /// "name":"yyy", | |
| /// "repo":"flutter", | |
| /// "taskName":"zzz", | |
| /// "enabled":true, | |
| /// "run_if_not":["a/b/", "c/d_e/**", "f", "g*h/"] | |
| /// } | |
| /// Note: if both [run_if] and [run_if_not] are provided and not empty only | |
| /// [run_if] is evaluated. | |
| /// | |
| /// [file] is based on repo root: `a/b/c.dart`. | |
| Future<List<Target>> getTargetsToRun( | |
| Iterable<Target> targets, | |
| FilesChanged filesChanged, | |
| ) async { | |
| log.info('Getting targets to run from diff.'); | |
| // If we were not able to determine what files were changed run all targets. | |
| switch (filesChanged) { | |
| case InconclusiveFilesChanged(:final pullRequestNumber, :final reason): | |
| log.info('Running all targets on PR#$pullRequestNumber: $reason'); | |
| return [...targets]; | |
| case SuccessfulFilesChanged(:final pullRequestNumber, :final filesChanged): | |
| final targetsToRun = <Target>[]; | |
| for (final target in targets) { | |
| final globs = target.runIf; | |
| // Handle case where [Target] initializes empty runif | |
| if (globs.isEmpty) { | |
| targetsToRun.add(target); | |
| } else { | |
| for (final glob in globs) { | |
| // If a file is found within a pre-set dir, the builder needs to run. No need to check further. | |
| final regExp = _convertGlobToRegExp(glob); | |
| if (glob.isEmpty || filesChanged.any(regExp.hasMatch)) { | |
| targetsToRun.add(target); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| log.info( | |
| 'Running a subset of targets on PR#$pullRequestNumber: ${targetsToRun.map((t) => t.name).join(', ')}', | |
| ); | |
| return targetsToRun; | |
| } | |
| } | |
| /// Expands globs string to a regex for evaluation. | |
| RegExp _convertGlobToRegExp(String glob) { | |
| glob = glob.replaceAll('**', '[A-Za-z0-9_/.]+'); | |
| glob = glob.replaceAll('*', '[A-Za-z0-9_.]+'); | |
| return RegExp('^$glob\$'); | |
| } | |
| Future<void> insertBigQuery( | |
| String tableName, | |
| Map<String, dynamic> data, | |
| TabledataResource tabledataResourceApi, | |
| ) async { | |
| // Define const variables for [BigQuery] operations. | |
| const projectId = 'flutter-dashboard'; | |
| const dataset = 'cocoon'; | |
| final table = tableName; | |
| final requestRows = <Map<String, Object>>[]; | |
| requestRows.add(<String, Object>{'json': data}); | |
| // Obtain [rows] to be inserted to [BigQuery]. | |
| final request = TableDataInsertAllRequest.fromJson(<String, dynamic>{ | |
| 'rows': requestRows, | |
| }); | |
| try { | |
| await tabledataResourceApi.insertAll(request, projectId, dataset, table); | |
| } on ApiRequestError catch (e) { | |
| log.warn('Failed to add to BigQuery', e); | |
| } | |
| } | |
| /// Validate test ownership defined in [testOwnersContent] for tests configured in `ciYamlContent`. | |
| List<String> validateOwnership( | |
| String ciYamlContent, | |
| String testOwnersContent, { | |
| bool unfilteredTargets = false, | |
| }) { | |
| final noOwnerBuilders = <String>[]; | |
| final ciYaml = loadYaml(ciYamlContent) as YamlMap?; | |
| final unCheckedSchedulerConfig = pb.SchedulerConfig() | |
| ..mergeFromProto3Json(ciYaml); | |
| final ciYamlFromProto = CiYamlSet( | |
| slug: Config.flutterSlug, | |
| branch: Config.defaultBranch(Config.flutterSlug), | |
| yamls: {CiType.any: unCheckedSchedulerConfig}, | |
| ); | |
| final schedulerConfig = ciYamlFromProto.configFor(CiType.any); | |
| for (var target in schedulerConfig.targets) { | |
| final builder = target.name; | |
| final builderType = getTypeForBuilder( | |
| builder, | |
| ciYamlFromProto, | |
| unfilteredTargets: unfilteredTargets, | |
| ); | |
| final owner = getTestOwnership( | |
| target, | |
| builderType, | |
| testOwnersContent, | |
| ).owner; | |
| if (owner == null) { | |
| print( | |
| '$builder: No owner found. This task was classified as ${builderType.name}.\n' | |
| 'If that was not what you expected, check the "tags" property provided to the ' | |
| 'target.', | |
| ); | |
| noOwnerBuilders.add(builder); | |
| } | |
| } | |
| return noOwnerBuilders; | |
| } | |
| /// Utility to class to wrap related objects in. | |
| class Tuple<S, T, U> { | |
| const Tuple(this.first, this.second, this.third); | |
| final S first; | |
| final T second; | |
| final U third; | |
| } |