diff --git a/dev/bots/post_process_docs.dart b/dev/bots/post_process_docs.dart new file mode 100644 index 0000000000..8be41235fa --- /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 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 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( + [ + 'curl', + '-L', + 'https://storage.googleapis.com/flutter_infra_release/flutter/$revision/api_docs.zip', + '--output', + zipDestination, + '--fail', + ], + docsPath, + ); + + // Unzip to docs folder. + await runProcessWithValidations( + [ + 'unzip', + '-o', + zipDestination, + ], + docsPath, + ); + + // Generate versions file. + await runProcessWithValidations( + ['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 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(['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 runProcessWithValidations( + List 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 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(['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 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 3ad830be11..00cc658b4f 100644 --- a/dev/bots/pubspec.yaml +++ b/dev/bots/pubspec.yaml @@ -7,6 +7,7 @@ environment: 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 @@ dependencies: 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 @@ dependencies: 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 0000000000..043d722a2f --- /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: { + '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: {}, + ); + + final ProcessManager processManager = FakeProcessManager.list( + [ + const FakeCommand( + command: ['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: { + 'LUCI_BRANCH': '', + }, + ); + + final ProcessManager processManager = FakeProcessManager.list( + [ + const FakeCommand( + command: ['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( + [ + const FakeCommand( + command: ['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( + [ + const FakeCommand( + command: ['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 command = ['git', 'rev-parse', 'HEAD']; + final ProcessManager processManager = FakeProcessManager.list( + [ + const FakeCommand( + command: command, + ), + ], + ); + await runProcessWithValidations(command, '', processManager: processManager); + expect(processManager, hasNoRemainingExpectations); + }); + + test('With error', () async { + const List command = ['git', 'rev-parse', 'HEAD']; + final ProcessManager processManager = FakeProcessManager.list( + [ + 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()); + } + }); + }); + + 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); + }); + }); +}