blob: eeb8e2d5665904829b730c612030910443f44eb8 [file] [log] [blame] [edit]
// Copyright 2020 The Chromium 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:async';
import 'dart:convert' show jsonDecode;
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:http/http.dart' as http;
import '../model.dart';
const versionArg = 'version';
const sinceArg = 'since-tag';
/// Changelog generator.
///
/// Sample usage:
/// ```
/// $dart tool/bin/repo_tool.dart generate-changelog
/// Retrieving the most recent tag...
/// Getting the date of the tagged commit for v2.4.0...
/// Adding entries to the changelog for all commits since 2021-07-08T16:51:57Z...
/// Skipping commit 9cfec998746f71bc533bd6617ae80f407560a952 because this is the commit of the previous tag
/// Incrementing version number...
/// Incremented version number for the changelog from 2.4.0 to 2.5.0. Note that this is not inserted to any files other than changelog.
/// Wrote the following output to /Users/kenzieschmoll/develop/devtools/packages/devtools/CHANGELOG.md:
/// ## 2.5.0
/// * Fix debug buttons layout overflow [#3224](https://github.com/flutter/devtools/pull/3224)
/// * Make return key submit connect form [#3228](https://github.com/flutter/devtools/pull/3228)
/// * Minor analysis updates to the repo [#3225](https://github.com/flutter/devtools/pull/3225)
/// * Always build devtools from a specified, specific sdk version [#3216](https://github.com/flutter/devtools/pull/3216)
/// ...
///
/// Please note that this script is intended to simplify the changelog writing process, not to completely replace it.
/// Please review the generated changelog and tune it by hand to make it easily legible.
/// ```
///
/// The command will write commits after the last tagged non-dev / non-dot
/// release into the changelog file. It will ignore commits with names strictly
/// matching [lowercaseIgnoredCommitNames]. It will then add 1 to the minor
/// version number (eg 2.4.2 => 2.5.0).
///
/// If `--version` is specified from the command
/// (e.g. dart tool/bin/repo_tool.dart generate-changelog --version=2.5.2), this
/// version number will be used for the CHANGELOG entry.
///
/// If `--since-tag` is specified from the command
/// (e.g. dart tool/bin/repo_tool.dart generate-changelog --since-tag=v2.5.2),
/// this tag version will be used as the lower bound for commit history instead
/// of using the most recent tagged version as the lower bound.
class GenerateChangelogCommand extends Command {
GenerateChangelogCommand() {
argParser
..addOption(
versionArg,
help: 'Specify the target changelog version',
)
..addOption(
sinceArg,
help: 'Specify the name of the tag to mark the lower bound',
);
}
static const _authorKey = 'author';
static const _commitKey = 'commit';
static const _dateKey = 'date';
static const _messageKey = 'message';
static const _nameKey = 'name';
static const _shaKey = 'sha';
// You can authorize your access if you run into a github rate limit.
// Don't check in your passwords or auth tokens.
static const auth = '';
/// Commit names to ignore in changelog generation.
///
/// We will only check for exact matches on this, after trimming out the
/// trailing commit number.
static const lowercaseIgnoredCommitNames = [
'update goldens',
'updated goldens',
];
@override
String get name => 'generate-changelog';
@override
String get description =>
'Generates a changelog of all commits since a given tag.';
@override
Future run() async {
final repo = DevToolsRepo.getInstance()!;
String? nextVersion = argResults?[versionArg];
final sinceTag = argResults?[sinceArg];
final List<Map<String, dynamic>> tags = (jsonDecode((await http.get(
Uri.https(
'${auth}api.github.com', '/repos/flutter/devtools/tags')))
.body) as List)
.cast<Map<String, dynamic>>();
bool isDevBuild(String tagName) => tagName.contains('dev');
bool isCherryPickRelease(String tagName) => tagName.contains('+');
String nameOf(tag) => tag[_nameKey];
Map<String, dynamic>? closestTag;
if (sinceTag != null) {
print('Attempting to find tag with name $sinceTag, '
'as specified by --$sinceArg...');
closestTag = tags.firstWhere((tag) => nameOf(tag) == sinceTag);
} else {
print('Retrieving the most recent tag...');
closestTag = tags.skipWhile((tag) {
final skip =
isDevBuild(nameOf(tag)) || isCherryPickRelease(nameOf(tag));
return skip;
}).first;
}
final commitInfo = closestTag[_commitKey] as Map<String, dynamic>;
print(
'Getting the date of the tagged commit for ${closestTag[_nameKey]}...',
);
final taggedCommit = jsonDecode((await http.get(Uri.https(
'${auth}api.github.com',
'/repos/flutter/devtools/commits/${commitInfo[_shaKey]}',
)))
.body);
final commitDate = taggedCommit[_commitKey][_authorKey][_dateKey];
print(
'Adding entries to the changelog for all commits since $commitDate...',
);
final changes = <String>[];
// TODO(kenz): consider traversing pages properly instead of using a while
// loop.
// See https://docs.github.com/en/rest/guides/traversing-with-pagination
const maxPerPage = 100;
var requestPage = 0;
bool lastPageReceived = false;
while (!lastPageReceived) {
final uri = Uri.https(
'${auth}api.github.com',
'/repos/flutter/devtools/commits',
{
'since': commitDate,
'per_page': '$maxPerPage',
'page': '$requestPage',
},
);
final githubResponse = await http.get(uri);
final commits = jsonDecode(githubResponse.body);
for (final commit in commits) {
if (commit[_shaKey] == taggedCommit[_shaKey]) {
print(
'Skipping commit ${commit[_shaKey]} because this is the commit of '
'the previous tag',
);
continue;
}
final message = commit[_commitKey][_messageKey];
if (_shouldSkip(commit[_commitKey][_messageKey])) {
print('Skipping commit marked to be ignored: $message');
continue;
}
final entry = '* ${_sanitize(commit[_commitKey][_messageKey])}';
changes.add(entry);
}
requestPage++;
if (commits.length < maxPerPage) {
lastPageReceived = true;
}
}
if (nextVersion != null) {
print('Using specified version $nextVersion...');
} else {
print('Incrementing version number...');
final currentVersion = nameOf(closestTag).replaceFirst('v', '');
// Increment the minor version by 1 and reset the patch version to 0
// (e.g. 2.4.2 => 2.5.0)
final List<String> parts = currentVersion.split('.');
parts[1] = '${int.parse(parts[1]) + 1}';
parts[2] = '0';
nextVersion = parts.join('.');
print('Incremented version number for the changelog from '
'$currentVersion to $nextVersion. Note that this is not '
'inserted to any files other than changelog.');
}
final changelogFile = File('${repo.repoPath}/CHANGELOG.md');
final output = '## $nextVersion\n${changes.join('\n')}\n\n';
await changelogFile.writeAsString(
output + changelogFile.readAsStringSync(),
);
print('Wrote the following output to ${changelogFile.path}:\n$output');
print('Please note that this script is intended to simplify the changelog '
'writing process, not to completely replace it. Please review the '
'generated changelog and tune it by hand to make it easily legible.');
}
String _sanitize(String message) {
try {
var modifiedMessage = message.split('\n').first;
modifiedMessage = modifiedMessage.substring(0, 1).toUpperCase() +
modifiedMessage.substring(1, modifiedMessage.length);
const prPrefix = '(#';
final periodNumberIndex = modifiedMessage.lastIndexOf('. $prPrefix');
if (periodNumberIndex != -1) {
modifiedMessage = modifiedMessage.replaceFirst(
'. $prPrefix',
prPrefix,
periodNumberIndex,
);
}
final prIndex = modifiedMessage.indexOf(prPrefix);
final endPrIndexExclusive = modifiedMessage.lastIndexOf(')');
final pr = modifiedMessage.substring(
prIndex + prPrefix.length,
endPrIndexExclusive,
);
return '${modifiedMessage.substring(0, prIndex).trim()}'
' [#$pr](https://github.com/flutter/devtools/pull/$pr)';
} catch (_) {
return '# Something went wrong. Please input this CHANGELOG entry '
'manually: "$message"';
}
}
bool _shouldSkip(String message) {
message = message.split('\n').first;
message = message.replaceAll(RegExp('\\(#\\d*\\)'), '').trim();
for (final ignore in lowercaseIgnoredCommitNames) {
if (message.toLowerCase().contains(ignore)) {
return true;
}
}
return false;
}
}