blob: 222d2ecae95ac4f3ae73311a471982ca757b0ef6 [file] [log] [blame]
// 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 'dart:io';
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;
}