[flutter_tools] refactor packages_autoroller.dart script (#106580)

diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart
index c14bfcb..c11a820 100644
--- a/dev/bots/analyze.dart
+++ b/dev/bots/analyze.dart
@@ -1778,7 +1778,7 @@
   'dev/bots/docs.sh',
 
   'dev/conductor/bin/conductor',
-  'dev/conductor/bin/roll-packages',
+  'dev/conductor/bin/packages_autoroller',
   'dev/conductor/core/lib/src/proto/compile_proto.sh',
 
   'dev/customer_testing/ci.sh',
diff --git a/dev/conductor/bin/roll-packages b/dev/conductor/bin/packages_autoroller
similarity index 97%
rename from dev/conductor/bin/roll-packages
rename to dev/conductor/bin/packages_autoroller
index 231d544..c424296 100755
--- a/dev/conductor/bin/roll-packages
+++ b/dev/conductor/bin/packages_autoroller
@@ -43,4 +43,4 @@
 # 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" "$@"
+exec "$DART_BIN" --enable-asserts "$REPO_DIR/dev/conductor/core/bin/packages_autoroller.dart" "$@"
diff --git a/dev/conductor/core/bin/packages_autoroller.dart b/dev/conductor/core/bin/packages_autoroller.dart
index d69c846..db9635e 100644
--- a/dev/conductor/core/bin/packages_autoroller.dart
+++ b/dev/conductor/core/bin/packages_autoroller.dart
@@ -9,6 +9,7 @@
 import 'package:conductor_core/packages_autoroller.dart';
 import 'package:file/file.dart';
 import 'package:file/local.dart';
+import 'package:meta/meta.dart' show visibleForTesting;
 import 'package:platform/platform.dart';
 import 'package:process/process.dart';
 
@@ -17,12 +18,21 @@
 const String kMirrorRemote = 'mirror-remote';
 const String kUpstreamRemote = 'upstream-remote';
 
-Future<void> main(List<String> args) async {
+Future<void> main(List<String> args) {
+  return run(args);
+}
+
+@visibleForTesting
+Future<void> run(
+  List<String> args, {
+  FileSystem fs = const LocalFileSystem(),
+  ProcessManager processManager = const LocalProcessManager(),
+}) async {
   final ArgParser parser = ArgParser();
   parser.addOption(
     kTokenOption,
-    help: 'GitHub access token env variable name.',
-    defaultsTo: 'GITHUB_TOKEN',
+    help: 'Path to GitHub access token file.',
+    mandatory: true,
   );
   parser.addOption(
     kGithubClient,
@@ -56,12 +66,17 @@
 
   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 String tokenPath = results[kTokenOption]! as String;
+  final File tokenFile = fs.file(tokenPath);
+  if (!tokenFile.existsSync()) {
+    throw ArgumentError(
+      'Provided token path $tokenPath but no file exists at ${tokenFile.absolute.path}',
+    );
+  }
+  final String token = tokenFile.readAsStringSync().trim();
+  if (token.isEmpty) {
+    throw ArgumentError(
+      'Tried to read a GitHub access token from file ${tokenFile.path} but it was empty',
     );
   }
 
@@ -76,7 +91,7 @@
     githubClient: results[kGithubClient] as String? ?? 'gh',
     orgName: _parseOrgName(mirrorUrl),
     token: token,
-    processManager: const LocalProcessManager(),
+    processManager: processManager,
   ).roll();
 }
 
@@ -126,3 +141,8 @@
   );
   return fileSystem.directory(checkoutsDirname);
 }
+
+@visibleForTesting
+void validateTokenFile(String filePath, [FileSystem fs = const LocalFileSystem()]) {
+
+}
diff --git a/dev/conductor/core/lib/src/packages_autoroller.dart b/dev/conductor/core/lib/src/packages_autoroller.dart
index e05d494..8e277f8 100644
--- a/dev/conductor/core/lib/src/packages_autoroller.dart
+++ b/dev/conductor/core/lib/src/packages_autoroller.dart
@@ -10,6 +10,7 @@
 import 'git.dart';
 import 'globals.dart';
 import 'repository.dart';
+import 'stdio.dart';
 
 /// A service for rolling the SDK's pub packages to latest and open a PR upstream.
 class PackageAutoroller {
@@ -19,7 +20,10 @@
     required this.framework,
     required this.orgName,
     required this.processManager,
+    this.githubUsername = 'fluttergithubbot',
+    Stdio? stdio,
   }) {
+    this.stdio = stdio ?? VerboseStdio.local();
     if (token.trim().isEmpty) {
       throw Exception('empty token!');
     }
@@ -31,12 +35,16 @@
     }
   }
 
+  late final Stdio stdio;
+
   final FrameworkRepository framework;
   final ProcessManager processManager;
 
   /// Path to GitHub CLI client.
   final String githubClient;
 
+  final String githubUsername;
+
   /// GitHub API access token.
   final String token;
 
@@ -63,23 +71,46 @@
     return name(x);
   })();
 
+  void log(String message) {
+    stdio.printStatus(_redactToken(message));
+  }
+
   /// Name of the GitHub organization to push the feature branch to.
   final String orgName;
 
   Future<void> roll() async {
-    await authLogin();
-    await updatePackages();
-    await pushBranch();
-    await createPr(
-      repository: await framework.checkoutDirectory,
-    );
-    await authLogout();
+    try {
+      await authLogin();
+      final bool openPrAlready = await hasOpenPrs();
+      if (openPrAlready) {
+        // Don't open multiple roll PRs.
+        return;
+      }
+      final bool didUpdate = await updatePackages();
+      if (!didUpdate) {
+        log('Packages are already at latest.');
+        return;
+      }
+      await pushBranch();
+      await createPr(repository: await framework.checkoutDirectory);
+      await authLogout();
+    } on Exception catch (exception) {
+      final String message = _redactToken(exception.toString());
+      throw Exception('${exception.runtimeType}: $message');
+    }
   }
 
-  Future<void> updatePackages({
+  // Ensure we don't leak the GitHub token in exception messages
+  String _redactToken(String message) => message.replaceAll(token, '[GitHub TOKEN]');
+
+  /// Attempt to update all pub packages.
+  ///
+  /// Will return whether or not any changes were made.
+  Future<bool> updatePackages({
     bool verbose = true,
-    String author = 'flutter-packages-autoroller <flutter-packages-autoroller@google.com>'
   }) async {
+    final String author = '$githubUsername <$githubUsername@gmail.com>';
+
     await framework.newBranch(await featureBranchName);
     final io.Process flutterProcess = await framework.streamFlutter(<String>[
       if (verbose) '--verbose',
@@ -90,18 +121,26 @@
     if (exitCode != 0) {
       throw ConductorException('Failed to update packages with exit code $exitCode');
     }
+    // If the git checkout is clean, then pub packages are already at latest that cleanly resolve.
+    if (await framework.gitCheckoutClean()) {
+      return false;
+    }
     await framework.commit(
       'roll packages',
       addFirst: true,
       author: author,
     );
+    return true;
   }
 
   Future<void> pushBranch() async {
+    final String projectName = framework.mirrorRemote!.url.split(r'/').last;
+    // Encode the token into the remote URL for authentication to work
+    final String remote = 'https://$token@$hostname/$orgName/$projectName';
     await framework.pushRef(
       fromRef: await featureBranchName,
       toRef: await featureBranchName,
-      remote: framework.mirrorRemote!.url,
+      remote: remote,
     );
   }
 
@@ -123,7 +162,7 @@
         'https',
         '--with-token',
       ],
-      stdin: token,
+      stdin: '$token\n',
     );
   }
 
@@ -151,6 +190,8 @@
         '$orgName:${await featureBranchName}',
         '--base',
         base,
+        '--label',
+        'tool',
         if (draft)
           '--draft',
       ],
@@ -165,13 +206,16 @@
     ]);
   }
 
-  Future<void> cli(
+  /// Run a sub-process with the GitHub CLI client.
+  ///
+  /// Will return STDOUT of the sub-process.
+  Future<String> cli(
     List<String> args, {
     bool allowFailure = false,
     String? stdin,
     String? workingDirectory,
   }) async {
-    print('Executing "$githubClient ${args.join(' ')}" in $workingDirectory');
+    log('Executing "$githubClient ${args.join(' ')}" in $workingDirectory');
     final io.Process process = await processManager.start(
       <String>[githubClient, ...args],
       workingDirectory: workingDirectory,
@@ -203,6 +247,36 @@
         args,
       );
     }
-    print(stdout);
+    log(stdout);
+    return stdout;
+  }
+
+  Future<bool> hasOpenPrs() async {
+    // gh pr list --author christopherfujino --repo flutter/flutter --state open --json number
+    final String openPrString = await cli(<String>[
+      'pr',
+      'list',
+      '--author',
+      githubUsername,
+      '--repo',
+      'flutter/flutter',
+      '--state',
+      'open',
+      // We are only interested in pub rolls, not devicelab flaky PRs
+      '--label',
+      'tool',
+      // Return structured JSON with the PR numbers of open PRs
+      '--json',
+      'number',
+    ]);
+
+    // This will be an array of objects, one for each open PR.
+    final List<Object?> openPrs = json.decode(openPrString) as List<Object?>;
+
+    if (openPrs.isNotEmpty) {
+      log('$githubUsername already has open tool PRs:\n$openPrs');
+      return true;
+    }
+    return false;
   }
 }
diff --git a/dev/conductor/core/test/packages_autoroller_test.dart b/dev/conductor/core/test/packages_autoroller_test.dart
index 9799261..c1828d1 100644
--- a/dev/conductor/core/test/packages_autoroller_test.dart
+++ b/dev/conductor/core/test/packages_autoroller_test.dart
@@ -12,6 +12,7 @@
 import 'package:platform/platform.dart';
 
 import './common.dart';
+import '../bin/packages_autoroller.dart' show run;
 
 void main() {
   const String flutterRoot = '/flutter';
@@ -61,10 +62,11 @@
       framework: framework,
       orgName: orgName,
       processManager: processManager,
+      stdio: stdio,
     );
   });
 
-  test('can roll with correct inputs', () async {
+  test('GitHub token is redacted from exceptions while pushing', () async {
     final StreamController<List<int>> controller =
         StreamController<List<int>>();
     processManager.addCommands(<FakeCommand>[
@@ -150,7 +152,7 @@
         'commit',
         '--message',
         'roll packages',
-        '--author="flutter-packages-autoroller <flutter-packages-autoroller@google.com>"',
+        '--author="fluttergithubbot <fluttergithubbot@google.com>"',
       ]),
       const FakeCommand(command: <String>[
         'git',
@@ -160,7 +162,282 @@
       const FakeCommand(command: <String>[
         'git',
         'push',
+        'https://$token@github.com/$orgName/flutter.git',
+        'packages-autoroller-branch-1:packages-autoroller-branch-1',
+      ], exitCode: 1, stderr: 'Authentication error!'),
+    ]);
+    await expectLater(
+      () async {
+        final Future<void> rollFuture = autoroller.roll();
+        await controller.stream.drain();
+        await rollFuture;
+      },
+      throwsA(isA<Exception>().having(
+        (Exception exc) => exc.toString(),
+        'message',
+        isNot(contains(token)),
+      )),
+    );
+  });
+
+  test('Does not attempt to roll if bot already has an open PR', () async {
+    final StreamController<List<int>> controller =
+        StreamController<List<int>>();
+    processManager.addCommands(<FakeCommand>[
+      FakeCommand(command: const <String>[
+        'gh',
+        'auth',
+        'login',
+        '--hostname',
+        'github.com',
+        '--git-protocol',
+        'https',
+        '--with-token',
+      ], stdin: io.IOSink(controller.sink)),
+      const FakeCommand(command: <String>[
+        'gh',
+        'pr',
+        'list',
+        '--author',
+        'fluttergithubbot',
+        '--repo',
+        'flutter/flutter',
+        '--state',
+        'open',
+        '--label',
+        'tool',
+        '--json',
+        'number',
+      // Non empty array means there are open PRs by the bot with the tool label
+      // We expect no further commands to be run
+      ], stdout: '[{"number": 123}]'),
+    ]);
+    final Future<void> rollFuture = autoroller.roll();
+    await controller.stream.drain();
+    await rollFuture;
+    expect(processManager, hasNoRemainingExpectations);
+    expect(stdio.stdout, contains('fluttergithubbot already has open tool PRs'));
+    expect(stdio.stdout, contains(r'[{number: 123}]'));
+  });
+
+  test('Does not commit or create a PR if no changes were made', () async {
+    final StreamController<List<int>> controller =
+        StreamController<List<int>>();
+    processManager.addCommands(<FakeCommand>[
+      FakeCommand(command: const <String>[
+        'gh',
+        'auth',
+        'login',
+        '--hostname',
+        'github.com',
+        '--git-protocol',
+        'https',
+        '--with-token',
+      ], stdin: io.IOSink(controller.sink)),
+      const FakeCommand(command: <String>[
+        'gh',
+        'pr',
+        'list',
+        '--author',
+        'fluttergithubbot',
+        '--repo',
+        'flutter/flutter',
+        '--state',
+        'open',
+        '--label',
+        'tool',
+        '--json',
+        'number',
+      // Returns empty array, as there are no other open roll PRs from the bot
+      ], stdout: '[]'),
+      const FakeCommand(command: <String>[
+        'git',
+        'clone',
+        '--origin',
+        'upstream',
+        '--',
+        FrameworkRepository.defaultUpstream,
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'remote',
+        'add',
+        'mirror',
         mirrorUrl,
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'fetch',
+        'mirror',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'checkout',
+        FrameworkRepository.defaultBranch,
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'rev-parse',
+        'HEAD',
+      ], stdout: 'deadbeef'),
+      const FakeCommand(command: <String>[
+        'git',
+        'ls-remote',
+        '--heads',
+        'mirror',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'checkout',
+        '-b',
+        'packages-autoroller-branch-1',
+      ]),
+      const FakeCommand(command: <String>[
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+        'help',
+      ]),
+      const FakeCommand(command: <String>[
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+        '--verbose',
+        'update-packages',
+        '--force-upgrade',
+      ]),
+      // Because there is no stdout to git status, the script should exit cleanly here
+      const FakeCommand(command: <String>[
+        'git',
+        'status',
+        '--porcelain',
+      ]),
+    ]);
+    final Future<void> rollFuture = autoroller.roll();
+    await controller.stream.drain();
+    await rollFuture;
+    expect(processManager, hasNoRemainingExpectations);
+  });
+
+  test('can roll with correct inputs', () async {
+    final StreamController<List<int>> controller =
+        StreamController<List<int>>();
+    processManager.addCommands(<FakeCommand>[
+      FakeCommand(command: const <String>[
+        'gh',
+        'auth',
+        'login',
+        '--hostname',
+        'github.com',
+        '--git-protocol',
+        'https',
+        '--with-token',
+      ], stdin: io.IOSink(controller.sink)),
+      const FakeCommand(command: <String>[
+        'gh',
+        'pr',
+        'list',
+        '--author',
+        'fluttergithubbot',
+        '--repo',
+        'flutter/flutter',
+        '--state',
+        'open',
+        '--label',
+        'tool',
+        '--json',
+        'number',
+      // Returns empty array, as there are no other open roll PRs from the bot
+      ], stdout: '[]'),
+      const FakeCommand(command: <String>[
+        'git',
+        'clone',
+        '--origin',
+        'upstream',
+        '--',
+        FrameworkRepository.defaultUpstream,
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'remote',
+        'add',
+        'mirror',
+        mirrorUrl,
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'fetch',
+        'mirror',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'checkout',
+        FrameworkRepository.defaultBranch,
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'rev-parse',
+        'HEAD',
+      ], stdout: 'deadbeef'),
+      const FakeCommand(command: <String>[
+        'git',
+        'ls-remote',
+        '--heads',
+        'mirror',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'checkout',
+        '-b',
+        'packages-autoroller-branch-1',
+      ]),
+      const FakeCommand(command: <String>[
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+        'help',
+      ]),
+      const FakeCommand(command: <String>[
+        '$checkoutsParentDirectory/flutter_conductor_checkouts/framework/bin/flutter',
+        '--verbose',
+        'update-packages',
+        '--force-upgrade',
+      ]),
+      const FakeCommand(command: <String>[
+        '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: <String>[
+        '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: <String>[
+        'git',
+        'add',
+        '--all',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'commit',
+        '--message',
+        'roll packages',
+        '--author="fluttergithubbot <fluttergithubbot@gmail.com>"',
+      ]),
+      const FakeCommand(command: <String>[
+        'git',
+        'rev-parse',
+        'HEAD',
+      ], stdout: '000deadbeef'),
+      const FakeCommand(command: <String>[
+        'git',
+        'push',
+        'https://$token@github.com/$orgName/flutter.git',
         'packages-autoroller-branch-1:packages-autoroller-branch-1',
       ]),
       const FakeCommand(command: <String>[
@@ -175,6 +452,8 @@
         'flutter-roller:packages-autoroller-branch-1',
         '--base',
         FrameworkRepository.defaultBranch,
+        '--label',
+        'tool',
       ]),
       const FakeCommand(command: <String>[
         'gh',
@@ -187,7 +466,48 @@
     final Future<void> rollFuture = autoroller.roll();
     final String givenToken =
         await controller.stream.transform(const Utf8Decoder()).join();
-    expect(givenToken, token);
+    expect(givenToken.trim(), token);
     await rollFuture;
+    expect(processManager, hasNoRemainingExpectations);
+  });
+
+  group('command argument validations', () {
+    const String tokenPath = '/path/to/token';
+    const String mirrorRemote = 'https://githost.com/org/project';
+
+    test('validates that file exists at --token option', () async {
+      await expectLater(
+        () => run(
+          <String>['--token', tokenPath, '--mirror-remote', mirrorRemote],
+          fs: fileSystem,
+          processManager: processManager,
+        ),
+        throwsA(isA<ArgumentError>().having(
+          (ArgumentError err) => err.message,
+          'message',
+          contains('Provided token path $tokenPath but no file exists at'),
+        )),
+      );
+      expect(processManager, hasNoRemainingExpectations);
+    });
+
+    test('validates that the token file is not empty', () async {
+      fileSystem.file(tokenPath)
+        ..createSync(recursive: true)
+        ..writeAsStringSync('');
+      await expectLater(
+        () => run(
+          <String>['--token', tokenPath, '--mirror-remote', mirrorRemote],
+          fs: fileSystem,
+          processManager: processManager,
+        ),
+        throwsA(isA<ArgumentError>().having(
+          (ArgumentError err) => err.message,
+          'message',
+          contains('Tried to read a GitHub access token from file $tokenPath but it was empty'),
+        )),
+      );
+      expect(processManager, hasNoRemainingExpectations);
+    });
   });
 }