Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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>
Prefix for version tags (defaults to "v")
--package=<name> Package name for workspace tags (package/vX.Y.Z)
```

## Version
Expand Down
200 changes: 169 additions & 31 deletions lib/changelog_generator/changelog_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = <String>{'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.
Expand All @@ -36,53 +41,116 @@ class ChangelogGenerator {
return GitUtils.getCommitsSinceLastTag(projectRoot.path, currentVersion);
}

/// These are the commit types that should trigger a release.
final releasableCommitTypes = <String>{'feat', 'fix', 'perf'};

/// Checks whether a list of commits has commits that can be released.
bool _hasReleasableCommits(List<Commit> 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<String> 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<String> 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();
}
Expand Down Expand Up @@ -110,6 +178,76 @@ class ChangelogGenerator {
logger.info('${changelogFile.absolute.path} updated successfully.');
}

/// Overwrites CHANGELOG.md with a full regeneration from git tags.
Future<void> 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<String> _generateReleaseEntry({
required String version,
required List<ApiChange> apiChanges,
required List<Commit> 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<Commit> commits) {
return commits.any((commit) => releasableCommitTypes.contains(commit.type));
}

/// Retrieves the package version and homepage from pubspec.yaml
Future<Map<String, String?>> _getPubspecInfo() async {
try {
Expand Down
63 changes: 60 additions & 3 deletions lib/changelog_generator/changelog_generator_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down
32 changes: 32 additions & 0 deletions lib/doc_generator/git_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,38 @@ class GitUtils {
return '$remoteUrl/compare/$baseRef..$newRef#diff-$digest';
}

static Future<String?> 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<DateTime?> 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<List<Commit>> getCommits({required String root, String? fromRef, String? toRef}) async {
Expand Down
Loading
Loading