blob: 2c4225a621037210c9b3b913d4d76205d76d034a [file] [log] [blame] [edit]
// Copyright 2022 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:convert';
import 'dart:typed_data';
import 'package:auto_submit/configuration/repository_configuration.dart';
import 'package:auto_submit/configuration/repository_configuration_manager.dart';
import 'package:cocoon_server/access_client_provider.dart';
import 'package:cocoon_server/bigquery.dart';
import 'package:cocoon_server/logging.dart';
import 'package:corsac_jwt/corsac_jwt.dart';
import 'package:github/github.dart';
import 'package:googleapis/bigquery/v2.dart';
import 'package:graphql/client.dart';
import 'package:http/http.dart' as http;
import 'package:neat_cache/cache_provider.dart';
import 'package:neat_cache/neat_cache.dart';
import 'package:retry/retry.dart';
import '../foundation/providers.dart';
import '../service/secrets.dart';
import 'github_service.dart';
class CocoonGitHubRequestException implements Exception {
const CocoonGitHubRequestException(this.message, {required this.code, required this.uri});
final String message;
final int code;
final Uri uri;
@override
String toString() {
return 'CocoonGitHubRequestException: Request to "$uri" (response code $code):\n$message';
}
}
/// Configuration for the autosubmit engine.
class Config {
Config({
required this.cacheProvider,
this.httpProvider = Providers.freshHttpClient,
required this.secretManager,
}) {
repositoryConfigurationManager = RepositoryConfigurationManager(this, cache);
}
late RepositoryConfigurationManager repositoryConfigurationManager;
/// Project/GCP constants
static const String flutter = 'flutter';
static const String flutterGcpProjectId = 'flutter-dashboard';
// List of environment variable keys related to the Github app authentication.
static const String kGithubKey = 'AUTO_SUBMIT_GITHUB_KEY';
static const String kGithubAppId = 'AUTO_SUBMIT_GITHUB_APP_ID';
static const String kWebHookKey = 'AUTO_SUBMIT_WEBHOOK_TOKEN';
static const String kFlutterGitHubBotKey = 'AUTO_SUBMIT_FLUTTER_GITHUB_TOKEN';
static const String kTreeStatusDiscordUrl = 'TREE_STATUS_DISCORD_WEBHOOK_URL';
/// Labels autosubmit looks for on pull requests
///
/// Keep this in sync with the similar `Config` class in `app_dart`.
static const String kAutosubmitLabel = 'autosubmit';
/// GitHub check stale threshold.
static const int kGitHubCheckStaleThreshold = 2; // hours
// Labels the bot looks for on revert requests.
// TODO (ricardoamador) https://github.com/flutter/flutter/issues/134845:
// add a link to a one page doc outlining the workflow that happens here.
/// The `revert` label is used by developers to initiate the revert request.
/// This signals to the service that it should revert the changes in this pull
/// request.
static const String kRevertLabel = 'revert';
/// The `revert of` label is used exclusively by the bot. The user does not
/// add this. When the bot successfully pushes the revert request to Github
/// it adds this label to signify that it should then validate and merge this
/// as a revert.
static const String kRevertOfLabel = 'revert of';
/// The label which shows the overrideTree Status.
String get overrideTreeStatusLabel => 'warning: land on red to fix tree breakage';
/// Repository Slug data
/// GitHub repositories that use CI status to determine if pull requests can be submitted.
static Set<RepositorySlug> reposWithTreeStatus = <RepositorySlug>{
engineSlug,
flutterSlug,
};
static RepositorySlug get engineSlug => RepositorySlug('flutter', 'engine');
static RepositorySlug get flutterSlug => RepositorySlug('flutter', 'flutter');
String get autosubmitBot => 'auto-submit[bot]';
/// The names of autoroller accounts for the repositories.
///
/// These accounts should not need reviews before merging. See
/// https://github.com/flutter/flutter/blob/master/docs/infra/Autorollers.md
Set<String> get rollerAccounts => const <String>{
'skia-flutter-autoroll',
'engine-flutter-autoroll',
// REST API returns dependabot[bot] as author while GraphQL returns dependabot. We need
// both as we use graphQL to merge the PR and REST API to approve the PR.
'dependabot[bot]',
'dependabot',
'DartDevtoolWorkflowBot',
};
/// Repository configuration variables
Duration get repositoryConfigurationTtl => const Duration(minutes: 10);
/// PubSub configs
int get kPullMesssageBatchSize => 100;
/// Number of Pub/Sub pull calls in each cron job run.
///
/// TODO(keyonghan): monitor and optimize this number based on response time
/// https://github.com/flutter/cocoon/pull/2035/files#r938143840.
int get kPubsubPullNumber => 5;
static String get pubsubTopicsPrefix => 'projects/$flutterGcpProjectId/topics';
static String get pubsubSubscriptionsPrefix => 'projects/$flutterGcpProjectId/subscriptions';
String get pubsubPullRequestTopic => 'auto-submit-queue';
String get pubsubPullRequestSubscription => 'auto-submit-queue-sub';
String get pubsubRevertRequestTopic => 'auto-submit-revert-queue';
String get pubsubRevertRequestSubscription => 'auto-submit-revert-queue-sub';
/// Retry options for timing related retryable code.
static const RetryOptions mergeRetryOptions = RetryOptions(
delayFactor: Duration(milliseconds: 200),
maxDelay: Duration(seconds: 1),
maxAttempts: 5,
);
static const RetryOptions requiredChecksRetryOptions = RetryOptions(
delayFactor: Duration(milliseconds: 500),
maxDelay: Duration(seconds: 5),
maxAttempts: 5,
);
/// Pull request approval message
static const String pullRequestApprovalRequirementsMessage =
'- Merge guidelines: A PR needs at least one approved review if the author is already '
'part of flutter-hackers or two member reviews if the author is not a flutter-hacker '
'before re-applying the autosubmit label. __Reviewers__: If you left a comment '
'approving, please use the "approve" review action instead.';
/// Config object members
final CacheProvider cacheProvider;
final HttpProvider httpProvider;
final SecretManager secretManager;
Cache get cache => Cache<dynamic>(cacheProvider).withPrefix('config');
Future<RepositoryConfiguration> getRepositoryConfiguration(RepositorySlug slug) async {
return repositoryConfigurationManager.readRepositoryConfiguration(slug);
}
Future<GithubService> createGithubService(RepositorySlug slug) async {
final GitHub github = await createGithubClient(slug);
return GithubService(github);
}
Future<GitHub> createGithubClient(RepositorySlug slug) async {
final String token = await generateGithubToken(slug);
return GitHub(auth: Authentication.withToken(token));
}
Future<GitHub> createFlutterGitHubBotClient(RepositorySlug slug) async {
final String token = await getFlutterGitHubBotToken();
return GitHub(auth: Authentication.withToken(token));
}
Future<String> generateGithubToken(RepositorySlug slug) async {
// GitHub's secondary rate limits are run into very frequently when making auth tokens.
final Uint8List? cacheValue = await cache['githubToken-${slug.owner}'].get(
() => _generateGithubToken(slug),
// Tokens have a TTL of 10 minutes. AppEngine requests have a TTL of 1 minute.
// To ensure no expired tokens are used, set this to 10 - 1, with an extra buffer of a duplicate request.
const Duration(minutes: 8),
) as Uint8List?;
return String.fromCharCodes(cacheValue!);
}
Future<String> getInstallationId(RepositorySlug slug) async {
final String jwt = await _generateGithubJwt();
final Map<String, String> headers = <String, String>{
'Authorization': 'Bearer $jwt',
'Accept': 'application/vnd.github.machine-man-preview+json',
};
// TODO(KristinBi): Upstream the github package.https://github.com/flutter/flutter/issues/100920
final Uri githubInstallationUri = Uri.https('api.github.com', 'users/${slug.owner}/installation');
final http.Client client = httpProvider();
// TODO(KristinBi): Track the installation id by repo. https://github.com/flutter/flutter/issues/100808
final http.Response response = await client.get(
githubInstallationUri,
headers: headers,
);
final Map<String, dynamic> installData = json.decode(response.body) as Map<String, dynamic>;
final String? installationId = installData['id']?.toString();
if (installationId == null) {
log.warning('Failed to get ID from Github '
'(response code ${response.statusCode}):\n${response.body}');
throw CocoonGitHubRequestException(
'getInstallationId failed to get ID from Github',
code: response.statusCode,
uri: githubInstallationUri,
);
}
return installationId;
}
Future<GraphQLClient> createGitHubGraphQLClient(RepositorySlug slug) async {
final HttpLink httpLink = HttpLink(
'https://api.github.com/graphql',
defaultHeaders: <String, String>{
'Accept': 'application/vnd.github.antiope-preview+json',
},
);
final String token = await generateGithubToken(slug);
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer $token',
);
return GraphQLClient(
cache: GraphQLCache(),
link: authLink.concat(httpLink),
);
}
Future<BigqueryService> createBigQueryService() async {
final AccessClientProvider accessClientProvider = AccessClientProvider();
return BigqueryService(accessClientProvider);
}
Future<TabledataResource> createTabledataResourceApi() async {
return (await createBigQueryService()).defaultTabledata();
}
Future<Uint8List> _generateGithubToken(RepositorySlug slug) async {
log.info('Generating new GitHub token');
final String jwt = await _generateGithubJwt();
final Map<String, String> headers = <String, String>{
'Authorization': 'Bearer $jwt',
'Accept': 'application/vnd.github.machine-man-preview+json',
};
final String installationId = await getInstallationId(slug);
final Uri githubAccessTokensUri = Uri.https('api.github.com', 'app/installations/$installationId/access_tokens');
final http.Client client = httpProvider();
final http.Response response = await client.post(
githubAccessTokensUri,
headers: headers,
);
final Map<String, dynamic> jsonBody = jsonDecode(response.body) as Map<String, dynamic>;
final String? token = jsonBody['token'] as String?;
if (token == null) {
log.warning('Failed to get token from Github '
'(response code ${response.statusCode}):\n${response.body}');
throw CocoonGitHubRequestException(
'generateGithubToken failed to get token from Github',
code: response.statusCode,
uri: githubAccessTokensUri,
);
}
log.info('Successfully generated new GitHub token');
return Uint8List.fromList(token.codeUnits);
}
Future<String> _generateGithubJwt() async {
final String rawKey = await secretManager.get(kGithubKey);
final StringBuffer sb = StringBuffer();
sb.writeln(rawKey.substring(0, 32));
sb.writeln(rawKey.substring(32, rawKey.length - 30).replaceAll(' ', ' \n'));
sb.writeln(rawKey.substring(rawKey.length - 30, rawKey.length));
final String privateKey = sb.toString();
final JWTBuilder builder = JWTBuilder();
final DateTime now = DateTime.now();
builder
..issuer = await secretManager.get(kGithubAppId)
..issuedAt = now
..expiresAt = now.add(const Duration(minutes: 10));
final JWTRsaSha256Signer signer = JWTRsaSha256Signer(privateKey: privateKey);
final JWT signedToken = builder.getSignedToken(signer);
return signedToken.toString();
}
/// Get the webhook key
Future<String> getWebhookKey() async {
final Uint8List? cacheValue = await cache[kWebHookKey].get(
() => _getValueFromSecretManager(kWebHookKey),
) as Uint8List?;
return String.fromCharCodes(cacheValue!);
}
Future<String> getFlutterGitHubBotToken() async {
final Uint8List? cacheValue = await cache[kFlutterGitHubBotKey].get(
() => _getValueFromSecretManager(kFlutterGitHubBotKey),
) as Uint8List?;
return String.fromCharCodes(cacheValue!);
}
Future<String> getTreeStatusDiscordUrl() async {
final Uint8List? cacheValue = await cache[kTreeStatusDiscordUrl].get(
() => _getValueFromSecretManager(kTreeStatusDiscordUrl),
) as Uint8List?;
return String.fromCharCodes(cacheValue!);
}
Future<Uint8List> _getValueFromSecretManager(String key) async {
final String value = await secretManager.get(key);
return Uint8List.fromList(value.codeUnits);
}
}