Tool for bumping dependencies

Aims to automate the process of bumping a single dependency.

To bump pub_semver to the newest commit invoke as:

```
> dart tool/manage_deps.dart bump pub_semver
```

Change-Id: I16a3edabfb03adbd4460fb3cd81738a792e44d50
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/213764
Commit-Queue: Sigurd Meldgaard <sigurdm@google.com>
Reviewed-by: Alexander Thomas <athom@google.com>
Reviewed-by: Jonas Jensen <jonasfj@google.com>
diff --git a/tools/manage_deps.dart b/tools/manage_deps.dart
new file mode 100755
index 0000000..0d1e8b4
--- /dev/null
+++ b/tools/manage_deps.dart
@@ -0,0 +1,261 @@
+#!tools/sdks/dart-sdk/bin/dart
+// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// Helps rolling dependency to the newest version available
+/// (or a target version).
+///
+/// Usage: ./tools/manage_deps.dart bump <dependency> [--branch <branch>] [--target <ref>]
+///
+/// This will:
+/// 0. Check that git is clean
+/// 1. Create branch `<branch> ?? bump_<dependency>`
+/// 2. Update `DEPS` for `<dependency>`
+/// 3. Create a commit with `git log` of imported commits in the message.
+/// 4. Prompt to create a CL
+
+// @dart = 2.13
+library bump;
+
+import 'dart:io';
+import 'package:args/command_runner.dart';
+
+import 'package:path/path.dart' as p;
+
+class BumpCommand extends Command<int> {
+  @override
+  String get description => '''
+Bump a dependency in DEPS and create a CL
+
+This will:
+0. Check that git is clean
+1. Create branch `<branch> ?? bump_<dependency>`
+2. Update `DEPS` for `<dependency>`
+3. Create a commit with `git log` of imported commits in the message.
+4. Prompt to create a CL
+''';
+
+  String get invocation =>
+      './tools/manage_deps.dart bump <path/to/dependency> <options>';
+
+  BumpCommand() {
+    argParser.addOption(
+      'branch',
+      help: 'The name of the branch where the update is created.',
+      valueHelp: 'branch-name',
+    );
+    argParser.addOption(
+      'target',
+      help: 'The git ref to update to.',
+      valueHelp: 'ref',
+    );
+  }
+
+  @override
+  String get name => 'bump';
+
+  @override
+  Future<int> run() async {
+    final argResults = this.argResults!;
+    if (argResults.rest.length != 1) {
+      usageException('No dependency directory given');
+    }
+    final status = runProcessForLines(['git', 'status', '--porcelain'],
+        explanation: 'Checking if your git checkout is clean');
+    if (status.isNotEmpty) {
+      print('Note your git checkout is dirty!');
+    }
+
+    final pkgDir = argResults.rest.first;
+    if (!Directory(pkgDir).existsSync()) {
+      usageException('No directory $pkgDir');
+    }
+    final toUpdate = p.split(pkgDir).last;
+    final branchName = argResults['branch'] ?? 'bump_$toUpdate';
+
+    final exists = runProcessForExitCode(
+        ['git', 'show-ref', '--quiet', 'refs/head/$branchName'],
+        explanation: 'Checking if branch-name exists');
+    if (exists != 0) {
+      print('Branch $branchName already exist - delete it?');
+      if (!prompt()) {
+        print('Ok - exiting');
+        exit(-1);
+      }
+      runProcessAssumingSuccess(
+        ['git', 'branch', '-D', branchName],
+        explanation: 'Deleting existing branch',
+      );
+    }
+    runProcessAssumingSuccess(
+      ['git', 'checkout', '-b', branchName],
+      explanation: 'Creating branch',
+    );
+
+    final currentRev = runProcessForLines(
+      ['gclient', 'getdep', '-r', p.join('sdk', pkgDir)],
+      explanation: 'Finding current revision',
+    ).first;
+
+    final originUrl = runProcessForLines(
+      ['git', 'config', '--get', 'remote.origin.url'],
+      workingDirectory: pkgDir,
+      explanation: 'Finding origin url',
+    ).first;
+
+    runProcessAssumingSuccess(
+      ['git', 'fetch', 'origin'],
+      workingDirectory: pkgDir,
+      explanation: 'Retrieving updates to $toUpdate',
+    );
+
+    final gitRevParseResult = runProcessForLines([
+      'git',
+      'rev-parse',
+      if (argResults.wasParsed('target'))
+        argResults['target']
+      else
+        'origin/HEAD',
+    ], workingDirectory: pkgDir, explanation: 'Finding sha-id');
+
+    final target = gitRevParseResult.first;
+    if (currentRev == target) {
+      print('Already at $target - nothing to do');
+      return -1;
+    }
+    runProcessAssumingSuccess(
+      ['gclient', 'setdep', '-r', '${p.join('sdk', pkgDir)}@$target'],
+      explanation: 'Updating $toUpdate',
+    );
+    runProcessAssumingSuccess(
+      ['gclient', 'sync', '-D'],
+      explanation: 'Syncing your deps',
+    );
+    runProcessAssumingSuccess(
+      [
+        Platform.resolvedExecutable,
+        'tools/generate_package_config.dart',
+      ],
+      explanation: 'Updating package config',
+    );
+    final gitLogResult = runProcessForLines([
+      'git',
+      'log',
+      '--format=%C(auto) $originUrl/+/%h %s ',
+      '$currentRev..$target',
+    ], workingDirectory: pkgDir, explanation: 'Listing new commits');
+    final commitMessage = '''
+Bump $toUpdate to $target
+
+Changes:
+```
+> git log --format="%C(auto) %h %s" ${currentRev.substring(0, 7)}..${target.substring(0, 7)}
+${gitLogResult.join('\n')}
+```
+Diff: $originUrl/+/$currentRev~..$target/
+''';
+    runProcessAssumingSuccess(['git', 'commit', '-am', commitMessage],
+        explanation: 'Committing');
+    print('Consider updating CHANGELOG.md');
+    print('Do you want to create a CL?');
+    if (prompt()) {
+      await runProcessInteractively(
+        ['git', 'cl', 'upload', '-m', commitMessage],
+        explanation: 'Creating CL',
+      );
+    }
+    return 0;
+  }
+}
+
+Future<void> main(List<String> args) async {
+  final runner = CommandRunner<int>(
+      'manage_deps.dart', 'helps managing the DEPS file',
+      usageLineLength: 80)
+    ..addCommand(BumpCommand());
+  try {
+    exit(await runner.run(args) ?? -1);
+  } on UsageException catch (e) {
+    print(e.message);
+    print(e.usage);
+  }
+}
+
+bool prompt() {
+  stdout.write('(y/N):');
+  final answer = stdin.readLineSync() ?? '';
+  return answer.trim().toLowerCase() == 'y';
+}
+
+void printRunningLine(
+    List<String> cmd, String? explanation, String? workingDirectory) {
+  stdout.write(
+      "${explanation ?? 'Running'}: `${cmd.join(' ')}` ${workingDirectory == null ? '' : 'in $workingDirectory'}");
+}
+
+void printSuccessTrailer(ProcessResult result, String? onFailure) {
+  if (result.exitCode == 0) {
+    stdout.writeln(' ✓');
+  } else {
+    stdout.writeln(' X');
+    stderr.write(result.stdout);
+    stderr.write(result.stderr);
+    if (onFailure != null) {
+      print(onFailure);
+    }
+    throw Exception();
+  }
+}
+
+void runProcessAssumingSuccess(List<String> cmd,
+    {String? explanation,
+    String? workingDirectory,
+    Map<String, String> environment = const {},
+    String? onFailure}) {
+  printRunningLine(cmd, explanation, workingDirectory);
+  final result = Process.runSync(
+    cmd[0],
+    cmd.skip(1).toList(),
+    workingDirectory: workingDirectory,
+    environment: environment,
+  );
+  printSuccessTrailer(result, onFailure);
+}
+
+List<String> runProcessForLines(List<String> cmd,
+    {String? explanation, String? workingDirectory, String? onFailure}) {
+  printRunningLine(cmd, explanation, workingDirectory);
+  final result = Process.runSync(
+    cmd[0],
+    cmd.skip(1).toList(),
+    workingDirectory: workingDirectory,
+  );
+  printSuccessTrailer(result, onFailure);
+  final output = (result.stdout as String);
+  return output == '' ? <String>[] : output.split('\n');
+}
+
+Future<void> runProcessInteractively(List<String> cmd,
+    {String? explanation, String? workingDirectory}) async {
+  printRunningLine(cmd, explanation, workingDirectory);
+  stdout.writeln('');
+  final process = await Process.start(cmd[0], cmd.skip(1).toList(),
+      workingDirectory: workingDirectory, mode: ProcessStartMode.inheritStdio);
+  final exitCode = await process.exitCode;
+  if (exitCode != 0) {
+    throw Exception();
+  }
+}
+
+int runProcessForExitCode(List<String> cmd,
+    {String? explanation, String? workingDirectory}) {
+  printRunningLine(cmd, explanation, workingDirectory);
+  final result = Process.runSync(
+    cmd[0],
+    cmd.skip(1).toList(),
+    workingDirectory: workingDirectory,
+  );
+  stdout.writeln(' => ${result.exitCode}');
+  return result.exitCode;
+}