| // Copyright 2024 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. | |
| import 'dart:async'; | |
| import 'dart:io'; | |
| import 'package:args/command_runner.dart'; | |
| import 'package:cli_util/cli_logging.dart'; | |
| import 'package:devtools_tool/model.dart'; | |
| import 'package:devtools_tool/utils.dart'; | |
| import 'package:io/io.dart'; | |
| import 'package:path/path.dart' as p; | |
| class ReleaseNotesCommand extends Command { | |
| ReleaseNotesCommand() { | |
| argParser | |
| ..addOption( | |
| _websiteRepoPath, | |
| abbr: 'w', | |
| help: 'The absolute path to the flutter/website repo clone on disk.', | |
| ) | |
| ..addFlag( | |
| _useCurrentBranch, | |
| abbr: 'c', | |
| help: | |
| 'Whether to use the current branch on the local flutter/website ' | |
| 'checkout instead of creating a new one.', | |
| ); | |
| } | |
| static const _websiteRepoPath = 'website-repo'; | |
| static const _useCurrentBranch = 'use-current-branch'; | |
| @override | |
| String get description => | |
| 'Creates a PR on the flutter/website repo with the current release notes.'; | |
| @override | |
| String get name => 'release-notes'; | |
| @override | |
| FutureOr? run() async { | |
| final log = Logger.standard(); | |
| final processManager = ProcessManager(); | |
| final devToolsReleaseNotesDirectory = Directory( | |
| p.join( | |
| DevToolsRepo.getInstance().devtoolsAppDirectoryPath, | |
| 'release_notes', | |
| ), | |
| ); | |
| final devToolsReleaseNotes = _DevToolsReleaseNotes.fromFile( | |
| File(p.join(devToolsReleaseNotesDirectory.path, 'NEXT_RELEASE_NOTES.md')), | |
| ); | |
| final releaseNotesVersion = devToolsReleaseNotes.version; | |
| log.stdout( | |
| 'Drafting release notes for DevTools version $releaseNotesVersion...', | |
| ); | |
| // Maybe create a new branch on the flutter/website repo. | |
| final websiteRepoPath = argResults![_websiteRepoPath] as String; | |
| final useCurrentBranch = argResults![_useCurrentBranch] as bool; | |
| if (!useCurrentBranch) { | |
| try { | |
| await processManager.runAll( | |
| commands: [ | |
| CliCommand.git(['stash']), | |
| CliCommand.git(['checkout', 'main']), | |
| CliCommand.git(['pull']), | |
| CliCommand.git([ | |
| 'checkout', | |
| '-b', | |
| 'devtools-release-notes-$releaseNotesVersion', | |
| ]), | |
| ], | |
| workingDirectory: websiteRepoPath, | |
| ); | |
| } catch (e) { | |
| log.stderr( | |
| 'Something went wrong while trying to prepare a branch on the ' | |
| 'flutter/website repo. Please make sure your flutter/website clone ' | |
| 'is set up as specified by the contributing instructions: ' | |
| 'https://github.com/flutter/website?tab=readme-ov-file#contributing.' | |
| '\n\n$e', | |
| ); | |
| return; | |
| } | |
| } | |
| final websiteReleaseNotesDir = Directory( | |
| p.join( | |
| websiteRepoPath, | |
| 'sites', | |
| 'docs', | |
| 'src', | |
| 'content', | |
| 'tools', | |
| 'devtools', | |
| 'release-notes', | |
| ), | |
| ); | |
| if (!websiteReleaseNotesDir.existsSync()) { | |
| throw FileSystemException( | |
| 'Website release notes directory does not exist.', | |
| websiteReleaseNotesDir.path, | |
| ); | |
| } | |
| // Write the 'release-notes-<x.y.z>.md' file. | |
| final releaseNotesMd = File( | |
| p.join( | |
| websiteReleaseNotesDir.path, | |
| 'release-notes-$releaseNotesVersion.md', | |
| ), | |
| )..createSync(); | |
| final srcLines = devToolsReleaseNotes.sourceLines; | |
| // Copy release notes images and fix image reference paths. | |
| if (devToolsReleaseNotes.imageLineIndices.isNotEmpty) { | |
| // This set of release notes contains images. Perform the line | |
| // transformations and copy the image files. | |
| final websiteImagesDirName = 'images-$releaseNotesVersion'; | |
| final devtoolsImagesDir = Directory( | |
| p.join(devToolsReleaseNotesDirectory.path, 'images'), | |
| ); | |
| final websiteImagesDir = Directory( | |
| p.join(websiteReleaseNotesDir.path, websiteImagesDirName), | |
| )..createSync(); | |
| await copyPath(devtoolsImagesDir.path, websiteImagesDir.path); | |
| // Remove the .gitkeep file that was copied over. | |
| File(p.join(websiteImagesDir.path, '.gitkeep')).deleteSync(); | |
| for (final index in devToolsReleaseNotes.imageLineIndices) { | |
| final line = srcLines[index]; | |
| final transformed = line.replaceFirst( | |
| _DevToolsReleaseNotes._imagePathMarker, | |
| '/tools/devtools/release-notes/$websiteImagesDirName', | |
| ); | |
| srcLines[index] = transformed; | |
| } | |
| } | |
| final metadataHeader = | |
| '''--- | |
| title: DevTools $releaseNotesVersion release notes | |
| shortTitle: $releaseNotesVersion release notes | |
| breadcrumb: $releaseNotesVersion | |
| showToc: false | |
| --- | |
| '''; | |
| // Write the contents of the 'release-notes-<x.y.z>.md' file, | |
| // including any updates for image paths. | |
| releaseNotesMd.writeAsStringSync( | |
| metadataHeader + srcLines.joinWithNewLine().trimLeft(), | |
| flush: true, | |
| ); | |
| const firstPartInstructions = | |
| 'Release notes successfully drafted in a local flutter/website branch. ' | |
| 'Please clean them up by deleting empty sections and fixing any ' | |
| 'grammar mistakes or typos. Run the following to open the release ' | |
| 'notes source file:'; | |
| log.stdout(''' | |
| $firstPartInstructions | |
| cd $websiteRepoPath; | |
| code ${releaseNotesMd.absolute.path} | |
| Create a PR on the flutter/website repo when you are finished. | |
| '''); | |
| } | |
| } | |
| class _DevToolsReleaseNotes { | |
| _DevToolsReleaseNotes._({ | |
| required this.file, | |
| required this.version, | |
| required this.sourceLines, | |
| required this.imageLineIndices, | |
| }); | |
| factory _DevToolsReleaseNotes.fromFile(File file) { | |
| if (!file.existsSync()) { | |
| throw FileSystemException( | |
| 'NEXT_RELEASE_NOTES.md file does not exist.', | |
| file.path, | |
| ); | |
| } | |
| final rawLines = file.readAsLinesSync(); | |
| String? version; | |
| int? sourceStartIndex; | |
| final versionRegExp = RegExp(r"\d+\.\d+\.\d+"); | |
| for (int i = 0; i < rawLines.length; i++) { | |
| final line = rawLines[i]; | |
| final matches = versionRegExp.allMatches(line); | |
| if (matches.isEmpty) continue; | |
| // This match should be from the line "# DevTools <x.y.z> release notes". | |
| version = matches.first.group(0)!; | |
| // This is the markdown title where the release notes src begins. | |
| sourceStartIndex = i + 1; | |
| break; | |
| } | |
| if (version == null || sourceStartIndex == null) { | |
| throw Exception( | |
| 'Could not find the title line ("# DevTools x.y.z release notes") ' | |
| 'in the NEXT_RELEASE_NOTES.md file.', | |
| ); | |
| } | |
| if (sourceStartIndex >= rawLines.length) { | |
| throw Exception( | |
| 'No content found after the title line ("# DevTools x.y.z release notes") ' | |
| 'in the NEXT_RELEASE_NOTES.md file.', | |
| ); | |
| } | |
| // Don't include the copyright, draft notice, or h1 header in | |
| // the output release notes. | |
| final sourceLines = rawLines.sublist(sourceStartIndex); | |
| // TODO(kenz): one nice polish task could be to remove sections that are | |
| // empty (i.e. sections that have the line | |
| // "TODO: Remove this section if there are not any updates."). | |
| final imageLineIndices = <int>{}; | |
| for (int i = 0; i < sourceLines.length; i++) { | |
| final line = sourceLines[i]; | |
| if (line.contains(_imagePathMarker)) { | |
| imageLineIndices.add(i); | |
| } | |
| } | |
| return _DevToolsReleaseNotes._( | |
| file: file, | |
| version: version, | |
| sourceLines: sourceLines, | |
| imageLineIndices: imageLineIndices, | |
| ); | |
| } | |
| final File file; | |
| final String version; | |
| final List<String> sourceLines; | |
| final Set<int> imageLineIndices; | |
| static final _imagePathMarker = RegExp(r'images\/.*\.png'); | |
| } |