From a30012b27505622633429b4fa001c8631da5e048 Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Wed, 22 Jun 2022 17:04:07 -0700 Subject: [PATCH] Add update packages roller (#100982) --- dev/bots/analyze.dart | 1 + dev/conductor/bin/conductor | 2 +- dev/conductor/bin/roll-packages | 46 ++++ .../core/bin/packages_autoroller.dart | 128 +++++++++++ .../core/lib/packages_autoroller.dart | 5 + .../core/lib/src/packages_autoroller.dart | 208 ++++++++++++++++++ dev/conductor/core/lib/src/repository.dart | 95 +++++++- dev/conductor/core/test/common.dart | 1 - dev/conductor/core/test/next_test.dart | 20 +- .../core/test/packages_autoroller_test.dart | 193 ++++++++++++++++ dev/conductor/core/test/repository_test.dart | 75 ++++++- dev/conductor/core/test/start_test.dart | 10 +- 12 files changed, 760 insertions(+), 24 deletions(-) create mode 100755 dev/conductor/bin/roll-packages create mode 100644 dev/conductor/core/bin/packages_autoroller.dart create mode 100644 dev/conductor/core/lib/packages_autoroller.dart create mode 100644 dev/conductor/core/lib/src/packages_autoroller.dart create mode 100644 dev/conductor/core/test/packages_autoroller_test.dart diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart index d4c87a8af4a4..c14bfcbf3d52 100644 --- a/dev/bots/analyze.dart +++ b/dev/bots/analyze.dart @@ -1778,6 +1778,7 @@ const Set kExecutableAllowlist = { 'dev/bots/docs.sh', 'dev/conductor/bin/conductor', + 'dev/conductor/bin/roll-packages', 'dev/conductor/core/lib/src/proto/compile_proto.sh', 'dev/customer_testing/ci.sh', diff --git a/dev/conductor/bin/conductor b/dev/conductor/bin/conductor index 29cb05ef7438..3a5735a53e8a 100755 --- a/dev/conductor/bin/conductor +++ b/dev/conductor/bin/conductor @@ -40,4 +40,4 @@ DART_BIN="$REPO_DIR/bin/dart" # Ensure pub get has been run in the repo before running the conductor (cd "$REPO_DIR/dev/conductor/core"; "$DART_BIN" pub get 1>&2) -"$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/cli.dart" "$@" +exec "$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/cli.dart" "$@" diff --git a/dev/conductor/bin/roll-packages b/dev/conductor/bin/roll-packages new file mode 100755 index 000000000000..231d54453c3c --- /dev/null +++ b/dev/conductor/bin/roll-packages @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +# Needed because if it is set, cd may print the path it changed to. +unset CDPATH + +# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one +# link at a time, and then cds into the link destination and find out where it +# ends up. +# +# The returned filesystem path must be a format usable by Dart's URI parser, +# since the Dart command line tool treats its argument as a file URI, not a +# filename. For instance, multiple consecutive slashes should be reduced to a +# single slash, since double-slashes indicate a URI "authority", and these are +# supposed to be filenames. There is an edge case where this will return +# multiple slashes: when the input resolves to the root directory. However, if +# that were the case, we wouldn't be running this shell, so we don't do anything +# about it. +# +# The function is enclosed in a subshell to avoid changing the working directory +# of the caller. +function follow_links() ( + cd -P "$(dirname -- "$1")" + file="$PWD/$(basename -- "$1")" + while [[ -h "$file" ]]; do + cd -P "$(dirname -- "$file")" + file="$(readlink -- "$file")" + cd -P "$(dirname -- "$file")" + file="$PWD/$(basename -- "$file")" + done + echo "$file" +) + +PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")" +BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)" +REPO_DIR="$BIN_DIR/../../.." +DART_BIN="$REPO_DIR/bin/dart" + +# Ensure pub get has been run in the repo before running the conductor +(cd "$REPO_DIR/dev/conductor/core"; "$DART_BIN" pub get 1>&2) + +exec "$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/roll_packages.dart" "$@" diff --git a/dev/conductor/core/bin/packages_autoroller.dart b/dev/conductor/core/bin/packages_autoroller.dart new file mode 100644 index 000000000000..d69c846e32d7 --- /dev/null +++ b/dev/conductor/core/bin/packages_autoroller.dart @@ -0,0 +1,128 @@ +// 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 'dart:io' as io; + +import 'package:args/args.dart'; +import 'package:conductor_core/conductor_core.dart'; +import 'package:conductor_core/packages_autoroller.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; + +const String kTokenOption = 'token'; +const String kGithubClient = 'github-client'; +const String kMirrorRemote = 'mirror-remote'; +const String kUpstreamRemote = 'upstream-remote'; + +Future main(List args) async { + final ArgParser parser = ArgParser(); + parser.addOption( + kTokenOption, + help: 'GitHub access token env variable name.', + defaultsTo: 'GITHUB_TOKEN', + ); + parser.addOption( + kGithubClient, + help: 'Path to GitHub CLI client. If not provided, it is assumed `gh` is ' + 'present on the PATH.', + ); + parser.addOption( + kMirrorRemote, + help: 'The mirror git remote that the feature branch will be pushed to. ' + 'Required', + mandatory: true, + ); + parser.addOption( + kUpstreamRemote, + help: 'The upstream git remote that the feature branch will be merged to.', + hide: true, + defaultsTo: 'https://github.com/flutter/flutter.git', + ); + + final ArgResults results; + try { + results = parser.parse(args); + } on FormatException { + io.stdout.writeln(''' +Usage: + +${parser.usage} +'''); + rethrow; + } + + final String mirrorUrl = results[kMirrorRemote]! as String; + final String upstreamUrl = results[kUpstreamRemote]! as String; + const Platform platform = LocalPlatform(); + final String tokenName = results[kTokenOption]! as String; + final String? token = platform.environment[tokenName]; + if (token == null || token.isEmpty) { + throw FormatException( + 'Tried to read a GitHub access token from env variable \$$tokenName but it was undefined or empty', + ); + } + + final FrameworkRepository framework = FrameworkRepository( + _localCheckouts, + mirrorRemote: Remote.mirror(mirrorUrl), + upstreamRemote: Remote.upstream(upstreamUrl), + ); + + await PackageAutoroller( + framework: framework, + githubClient: results[kGithubClient] as String? ?? 'gh', + orgName: _parseOrgName(mirrorUrl), + token: token, + processManager: const LocalProcessManager(), + ).roll(); +} + +String _parseOrgName(String remoteUrl) { + final RegExp pattern = RegExp(r'^https:\/\/github\.com\/(.*)\/'); + final RegExpMatch? match = pattern.firstMatch(remoteUrl); + if (match == null) { + throw FormatException( + 'Malformed upstream URL "$remoteUrl", should start with "https://github.com/"', + ); + } + return match.group(1)!; +} + +Checkouts get _localCheckouts { + const FileSystem fileSystem = LocalFileSystem(); + const ProcessManager processManager = LocalProcessManager(); + const Platform platform = LocalPlatform(); + final Stdio stdio = VerboseStdio( + stdout: io.stdout, + stderr: io.stderr, + stdin: io.stdin, + ); + return Checkouts( + fileSystem: fileSystem, + parentDirectory: _localFlutterRoot.parent, + platform: platform, + processManager: processManager, + stdio: stdio, + ); +} + +Directory get _localFlutterRoot { + 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/conductor/core/bin + '..', // flutter/dev/conductor/core + '..', // flutter/dev/conductor + '..', // flutter/dev + '..', // flutter + ), + ); + return fileSystem.directory(checkoutsDirname); +} diff --git a/dev/conductor/core/lib/packages_autoroller.dart b/dev/conductor/core/lib/packages_autoroller.dart new file mode 100644 index 000000000000..d668027363d2 --- /dev/null +++ b/dev/conductor/core/lib/packages_autoroller.dart @@ -0,0 +1,5 @@ +// 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. + +export 'src/packages_autoroller.dart'; diff --git a/dev/conductor/core/lib/src/packages_autoroller.dart b/dev/conductor/core/lib/src/packages_autoroller.dart new file mode 100644 index 000000000000..e05d4941a0ea --- /dev/null +++ b/dev/conductor/core/lib/src/packages_autoroller.dart @@ -0,0 +1,208 @@ +// 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 'dart:convert'; +import 'dart:io' as io; + +import 'package:process/process.dart'; + +import 'git.dart'; +import 'globals.dart'; +import 'repository.dart'; + +/// A service for rolling the SDK's pub packages to latest and open a PR upstream. +class PackageAutoroller { + PackageAutoroller({ + required this.githubClient, + required this.token, + required this.framework, + required this.orgName, + required this.processManager, + }) { + if (token.trim().isEmpty) { + throw Exception('empty token!'); + } + if (githubClient.trim().isEmpty) { + throw Exception('Must provide path to GitHub client!'); + } + if (orgName.trim().isEmpty) { + throw Exception('Must provide an orgName!'); + } + } + + final FrameworkRepository framework; + final ProcessManager processManager; + + /// Path to GitHub CLI client. + final String githubClient; + + /// GitHub API access token. + final String token; + + static const String hostname = 'github.com'; + + static const String prBody = ''' +This PR was generated by `flutter update-packages --force-upgrade`. +'''; + + /// Name of the feature branch to be opened on against the mirror repo. + /// + /// We never re-use a previous branch, so the branch name ends in an index + /// number, which gets incremented for each roll. + late final Future featureBranchName = (() async { + final List remoteBranches = await framework.listRemoteBranches(framework.mirrorRemote!.name); + + int x = 1; + String name(int index) => 'packages-autoroller-branch-$index'; + + while (remoteBranches.contains(name(x))) { + x += 1; + } + + return name(x); + })(); + + /// Name of the GitHub organization to push the feature branch to. + final String orgName; + + Future roll() async { + await authLogin(); + await updatePackages(); + await pushBranch(); + await createPr( + repository: await framework.checkoutDirectory, + ); + await authLogout(); + } + + Future updatePackages({ + bool verbose = true, + String author = 'flutter-packages-autoroller ' + }) async { + await framework.newBranch(await featureBranchName); + final io.Process flutterProcess = await framework.streamFlutter([ + if (verbose) '--verbose', + 'update-packages', + '--force-upgrade', + ]); + final int exitCode = await flutterProcess.exitCode; + if (exitCode != 0) { + throw ConductorException('Failed to update packages with exit code $exitCode'); + } + await framework.commit( + 'roll packages', + addFirst: true, + author: author, + ); + } + + Future pushBranch() async { + await framework.pushRef( + fromRef: await featureBranchName, + toRef: await featureBranchName, + remote: framework.mirrorRemote!.url, + ); + } + + Future authLogout() { + return cli( + ['auth', 'logout', '--hostname', hostname], + allowFailure: true, + ); + } + + Future authLogin() { + return cli( + [ + 'auth', + 'login', + '--hostname', + hostname, + '--git-protocol', + 'https', + '--with-token', + ], + stdin: token, + ); + } + + /// Create a pull request on GitHub. + /// + /// Depends on the gh cli tool. + Future createPr({ + required io.Directory repository, + String title = 'Roll pub packages', + String body = 'This PR was generated by `flutter update-packages --force-upgrade`.', + String base = FrameworkRepository.defaultBranch, + bool draft = false, + }) async { + // We will wrap title and body in double quotes before delegating to gh + // binary + await cli( + [ + 'pr', + 'create', + '--title', + title.trim(), + '--body', + body.trim(), + '--head', + '$orgName:${await featureBranchName}', + '--base', + base, + if (draft) + '--draft', + ], + workingDirectory: repository.path, + ); + } + + Future help([List? args]) { + return cli([ + 'help', + ...?args, + ]); + } + + Future cli( + List args, { + bool allowFailure = false, + String? stdin, + String? workingDirectory, + }) async { + print('Executing "$githubClient ${args.join(' ')}" in $workingDirectory'); + final io.Process process = await processManager.start( + [githubClient, ...args], + workingDirectory: workingDirectory, + environment: {}, + ); + final List stderrStrings = []; + final List stdoutStrings = []; + final Future stdoutFuture = process.stdout + .transform(utf8.decoder) + .forEach(stdoutStrings.add); + final Future stderrFuture = process.stderr + .transform(utf8.decoder) + .forEach(stderrStrings.add); + if (stdin != null) { + process.stdin.write(stdin); + await process.stdin.flush(); + await process.stdin.close(); + } + final int exitCode = await process.exitCode; + await Future.wait(>[ + stdoutFuture, + stderrFuture, + ]); + final String stderr = stderrStrings.join(); + final String stdout = stdoutStrings.join(); + if (!allowFailure && exitCode != 0) { + throw GitException( + '$stderr\n$stdout', + args, + ); + } + print(stdout); + } +} diff --git a/dev/conductor/core/lib/src/repository.dart b/dev/conductor/core/lib/src/repository.dart index 71a382b2c35a..eff3fa1add4e 100644 --- a/dev/conductor/core/lib/src/repository.dart +++ b/dev/conductor/core/lib/src/repository.dart @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:convert' show jsonDecode; +import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; @@ -30,6 +31,20 @@ class Remote { assert(url != null), assert(url != ''); + factory Remote.mirror(String url) { + return Remote( + name: RemoteName.mirror, + url: url, + ); + } + + factory Remote.upstream(String url) { + return Remote( + name: RemoteName.upstream, + url: url, + ); + } + final RemoteName _name; /// The name of the remote. @@ -134,6 +149,37 @@ abstract class Repository { return _checkoutDirectory!; } + /// RegExp pattern to parse the output of git ls-remote. + /// + /// Git output looks like: + /// + /// 35185330c6af3a435f615ee8ac2fed8b8bb7d9d4 refs/heads/95159-squash + /// 6f60a1e7b2f3d2c2460c9dc20fe54d0e9654b131 refs/heads/add-debug-trace + /// c1436c42c0f3f98808ae767e390c3407787f1a67 refs/heads/add-recipe-field + /// 4d44dca340603e25d4918c6ef070821181202e69 refs/heads/add-release-channel + /// + /// We are interested in capturing what comes after 'refs/heads/'. + static final RegExp _lsRemotePattern = RegExp(r'.*\s+refs\/heads\/([^\s]+)$'); + + /// Parse git ls-remote --heads and return branch names. + Future> listRemoteBranches(String remote) async { + final String output = await git.getOutput( + ['ls-remote', '--heads', remote], + 'get remote branches', + workingDirectory: (await checkoutDirectory).path, + ); + + final List remoteBranches = []; + for (final String line in output.split('\n')) { + final RegExpMatch? match = _lsRemotePattern.firstMatch(line); + if (match != null) { + remoteBranches.add(match.group(1)!); + } + } + + return remoteBranches; + } + /// Ensure the repository is cloned to disk and initialized with proper state. Future lazilyInitialize(Directory checkoutDirectory) async { if (checkoutDirectory.existsSync()) { @@ -408,8 +454,8 @@ abstract class Repository { Future commit( String message, { bool addFirst = false, + String? author, }) async { - assert(!message.contains("'")); final bool hasChanges = (await git.getOutput( ['status', '--porcelain'], 'check for uncommitted changes', @@ -426,8 +472,28 @@ abstract class Repository { workingDirectory: (await checkoutDirectory).path, ); } + String? authorArg; + if (author != null) { + if (author.contains('"')) { + throw FormatException( + 'Commit author cannot contain character \'"\', received $author', + ); + } + // verify [author] matches git author convention, e.g. "Jane Doe " + if (!RegExp(r'.+<.*>').hasMatch(author)) { + throw FormatException( + 'Commit author appears malformed: "$author"', + ); + } + authorArg = '--author="$author"'; + } await git.run( - ['commit', "--message='$message'"], + [ + 'commit', + '--message', + message, + if (authorArg != null) authorArg, + ], 'commit changes', workingDirectory: (await checkoutDirectory).path, ); @@ -590,6 +656,29 @@ class FrameworkRepository extends Repository { ]); } + Future streamFlutter( + List args, { + void Function(String)? stdoutCallback, + void Function(String)? stderrCallback, + }) async { + await _ensureToolReady(); + final io.Process process = await processManager.start([ + fileSystem.path.join((await checkoutDirectory).path, 'bin', 'flutter'), + ...args, + ]); + process + .stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(stdoutCallback ?? stdio.printTrace); + process + .stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(stderrCallback ?? stdio.printError); + return process; + } + @override Future checkout(String ref) async { await super.checkout(ref); diff --git a/dev/conductor/core/test/common.dart b/dev/conductor/core/test/common.dart index b6612718951a..9cf66ffdfe36 100644 --- a/dev/conductor/core/test/common.dart +++ b/dev/conductor/core/test/common.dart @@ -2,7 +2,6 @@ // 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:conductor_core/src/stdio.dart'; import 'package:test/test.dart'; diff --git a/dev/conductor/core/test/next_test.dart b/dev/conductor/core/test/next_test.dart index e951a29b4764..787b78505edc 100644 --- a/dev/conductor/core/test/next_test.dart +++ b/dev/conductor/core/test/next_test.dart @@ -31,7 +31,7 @@ void main() { const String releaseChannel = 'beta'; const String stateFile = '/state-file.json'; final String localPathSeparator = const LocalPlatform().pathSeparator; - final String localOperatingSystem = const LocalPlatform().pathSeparator; + final String localOperatingSystem = const LocalPlatform().operatingSystem; group('next command', () { late MemoryFileSystem fileSystem; @@ -502,7 +502,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Create candidate branch version $candidateBranch for $releaseChannel'", + '--message', + 'Create candidate branch version $candidateBranch for $releaseChannel', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -516,7 +517,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + '--message', + 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -593,7 +595,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Create candidate branch version $candidateBranch for $releaseChannel'", + '--message', + 'Create candidate branch version $candidateBranch for $releaseChannel', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -607,7 +610,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + '--message', + 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -671,7 +675,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Create candidate branch version $candidateBranch for $releaseChannel'", + '--message', + 'Create candidate branch version $candidateBranch for $releaseChannel', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -685,7 +690,8 @@ void main() { const FakeCommand(command: [ 'git', 'commit', - "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + '--message', + 'Update Engine revision to $revision1 for $releaseChannel release $releaseVersion', ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], diff --git a/dev/conductor/core/test/packages_autoroller_test.dart b/dev/conductor/core/test/packages_autoroller_test.dart new file mode 100644 index 000000000000..97992615dd47 --- /dev/null +++ b/dev/conductor/core/test/packages_autoroller_test.dart @@ -0,0 +1,193 @@ +// 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 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:conductor_core/conductor_core.dart'; +import 'package:conductor_core/packages_autoroller.dart'; +import 'package:file/memory.dart'; +import 'package:platform/platform.dart'; + +import './common.dart'; + +void main() { + const String flutterRoot = '/flutter'; + const String checkoutsParentDirectory = '$flutterRoot/dev/conductor'; + const String githubClient = 'gh'; + const String token = '0123456789abcdef'; + const String orgName = 'flutter-roller'; + const String mirrorUrl = 'https://githost.com/flutter-roller/flutter.git'; + final String localPathSeparator = const LocalPlatform().pathSeparator; + final String localOperatingSystem = const LocalPlatform().operatingSystem; + late MemoryFileSystem fileSystem; + late TestStdio stdio; + late FrameworkRepository framework; + late PackageAutoroller autoroller; + late FakeProcessManager processManager; + + setUp(() { + stdio = TestStdio(); + fileSystem = MemoryFileSystem.test(); + processManager = FakeProcessManager.empty(); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory) + ..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + framework = FrameworkRepository( + checkouts, + mirrorRemote: const Remote( + name: RemoteName.mirror, + url: mirrorUrl, + ), + ); + + autoroller = PackageAutoroller( + githubClient: githubClient, + token: token, + framework: framework, + orgName: orgName, + processManager: processManager, + ); + }); + + test('can roll with correct inputs', () async { + final StreamController> controller = + StreamController>(); + processManager.addCommands([ + FakeCommand(command: const [ + 'gh', + 'auth', + 'login', + '--hostname', + 'github.com', + '--git-protocol', + 'https', + '--with-token', + ], stdin: io.IOSink(controller.sink)), + const FakeCommand(command: [ + 'git', + 'clone', + '--origin', + 'upstream', + '--', + FrameworkRepository.defaultUpstream, + '$checkoutsParentDirectory/flutter_conductor_checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'add', + 'mirror', + mirrorUrl, + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + 'mirror', + ]), + const FakeCommand(command: [ + 'git', + 'checkout', + FrameworkRepository.defaultBranch, + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + 'HEAD', + ], stdout: 'deadbeef'), + const FakeCommand(command: [ + 'git', + 'ls-remote', + '--heads', + 'mirror', + ]), + const FakeCommand(command: [ + 'git', + 'checkout', + '-b', + 'packages-autoroller-branch-1', + ]), + const FakeCommand(command: [ + '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter', + 'help', + ]), + const FakeCommand(command: [ + '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter', + '--verbose', + 'update-packages', + '--force-upgrade', + ]), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ], stdout: ''' + M packages/foo/pubspec.yaml + M packages/bar/pubspec.yaml + M dev/integration_tests/test_foo/pubspec.yaml +'''), + const FakeCommand(command: [ + 'git', + 'add', + '--all', + ]), + const FakeCommand(command: [ + 'git', + 'commit', + '--message', + 'roll packages', + '--author="flutter-packages-autoroller "', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + 'HEAD', + ], stdout: '000deadbeef'), + const FakeCommand(command: [ + 'git', + 'push', + mirrorUrl, + 'packages-autoroller-branch-1:packages-autoroller-branch-1', + ]), + const FakeCommand(command: [ + 'gh', + 'pr', + 'create', + '--title', + 'Roll pub packages', + '--body', + 'This PR was generated by `flutter update-packages --force-upgrade`.', + '--head', + 'flutter-roller:packages-autoroller-branch-1', + '--base', + FrameworkRepository.defaultBranch, + ]), + const FakeCommand(command: [ + 'gh', + 'auth', + 'logout', + '--hostname', + 'github.com', + ]), + ]); + final Future rollFuture = autoroller.roll(); + final String givenToken = + await controller.stream.transform(const Utf8Decoder()).join(); + expect(givenToken, token); + await rollFuture; + }); +} diff --git a/dev/conductor/core/test/repository_test.dart b/dev/conductor/core/test/repository_test.dart index c6b95a2d9979..35196b8acca3 100644 --- a/dev/conductor/core/test/repository_test.dart +++ b/dev/conductor/core/test/repository_test.dart @@ -13,6 +13,7 @@ void main() { group('repository', () { late FakePlatform platform; const String rootDir = '/'; + const String revision = 'deadbeef'; late MemoryFileSystem fileSystem; late FakeProcessManager processManager; late TestStdio stdio; @@ -31,8 +32,6 @@ void main() { }); test('canCherryPick returns true if git cherry-pick returns 0', () async { - const String commit = 'abc123'; - processManager.addCommands([ FakeCommand(command: [ 'git', @@ -53,7 +52,7 @@ void main() { 'git', 'rev-parse', 'HEAD', - ], stdout: commit), + ], stdout: revision), const FakeCommand(command: [ 'git', 'status', @@ -63,7 +62,7 @@ void main() { 'git', 'cherry-pick', '--no-commit', - commit, + revision, ]), const FakeCommand(command: [ 'git', @@ -80,7 +79,7 @@ void main() { stdio: stdio, ); final Repository repository = FrameworkRepository(checkouts); - expect(await repository.canCherryPick(commit), true); + expect(await repository.canCherryPick(revision), true); }); test('canCherryPick returns false if git cherry-pick returns non-zero', () async { @@ -262,7 +261,8 @@ vars = { const FakeCommand(command: [ 'git', 'commit', - "--message='$message'", + '--message', + message, ]), const FakeCommand(command: [ 'git', @@ -318,7 +318,8 @@ vars = { const FakeCommand(command: [ 'git', 'commit', - "--message='$message'", + '--message', + message, ]), const FakeCommand(command: [ 'git', @@ -501,6 +502,66 @@ vars = { expect(processManager.hasRemainingExpectations, false); expect(createdCandidateBranch, true); }); + + test('.listRemoteBranches() parses git output', () async { + const String remoteName = 'mirror'; + const String lsRemoteOutput = ''' +Extraneous debug information that should be ignored. + +4d44dca340603e25d4918c6ef070821181202e69 refs/heads/experiment +35185330c6af3a435f615ee8ac2fed8b8bb7d9d4 refs/heads/feature-a +6f60a1e7b2f3d2c2460c9dc20fe54d0e9654b131 refs/heads/feature-b +c1436c42c0f3f98808ae767e390c3407787f1a67 refs/heads/fix_bug_1234 +bbbcae73699263764ad4421a4b2ca3952a6f96cb refs/heads/stable + +Extraneous debug information that should be ignored. +'''; + processManager.addCommands(const [ + FakeCommand(command: [ + 'git', + 'clone', + '--origin', + 'upstream', + '--', + EngineRepository.defaultUpstream, + '${rootDir}flutter_conductor_checkouts/engine', + ]), + FakeCommand(command: [ + 'git', + 'checkout', + 'main', + ]), + FakeCommand(command: [ + 'git', + 'rev-parse', + 'HEAD', + ], stdout: revision), + FakeCommand( + command: ['git', 'ls-remote', '--heads', remoteName], + stdout: lsRemoteOutput, + ), + ]); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(rootDir), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + + final Repository repo = EngineRepository( + checkouts, + localUpstream: true, + ); + final List branchNames = await repo.listRemoteBranches(remoteName); + expect(branchNames, equals([ + 'experiment', + 'feature-a', + 'feature-b', + 'fix_bug_1234', + 'stable', + ])); + }); }); } diff --git a/dev/conductor/core/test/start_test.dart b/dev/conductor/core/test/start_test.dart index b9e78b74ef72..0945cbf76403 100644 --- a/dev/conductor/core/test/start_test.dart +++ b/dev/conductor/core/test/start_test.dart @@ -212,7 +212,7 @@ void main() { command: ['git', 'add', '--all'], ), const FakeCommand( - command: ['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"], + command: ['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'], ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -400,7 +400,7 @@ void main() { command: ['git', 'add', '--all'], ), const FakeCommand( - command: ['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"], + command: ['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'], ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -574,7 +574,7 @@ void main() { command: ['git', 'add', '--all'], ), const FakeCommand( - command: ['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"], + command: ['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'], ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -761,7 +761,7 @@ void main() { command: ['git', 'add', '--all'], ), const FakeCommand( - command: ['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"], + command: ['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'], ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], @@ -952,7 +952,7 @@ void main() { command: ['git', 'add', '--all'], ), const FakeCommand( - command: ['git', 'commit', "--message='Update Dart SDK to $nextDartRevision'"], + command: ['git', 'commit', '--message', 'Update Dart SDK to $nextDartRevision'], ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'],