blob: f70abc0e132c93126248d615c84ca5eebfae44e6 [file] [log] [blame]
// 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,
'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;
}
}
releaseNotesMd.writeAsStringSync('''---
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(
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');
}