Add a script to post-process docs. (#112228)

diff --git a/dev/bots/post_process_docs.dart b/dev/bots/post_process_docs.dart
new file mode 100644
index 0000000..8be4123
--- /dev/null
+++ b/dev/bots/post_process_docs.dart
@@ -0,0 +1,155 @@
+// 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';
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+
+import 'package:path/path.dart' as path;
+import 'package:platform/platform.dart' as platform;
+
+import 'package:process/process.dart';
+
+const String kDocsRoot = 'dev/docs';
+const String kPublishRoot = '$kDocsRoot/doc';
+
+class CommandException implements Exception {}
+
+Future<void> main() async {
+  await postProcess();
+}
+
+/// Post-processes an APIs documentation zip file to modify the footer and version
+/// strings for commits promoted to either beta or stable channels.
+Future<void> postProcess() async {
+  final String revision = await gitRevision(fullLength: true);
+  print('Docs revision being processed: $revision');
+  final Directory tmpFolder = Directory.systemTemp.createTempSync();
+  final String zipDestination = path.join(tmpFolder.path, 'api_docs.zip');
+
+  if (!Platform.environment.containsKey('SDK_CHECKOUT_PATH')) {
+    print('SDK_CHECKOUT_PATH env variable is required for this script');
+    exit(1);
+  }
+  final String checkoutPath = Platform.environment['SDK_CHECKOUT_PATH']!;
+  final String docsPath = path.join(checkoutPath, 'dev', 'docs');
+  await runProcessWithValidations(
+    <String>[
+      'curl',
+      '-L',
+      'https://storage.googleapis.com/flutter_infra_release/flutter/$revision/api_docs.zip',
+      '--output',
+      zipDestination,
+      '--fail',
+    ],
+    docsPath,
+  );
+
+  // Unzip to docs folder.
+  await runProcessWithValidations(
+    <String>[
+      'unzip',
+      '-o',
+      zipDestination,
+    ],
+    docsPath,
+  );
+
+  // Generate versions file.
+  await runProcessWithValidations(
+    <String>['flutter', '--version'],
+    docsPath,
+  );
+  final File versionFile = File('version');
+  final String version = versionFile.readAsStringSync();
+  // Recreate footer
+  final String publishPath = path.join(docsPath, 'doc', 'api', 'footer.js');
+  final File footerFile = File(publishPath)..createSync(recursive: true);
+  createFooter(footerFile, version);
+}
+
+/// Gets the git revision of the current checkout. [fullLength] if true will return
+/// the full commit hash, if false it will return the first 10 characters only.
+Future<String> gitRevision({
+  bool fullLength = false,
+  @visibleForTesting platform.Platform platform = const platform.LocalPlatform(),
+  @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+  const int kGitRevisionLength = 10;
+
+  final ProcessResult gitResult = processManager.runSync(<String>['git', 'rev-parse', 'HEAD']);
+  if (gitResult.exitCode != 0) {
+    throw 'git rev-parse exit with non-zero exit code: ${gitResult.exitCode}';
+  }
+  final String gitRevision = (gitResult.stdout as String).trim();
+  if (fullLength) {
+    return gitRevision;
+  }
+  return gitRevision.length > kGitRevisionLength ? gitRevision.substring(0, kGitRevisionLength) : gitRevision;
+}
+
+/// Wrapper function to run a subprocess checking exit code and printing stderr and stdout.
+/// [executable] is a string with the script/binary to execute, [args] is the list of flags/arguments
+/// and [workingDirectory] is as string to the working directory where the subprocess will be run.
+Future<void> runProcessWithValidations(
+  List<String> command,
+  String workingDirectory, {
+  @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+  final ProcessResult result =
+      processManager.runSync(command, stdoutEncoding: utf8, workingDirectory: workingDirectory);
+  if (result.exitCode == 0) {
+    print('Stdout: ${result.stdout}');
+  } else {
+    print('StdErr: ${result.stderr}');
+    throw CommandException();
+  }
+}
+
+/// Get the name of the release branch.
+///
+/// On LUCI builds, the git HEAD is detached, so first check for the env
+/// variable "LUCI_BRANCH"; if it is not set, fall back to calling git.
+Future<String> getBranchName({
+  @visibleForTesting platform.Platform platform = const platform.LocalPlatform(),
+  @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+  final RegExp gitBranchRegexp = RegExp(r'^## (.*)');
+  final String? luciBranch = platform.environment['LUCI_BRANCH'];
+  if (luciBranch != null && luciBranch.trim().isNotEmpty) {
+    return luciBranch.trim();
+  }
+  final ProcessResult gitResult = processManager.runSync(<String>['git', 'status', '-b', '--porcelain']);
+  if (gitResult.exitCode != 0) {
+    throw 'git status exit with non-zero exit code: ${gitResult.exitCode}';
+  }
+  final RegExpMatch? gitBranchMatch = gitBranchRegexp.firstMatch((gitResult.stdout as String).trim().split('\n').first);
+  return gitBranchMatch == null ? '' : gitBranchMatch.group(1)!.split('...').first;
+}
+
+/// Updates the footer of the api documentation with the correct branch and versions.
+/// [footerPath] is the path to the location of the footer js file and [version] is a
+/// string with the version calculated by the flutter tool.
+Future<void> createFooter(File footerFile, String version,
+    {@visibleForTesting String? timestampParam,
+    @visibleForTesting String? branchParam,
+    @visibleForTesting String? revisionParam}) async {
+  final String timestamp = timestampParam ?? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
+  final String gitBranch = branchParam ?? await getBranchName();
+  final String revision = revisionParam ?? await gitRevision();
+  final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch';
+  footerFile.writeAsStringSync('''
+(function() {
+  var span = document.querySelector('footer>span');
+  if (span) {
+    span.innerText = 'Flutter $version • $timestamp • $revision $gitBranchOut';
+  }
+  var sourceLink = document.querySelector('a.source-link');
+  if (sourceLink) {
+    sourceLink.href = sourceLink.href.replace('/master/', '/$revision/');
+  }
+})();
+''');
+}
diff --git a/dev/bots/pubspec.yaml b/dev/bots/pubspec.yaml
index 3ad830b..00cc658 100644
--- a/dev/bots/pubspec.yaml
+++ b/dev/bots/pubspec.yaml
@@ -7,6 +7,7 @@
 dependencies:
   args: 2.3.1
   crypto: 3.0.2
+  intl: 0.17.0
   flutter_devicelab:
     path: ../devicelab
   http_parser: 4.0.1
@@ -23,6 +24,7 @@
   async: 2.9.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   boolean_selector: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   checked_yaml: 2.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+  clock: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   collection: 1.16.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   convert: 3.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
   coverage: 1.6.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
@@ -69,4 +71,4 @@
 dev_dependencies:
   test_api: 0.4.14
 
-# PUBSPEC CHECKSUM: 09b7
+# PUBSPEC CHECKSUM: 7a48
diff --git a/dev/bots/test/post_process_docs_test.dart b/dev/bots/test/post_process_docs_test.dart
new file mode 100644
index 0000000..043d722
--- /dev/null
+++ b/dev/bots/test/post_process_docs_test.dart
@@ -0,0 +1,165 @@
+// 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';
+
+import 'package:file/memory.dart';
+import 'package:platform/platform.dart';
+
+import '../../../packages/flutter_tools/test/src/fake_process_manager.dart';
+import '../post_process_docs.dart';
+import 'common.dart';
+
+void main() async {
+  group('getBranch', () {
+    const String branchName = 'stable';
+    test('getBranchName does not call git if env LUCI_BRANCH provided', () async {
+      final Platform platform = FakePlatform(
+        environment: <String, String>{
+          'LUCI_BRANCH': branchName,
+        },
+      );
+      final ProcessManager processManager = FakeProcessManager.empty();
+      final String calculatedBranchName = await getBranchName(
+        platform: platform,
+        processManager: processManager,
+      );
+      expect(calculatedBranchName, branchName);
+    });
+
+    test('getBranchName calls git if env LUCI_BRANCH not provided', () async {
+      final Platform platform = FakePlatform(
+        environment: <String, String>{},
+      );
+
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: <String>['git', 'status', '-b', '--porcelain'],
+            stdout: '## $branchName',
+          ),
+        ],
+      );
+
+      final String calculatedBranchName = await getBranchName(platform: platform, processManager: processManager);
+      expect(
+        calculatedBranchName,
+        branchName,
+      );
+      expect(processManager, hasNoRemainingExpectations);
+    });
+    test('getBranchName calls git if env LUCI_BRANCH is empty', () async {
+      final Platform platform = FakePlatform(
+        environment: <String, String>{
+          'LUCI_BRANCH': '',
+        },
+      );
+
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: <String>['git', 'status', '-b', '--porcelain'],
+            stdout: '## $branchName',
+          ),
+        ],
+      );
+      final String calculatedBranchName = await getBranchName(
+        platform: platform,
+        processManager: processManager,
+      );
+      expect(
+        calculatedBranchName,
+        branchName,
+      );
+      expect(processManager, hasNoRemainingExpectations);
+    });
+  });
+
+  group('gitRevision', () {
+    test('Return short format', () async {
+      const String commitHash = 'e65f01793938e13cac2d321b9fcdc7939f9b2ea6';
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: <String>['git', 'rev-parse', 'HEAD'],
+            stdout: commitHash,
+          ),
+        ],
+      );
+      final String revision = await gitRevision(processManager: processManager);
+      expect(processManager, hasNoRemainingExpectations);
+      expect(revision, commitHash.substring(0, 10));
+    });
+
+    test('Return full length', () async {
+      const String commitHash = 'e65f01793938e13cac2d321b9fcdc7939f9b2ea6';
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: <String>['git', 'rev-parse', 'HEAD'],
+            stdout: commitHash,
+          ),
+        ],
+      );
+      final String revision = await gitRevision(fullLength: true, processManager: processManager);
+      expect(processManager, hasNoRemainingExpectations);
+      expect(revision, commitHash);
+    });
+  });
+
+  group('runProcessWithValidation', () {
+    test('With no error', () async {
+      const List<String> command = <String>['git', 'rev-parse', 'HEAD'];
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: command,
+          ),
+        ],
+      );
+      await runProcessWithValidations(command, '', processManager: processManager);
+      expect(processManager, hasNoRemainingExpectations);
+    });
+
+    test('With error', () async {
+      const List<String> command = <String>['git', 'rev-parse', 'HEAD'];
+      final ProcessManager processManager = FakeProcessManager.list(
+        <FakeCommand>[
+          const FakeCommand(
+            command: command,
+            exitCode: 1,
+          ),
+        ],
+      );
+      try {
+        await runProcessWithValidations(command, '', processManager: processManager);
+        throw Exception('Exception was not thrown');
+      } on CommandException catch (e) {
+        expect(e, isA<Exception>());
+      }
+    });
+  });
+
+  group('generateFooter', () {
+    test('generated correctly', () async {
+      const String expectedContent = '''
+(function() {
+  var span = document.querySelector('footer>span');
+  if (span) {
+    span.innerText = 'Flutter 3.0.0 • 2022-09-22 14:09 • abcdef • stable';
+  }
+  var sourceLink = document.querySelector('a.source-link');
+  if (sourceLink) {
+    sourceLink.href = sourceLink.href.replace('/master/', '/abcdef/');
+  }
+})();
+''';
+      final MemoryFileSystem fs = MemoryFileSystem();
+      final File footerFile = fs.file('/a/b/c/footer.js')..createSync(recursive: true);
+      await createFooter(footerFile, '3.0.0', timestampParam: '2022-09-22 14:09', branchParam: 'stable', revisionParam: 'abcdef');
+      final String content = await footerFile.readAsString();
+      expect(content, expectedContent);
+    });
+  });
+}