diff --git a/README.md b/README.md index 5944968..2cd4339 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,12 @@ Generate a changelog for the specified interval. Defaults to generating a change mtrust_api_guard changelog ``` +Regenerate the entire `CHANGELOG.md` by walking version tags and comparing each consecutive release pair. This overwrites the file. + +```sh +mtrust_api_guard changelog --regenerate +``` + ``` -r, --root Root directory of the Dart project. Defaults to auto-detect from the current directory. -b, --base-ref The previous version to compare against. Defaults to previous version from git history. @@ -248,6 +254,12 @@ mtrust_api_guard changelog -h, --help Print this usage information. -u, --[no-]update Update the CHANGELOG.md file (defaults to on) + --[no-]regenerate + Regenerate the entire CHANGELOG.md from version tags, + overwriting the file + --tag-prefix= + Prefix for version tags (defaults to "v") + --package= Package name for workspace tags (package/vX.Y.Z) ``` ## Version diff --git a/lib/changelog_generator/changelog_generator.dart b/lib/changelog_generator/changelog_generator.dart index 1983f09..32dc91d 100644 --- a/lib/changelog_generator/changelog_generator.dart +++ b/lib/changelog_generator/changelog_generator.dart @@ -3,9 +3,11 @@ import 'dart:io'; import 'package:conventional/conventional.dart'; import 'package:mtrust_api_guard/doc_comparator/api_change.dart'; import 'package:mtrust_api_guard/doc_comparator/api_change_formatter.dart'; +import 'package:mtrust_api_guard/doc_comparator/doc_comparator.dart'; import 'package:mtrust_api_guard/doc_generator/git_utils.dart'; import 'package:mtrust_api_guard/logger.dart'; import 'package:path/path.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; /// Generates a changelog entry based on the API changes and the current version from pubspec.yaml @@ -23,6 +25,9 @@ class ChangelogGenerator { ChangelogGenerator({required this.apiChanges, required this.projectRoot, this.baseRef, this.newRef}); + /// These are the commit types that should trigger a release. + static const releasableCommitTypes = {'feat', 'fix', 'perf'}; + /// Gets the commits since the last version tag /// If the latest tag matches the current version (e.g. CI running on tagged commit), /// it gets the commits since the previous tag. @@ -36,53 +41,116 @@ class ChangelogGenerator { return GitUtils.getCommitsSinceLastTag(projectRoot.path, currentVersion); } - /// These are the commit types that should trigger a release. - final releasableCommitTypes = {'feat', 'fix', 'perf'}; - - /// Checks whether a list of commits has commits that can be released. - bool _hasReleasableCommits(List commits) { - return commits.any((commit) => releasableCommitTypes.contains(commit.type)); - } - /// Generates a changelog entry, including the version from pubspec.yaml /// and the formatted API changes Future generateChangelogEntry() async { final pubspecInfo = await _getPubspecInfo(); final version = pubspecInfo['version']!; - - final remoteUrl = await GitUtils.getRemoteUrl(projectRoot.path); - - String? fileUrlBuilder(String filePath) { - return GitUtils.buildCompareUrl(remoteUrl, baseRef, newRef, filePath); - } - - final apiChangesFormatter = ApiChangeFormatter(apiChanges, markdownHeaderLevel: 4, fileUrlBuilder: fileUrlBuilder); - final formattedChanges = apiChangesFormatter.format(); final commits = await _getCommitsSinceLastVersion(); logger.detail("Commits since last version: ${commits.length}"); - final buffer = StringBuffer(); - final time = DateTime.now(); + return _generateReleaseEntry( + version: version, + apiChanges: apiChanges, + commits: commits, + baseRef: baseRef, + newRef: newRef, + ); + } - buffer.writeln('## $version'); - buffer.writeln('Released on: ${time.month}/${time.day}/${time.year}, changelog automatically generated.'); + /// Regenerates the entire CHANGELOG.md by walking version tags and comparing each release. + Future regenerateFullChangelog({ + required Directory gitRoot, + required bool cache, + String tagPrefix = 'v', + String? packageName, + }) async { + final tags = packageName != null + ? await GitUtils.getVersionsForPackage(gitRoot.path, packageName, tagPrefix: tagPrefix) + : await GitUtils.getVersions(gitRoot.path, tagPrefix: tagPrefix); + + if (tags.isEmpty) { + throw Exception('No version tags found to regenerate changelog from'); + } - if (_hasReleasableCommits(commits)) { - logger.detail("Has releasable commits, generating changelog summary"); - final summary = await changelogSummary(commits: commits, version: version); - if (summary is ChangeSummary) { - buffer.writeln('\n'); - buffer.writeln(_filterSectionsFromMarkdown(summary.toMarkdown())); + final rootCommit = await GitUtils.getRootCommit(gitRoot.path); + final releasePairs = <({String tag, Version version, String baseRef})>[]; + + String? previousTag; + for (final (tag, version) in tags) { + final compareBase = previousTag ?? rootCommit; + if (compareBase == null) { + throw Exception('Could not determine base ref for first release $tag'); } + releasePairs.add((tag: tag, version: version, baseRef: compareBase)); + previousTag = tag; } - if (formattedChanges.isNotEmpty) { - buffer.writeln('\n### API Changes'); - buffer.writeln(formattedChanges); + final buffer = StringBuffer(); + final pubspecInfo = await _getPubspecInfo(); + final pubspecVersion = Version.parse(pubspecInfo['version']!); + final latestTaggedVersion = tags.last.$2; + + if (pubspecVersion > latestTaggedVersion) { + final latestTag = tags.last.$1; + logger.info('Regenerating unreleased section ($latestTag..HEAD)'); + + final unreleasedChanges = await compare( + baseRef: latestTag, + newRef: 'HEAD', + dartRoot: projectRoot, + gitRoot: gitRoot, + cache: cache, + ); + + final unreleasedCommits = await GitUtils.getCommits( + root: gitRoot.path, + fromRef: latestTag, + toRef: 'HEAD', + ); + + buffer.write( + await _generateReleaseEntry( + version: 'Unreleased', + apiChanges: unreleasedChanges, + commits: unreleasedCommits, + baseRef: latestTag, + newRef: 'HEAD', + ), + ); } - buffer.writeln(); + for (final release in releasePairs.reversed) { + logger.info('Regenerating changelog for ${release.version} (${release.baseRef}..${release.tag})'); + + final changes = await compare( + baseRef: release.baseRef, + newRef: release.tag, + dartRoot: projectRoot, + gitRoot: gitRoot, + cache: cache, + ); + + final commits = await GitUtils.getCommits( + root: gitRoot.path, + fromRef: release.baseRef, + toRef: release.tag, + ); + + final releasedAt = await GitUtils.getTagDate(release.tag, gitRoot.path); + + buffer.write( + await _generateReleaseEntry( + version: release.version.toString(), + apiChanges: changes, + commits: commits, + baseRef: release.baseRef, + newRef: release.tag, + releasedAt: releasedAt, + ), + ); + } return buffer.toString(); } @@ -110,6 +178,76 @@ class ChangelogGenerator { logger.info('${changelogFile.absolute.path} updated successfully.'); } + /// Overwrites CHANGELOG.md with a full regeneration from git tags. + Future regenerateChangelogFile({ + required Directory gitRoot, + required bool cache, + String tagPrefix = 'v', + String? packageName, + }) async { + final changelogFile = File(join(projectRoot.path, 'CHANGELOG.md')); + final content = await regenerateFullChangelog( + gitRoot: gitRoot, + cache: cache, + tagPrefix: tagPrefix, + packageName: packageName, + ); + await changelogFile.writeAsString(content); + logger.info('${changelogFile.absolute.path} regenerated successfully.'); + } + + Future _generateReleaseEntry({ + required String version, + required List apiChanges, + required List commits, + String? baseRef, + String? newRef, + DateTime? releasedAt, + }) async { + final remoteUrl = await GitUtils.getRemoteUrl(projectRoot.path); + + String? fileUrlBuilder(String filePath) { + return GitUtils.buildCompareUrl(remoteUrl, baseRef, newRef, filePath); + } + + final apiChangesFormatter = ApiChangeFormatter(apiChanges, markdownHeaderLevel: 4, fileUrlBuilder: fileUrlBuilder); + final formattedChanges = apiChangesFormatter.format(); + + final buffer = StringBuffer(); + final time = releasedAt ?? DateTime.now(); + + buffer.writeln('## $version'); + if (version == 'Unreleased') { + buffer.writeln(); + } else { + buffer.writeln('Released on: ${time.month}/${time.day}/${time.year}, changelog automatically generated.'); + } + + if (_hasReleasableCommits(commits)) { + logger.detail("Has releasable commits for $version, generating changelog summary"); + final summaryVersion = version == 'Unreleased' ? (await _getPubspecInfo())['version']! : version; + final summary = await changelogSummary(commits: commits, version: summaryVersion); + if (summary is ChangeSummary) { + buffer.writeln('\n'); + buffer.writeln(_filterSectionsFromMarkdown(summary.toMarkdown())); + } + } + + if (formattedChanges.isNotEmpty) { + buffer.writeln('\n### API Changes'); + buffer.writeln(formattedChanges); + } + + buffer.writeln(); + + return buffer.toString(); + } + + /// Checks whether a list of commits has commits that can be released. + bool _hasReleasableCommits(List commits) { + return commits.any((commit) => releasableCommitTypes.contains(commit.type)); + } + /// Retrieves the package version and homepage from pubspec.yaml Future> _getPubspecInfo() async { try { diff --git a/lib/changelog_generator/changelog_generator_command.dart b/lib/changelog_generator/changelog_generator_command.dart index 0911b27..1482742 100644 --- a/lib/changelog_generator/changelog_generator_command.dart +++ b/lib/changelog_generator/changelog_generator_command.dart @@ -29,21 +29,78 @@ class ChangelogGeneratorCommand extends Command } ChangelogGeneratorCommand._internal() { - argParser.addFlag('update', abbr: 'u', help: 'Update the CHANGELOG.md file', defaultsTo: true); + argParser + ..addFlag('update', abbr: 'u', help: 'Update the CHANGELOG.md file', defaultsTo: true) + ..addFlag( + 'regenerate', + help: 'Regenerate the entire CHANGELOG.md from version tags, overwriting the file', + defaultsTo: false, + ) + ..addOption( + 'tag-prefix', + help: 'Prefix for version tags (e.g. v for v1.0.0)', + defaultsTo: 'v', + valueHelp: 'prefix', + ) + ..addOption( + 'package', + help: 'Package name for workspace tags in the format package/vX.Y.Z', + valueHelp: 'name', + ); } bool get update { return argResults?['update'] as bool; } + bool get regenerate { + return argResults?['regenerate'] as bool; + } + + String get tagPrefix { + return argResults?['tag-prefix'] as String? ?? 'v'; + } + + String? get packageName { + return argResults?['package'] as String?; + } + @override FutureOr? run() async { - final resolvedBaseRef = baseRef ?? await GitUtils.getPreviousRef(Directory.current.path); + final gitRoot = Directory.current; + + if (regenerate) { + final changelogGenerator = ChangelogGenerator( + apiChanges: const [], + projectRoot: root, + ); + + if (update) { + logger.info('Regenerating CHANGELOG.md from version tags'); + await changelogGenerator.regenerateChangelogFile( + gitRoot: gitRoot, + cache: cache, + tagPrefix: tagPrefix, + packageName: packageName, + ); + } else { + final changelog = await changelogGenerator.regenerateFullChangelog( + gitRoot: gitRoot, + cache: cache, + tagPrefix: tagPrefix, + packageName: packageName, + ); + print('\n$changelog'); + } + return; + } + + final resolvedBaseRef = baseRef ?? await GitUtils.getPreviousRef(gitRoot.path, tagPrefix: tagPrefix); final changes = await compare( baseRef: resolvedBaseRef, newRef: newRef, dartRoot: root, - gitRoot: Directory.current, + gitRoot: gitRoot, cache: cache, ); diff --git a/lib/doc_generator/git_utils.dart b/lib/doc_generator/git_utils.dart index c4afa0e..bc97036 100644 --- a/lib/doc_generator/git_utils.dart +++ b/lib/doc_generator/git_utils.dart @@ -325,6 +325,38 @@ class GitUtils { return '$remoteUrl/compare/$baseRef..$newRef#diff-$digest'; } + static Future getRootCommit(String? root) async { + try { + final result = await Process.run('git', ['rev-list', '--max-parents=0', 'HEAD'], workingDirectory: root); + if (result.exitCode != 0) { + return null; + } + final commits = result.stdout.toString().trim().split('\n').where((line) => line.isNotEmpty); + return commits.isEmpty ? null : commits.first; + } catch (e) { + logger.detail('Error retrieving root commit: $e'); + return null; + } + } + + /// Gets the date of a git tag + static Future getTagDate(String tag, String? root) async { + try { + final result = await Process.run('git', ['log', '-1', '--format=%ci', tag], workingDirectory: root); + if (result.exitCode != 0) { + return null; + } + final date = result.stdout.toString().trim(); + if (date.isEmpty) { + return null; + } + return DateTime.tryParse(date); + } catch (e) { + logger.detail('Error retrieving tag date for $tag: $e'); + return null; + } + } + /// Gets the commits between two refs /// If [fromRef] is null, it gets all commits up to [toRef] (or HEAD if [toRef] is null) static Future> getCommits({required String root, String? fromRef, String? toRef}) async { diff --git a/test/commands/changelog_command_test.dart b/test/commands/changelog_command_test.dart new file mode 100644 index 0000000..1afd152 --- /dev/null +++ b/test/commands/changelog_command_test.dart @@ -0,0 +1,87 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import '../helpers/test_setup.dart'; +import '../helpers/test_helpers.dart'; + +void main() { + group('Changelog Command Tests', () { + late TestSetup testSetup; + + setUp(() async { + testSetup = TestSetup(); + await testSetup.setUp(); + }); + + tearDown(() async { + await testSetup.tearDown(); + }); + + Future updatePubspec(String filePath, List path, dynamic value) async { + final pubspecFile = File(filePath); + final pubspecContent = await pubspecFile.readAsString(); + final editor = YamlEditor(pubspecContent); + editor.update(path, value); + await pubspecFile.writeAsString(editor.toString()); + } + + test('regenerate overwrites CHANGELOG.md from version tags', () async { + await testSetup.setupGitRepo(); + await testSetup.setupFlutterPackage(); + await copyDir(testSetup.fixtures.appV100Dir, testSetup.tempDir); + + final pubspecFile = File(p.join(testSetup.tempDir.path, 'pubspec.yaml')); + await updatePubspec(pubspecFile.path, ['homepage'], 'https://github.com/emdgroup/mtrust-api-guard/'); + + await testSetup.commitChanges('chore!: Initial release v${TestConstants.initialVersion}'); + await runProcess('git', ['tag', 'v${TestConstants.initialVersion}'], workingDir: testSetup.tempDir.path); + + await copyDir(testSetup.fixtures.appV101Dir, testSetup.tempDir); + testSetup.setVersion(TestConstants.patchVersion); + await testSetup.commitChanges('fix: API changes for v${TestConstants.patchVersion}'); + await runProcess('git', ['tag', 'v${TestConstants.patchVersion}'], workingDir: testSetup.tempDir.path); + + await testSetup.runApiGuard('changelog', ['--regenerate']); + + final changelogFile = File(p.join(testSetup.tempDir.path, 'CHANGELOG.md')); + expect(changelogFile.existsSync(), isTrue); + + final changelog = await changelogFile.readAsString(); + printOnFailure('Regenerated changelog:\n$changelog'); + + final versionHeaders = RegExp(r'^## (.+)$', multiLine: true).allMatches(changelog).map((m) => m.group(1)).toList(); + + expect(versionHeaders.first, TestConstants.patchVersion); + expect(versionHeaders, contains(TestConstants.initialVersion)); + expect(versionHeaders.indexOf(TestConstants.patchVersion), lessThan(versionHeaders.indexOf(TestConstants.initialVersion))); + expect(changelog, contains('### API Changes')); + expect(changelog, contains('Released on:')); + }, timeout: const Timeout(Duration(minutes: 3))); + + test('regenerate includes unreleased section when pubspec is ahead of latest tag', () async { + await testSetup.setupGitRepo(); + await testSetup.setupFlutterPackage(); + await copyDir(testSetup.fixtures.appV100Dir, testSetup.tempDir); + + await testSetup.commitChanges('chore!: Initial release v${TestConstants.initialVersion}'); + await runProcess('git', ['tag', 'v${TestConstants.initialVersion}'], workingDir: testSetup.tempDir.path); + + testSetup.setVersion(TestConstants.patchVersion); + await copyDir(testSetup.fixtures.appV101Dir, testSetup.tempDir); + await testSetup.commitChanges('fix: unreleased API changes'); + + await testSetup.runApiGuard('changelog', ['--regenerate']); + + final changelog = await File(p.join(testSetup.tempDir.path, 'CHANGELOG.md')).readAsString(); + printOnFailure('Regenerated changelog:\n$changelog'); + + expect(changelog.startsWith('## Unreleased'), isTrue); + expect(changelog, contains('## ${TestConstants.initialVersion}')); + }, timeout: const Timeout(Duration(minutes: 3))); + }); +}