// Copyright 2014 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:args/args.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:platform/platform.dart'; import 'proto/conductor_state.pb.dart' as pb; const String kUpstreamRemote = 'https://github.com/flutter/flutter.git'; const String gsutilBinary = 'gsutil.py'; const List<String> kReleaseChannels = <String>[ 'stable', 'beta', 'dev', 'master', ]; const String kReleaseDocumentationUrl = 'https://github.com/flutter/flutter/wiki/Flutter-Cherrypick-Process'; const String kLuciPackagingConsoleLink = 'https://ci.chromium.org/p/flutter/g/packaging/console'; final RegExp releaseCandidateBranchRegex = RegExp( r'flutter-(\d+)\.(\d+)-candidate\.(\d+)', ); /// Cast a dynamic to String and trim. String stdoutToString(dynamic input) { final String str = input as String; return str.trim(); } class ConductorException implements Exception { ConductorException(this.message); final String message; @override String toString() => 'Exception: $message'; } Directory? _flutterRoot; Directory get localFlutterRoot { if (_flutterRoot != null) { return _flutterRoot!; } String filePath; const FileSystem fileSystem = LocalFileSystem(); const Platform platform = LocalPlatform(); filePath = platform.script.toFilePath(); final String checkoutsDirname = fileSystem.path.normalize( fileSystem.path.join( fileSystem.path.dirname(filePath), '..', // flutter/dev/tools '..', // flutter/dev '..', // flutter ), ); _flutterRoot = fileSystem.directory(checkoutsDirname); return _flutterRoot!; } bool assertsEnabled() { // Verify asserts enabled bool assertsEnabled = false; assert(() { assertsEnabled = true; return true; }()); return assertsEnabled; } /// Either return the value from [env] or fall back to [argResults]. /// /// If the key does not exist in either the environment or CLI args, throws a /// [ConductorException]. /// /// The environment is favored over CLI args since the latter can have a default /// value, which the environment should be able to override. String? getValueFromEnvOrArgs( String name, ArgResults argResults, Map<String, String> env, { bool allowNull = false, }) { final String envName = fromArgToEnvName(name); if (env[envName] != null) { return env[envName]; } final String? argValue = argResults[name] as String?; if (argValue != null) { return argValue; } if (allowNull) { return null; } throw ConductorException( 'Expected either the CLI arg --$name or the environment variable $envName ' 'to be provided!'); } /// Return multiple values from the environment or fall back to [argResults]. /// /// Values read from an environment variable are assumed to be comma-delimited. /// /// If the key does not exist in either the CLI args or environment, throws a /// [ConductorException]. /// /// The environment is favored over CLI args since the latter can have a default /// value, which the environment should be able to override. List<String> getValuesFromEnvOrArgs( String name, ArgResults argResults, Map<String, String> env, ) { final String envName = fromArgToEnvName(name); if (env[envName] != null && env[envName] != '') { return env[envName]!.split(','); } final List<String> argValues = argResults[name] as List<String>; if (argValues != null) { return argValues; } throw ConductorException( 'Expected either the CLI arg --$name or the environment variable $envName ' 'to be provided!'); } /// Translate CLI arg names to env variable names. /// /// For example, 'state-file' -> 'STATE_FILE'. String fromArgToEnvName(String argName) { return argName.toUpperCase().replaceAll(r'-', r'_'); } /// Return a web link for the user to open a new PR. /// /// Includes PR title and body via query params. String getNewPrLink({ required String userName, required String repoName, required pb.ConductorState state, }) { assert(state.releaseChannel.isNotEmpty); assert(state.releaseVersion.isNotEmpty); late final String candidateBranch; late final String workingBranch; late final String repoLabel; switch (repoName) { case 'flutter': candidateBranch = state.framework.candidateBranch; workingBranch = state.framework.workingBranch; repoLabel = 'Framework'; break; case 'engine': candidateBranch = state.engine.candidateBranch; workingBranch = state.engine.workingBranch; repoLabel = 'Engine'; break; default: throw ConductorException('Expected repoName to be one of flutter or engine but got $repoName.'); } assert(candidateBranch.isNotEmpty); assert(workingBranch.isNotEmpty); final String title = '[flutter_releases] Flutter ${state.releaseChannel} ' '${state.releaseVersion} $repoLabel Cherrypicks'; final StringBuffer body = StringBuffer(); body.write(''' # Flutter ${state.releaseChannel} ${state.releaseVersion} $repoLabel ## Scheduled Cherrypicks '''); if (repoName == 'engine') { if (state.engine.dartRevision.isNotEmpty) { // shorten hashes to make final link manageable // prefix with github org/repo so GitHub will auto-generate a hyperlink body.writeln('- Roll dart revision: dart-lang/sdk@${state.engine.dartRevision.substring(0, 9)}'); } for (final pb.Cherrypick cp in state.engine.cherrypicks) { // Only list commits that map to a commit that exists upstream. if (cp.trunkRevision.isNotEmpty) { body.writeln('- commit: flutter/engine@${cp.trunkRevision.substring(0, 9)}'); } } } else { for (final pb.Cherrypick cp in state.framework.cherrypicks) { // Only list commits that map to a commit that exists upstream. if (cp.trunkRevision.isNotEmpty) { body.writeln('- commit: ${cp.trunkRevision.substring(0, 9)}'); } } } return 'https://github.com/flutter/$repoName/compare/' '$candidateBranch...$userName:$workingBranch?' 'expand=1' '&title=${Uri.encodeQueryComponent(title)}' '&body=${Uri.encodeQueryComponent(body.toString())}'; }