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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,13 @@ mtrust_api_guard changelog --regenerate
--tag-prefix=<prefix>
Prefix for version tags (defaults to "v")
--package=<name> Package name for workspace tags (package/vX.Y.Z)
--concurrency=<count>
Number of refs to analyze in parallel when regenerating
(defaults to "4")
```

When regenerating, the API documentation for each tag is analyzed in parallel (bounded by `--concurrency`). The first tagged release is diffed against the repository's root commit; if that commit has no analyzable Dart project (e.g. an empty initial commit), the API diff for that release is skipped and only its commit summary is shown.

## Version

Detects the API changes that occured and creates a changelog, version bump, version badge and tag automatically.
Expand Down
153 changes: 130 additions & 23 deletions lib/changelog_generator/changelog_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ 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_comparator/get_ref.dart';
import 'package:mtrust_api_guard/doc_generator/git_utils.dart';
import 'package:mtrust_api_guard/logger.dart';
import 'package:mtrust_api_guard/models/package_info.dart';
import 'package:path/path.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
Expand Down Expand Up @@ -60,11 +62,19 @@ class ChangelogGenerator {
}

/// Regenerates the entire CHANGELOG.md by walking version tags and comparing each release.
///
/// The API documentation for each unique ref is analyzed up front, in parallel
/// (bounded by [concurrency]), since that is the expensive part. The per-release
/// diffs are then computed from the in-memory results. Refs that cannot be
/// analyzed (e.g. an empty initial commit with no pubspec.yaml) are skipped
/// gracefully: the affected release still gets its commit-based entry, just
/// without an API Changes section.
Future<String> regenerateFullChangelog({
required Directory gitRoot,
required bool cache,
String tagPrefix = 'v',
String? packageName,
int concurrency = 4,
}) async {
final tags = packageName != null
? await GitUtils.getVersionsForPackage(gitRoot.path, packageName, tagPrefix: tagPrefix)
Expand All @@ -75,34 +85,57 @@ class ChangelogGenerator {
}

final rootCommit = await GitUtils.getRootCommit(gitRoot.path);
final releasePairs = <({String tag, Version version, String baseRef})>[];
final releasePairs = <({String tag, Version version, String? baseRef})>[];

String? previousTag;
for (final (tag, version) in tags) {
// The very first release has no previous tag. We fall back to the repo
// root commit, but it may be an empty initial commit without a Dart
// project, in which case the diff is skipped further down.
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;
}

final buffer = StringBuffer();
final pubspecInfo = await _getPubspecInfo();
final pubspecVersion = Version.parse(pubspecInfo['version']!);
final latestTaggedVersion = tags.last.$2;
final latestTag = tags.last.$1;
final hasUnreleased = pubspecVersion > latestTaggedVersion;

// Analyze all unique cacheable refs (tags + base commits) in parallel.
// HEAD is handled separately because it is analyzed from the working
// directory and is never cached.
final cacheableRefs = <String>{};
for (final release in releasePairs) {
if (release.baseRef != null) cacheableRefs.add(release.baseRef!);
cacheableRefs.add(release.tag);
}

final apisByRef = await _analyzeRefsInParallel(
refs: cacheableRefs,
gitRoot: gitRoot,
cache: cache,
concurrency: concurrency,
);

PackageApi? headApi;
if (hasUnreleased) {
headApi = await _tryAnalyzeRef('HEAD', gitRoot: gitRoot, cache: cache);
}

final buffer = StringBuffer();

if (pubspecVersion > latestTaggedVersion) {
final latestTag = tags.last.$1;
if (hasUnreleased) {
logger.info('Regenerating unreleased section ($latestTag..HEAD)');

final unreleasedChanges = await compare(
baseRef: latestTag,
newRef: 'HEAD',
dartRoot: projectRoot,
gitRoot: gitRoot,
cache: cache,
);
final baseApi = apisByRef[latestTag];
final unreleasedChanges = (baseApi != null && headApi != null)
? compareApis(baseApi, headApi)
: <ApiChange>[];
if (baseApi == null || headApi == null) {
logger.warn('Skipping API diff for unreleased section: could not analyze $latestTag or HEAD');
}

final unreleasedCommits = await GitUtils.getCommits(
root: gitRoot.path,
Expand All @@ -122,15 +155,22 @@ class ChangelogGenerator {
}

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,
);
logger.info('Regenerating changelog for ${release.version} (${release.baseRef ?? 'initial'}..${release.tag})');

final baseApi = release.baseRef != null ? apisByRef[release.baseRef] : null;
final newApi = apisByRef[release.tag];

List<ApiChange> changes;
if (baseApi != null && newApi != null) {
changes = compareApis(baseApi, newApi);
} else {
changes = <ApiChange>[];
final missing = newApi == null ? release.tag : (release.baseRef ?? 'initial commit');
logger.warn(
'Skipping API diff for ${release.version}: could not analyze $missing '
'(e.g. missing pubspec.yaml at that ref)',
);
}

final commits = await GitUtils.getCommits(
root: gitRoot.path,
Expand All @@ -155,6 +195,71 @@ class ChangelogGenerator {
return buffer.toString();
}

/// Analyzes the API documentation for each ref, deduplicating by resolved
/// commit hash and running up to [concurrency] analyses at a time.
///
/// Returns a map of every input ref to its [PackageApi], or `null` when the
/// ref could not be analyzed.
Future<Map<String, PackageApi?>> _analyzeRefsInParallel({
required Set<String> refs,
required Directory gitRoot,
required bool cache,
required int concurrency,
}) async {
// Deduplicate by resolved commit hash. Distinct refs that point at the same
// commit must not be analyzed concurrently, since they would race on the
// same worktree path.
final refToHash = <String, String>{};
for (final ref in refs) {
try {
refToHash[ref] = await GitUtils.resolveRef(ref, gitRoot.path);
} catch (e) {
logger.warn('Could not resolve ref $ref: $e');
}
}

final representativeRefByHash = <String, String>{};
for (final entry in refToHash.entries) {
representativeRefByHash.putIfAbsent(entry.value, () => entry.key);
}

final uniqueRefs = representativeRefByHash.values.toList();
final batchSize = concurrency < 1 ? 1 : concurrency;
final apiByHash = <String, PackageApi?>{};

for (var i = 0; i < uniqueRefs.length; i += batchSize) {
final batch = uniqueRefs.skip(i).take(batchSize).toList();
final results = await Future.wait(
batch.map((ref) async {
final api = await _tryAnalyzeRef(ref, gitRoot: gitRoot, cache: cache);
return MapEntry(refToHash[ref]!, api);
}),
);
for (final entry in results) {
apiByHash[entry.key] = entry.value;
}
}

final result = <String, PackageApi?>{};
for (final entry in refToHash.entries) {
result[entry.key] = apiByHash[entry.value];
}
return result;
}

Future<PackageApi?> _tryAnalyzeRef(
String ref, {
required Directory gitRoot,
required bool cache,
}) async {
try {
return await getRef(ref: ref, dartRoot: projectRoot, gitRoot: gitRoot, cache: cache);
} catch (e) {
logger.warn('Failed to analyze ref $ref: $e');
return null;
}
}

/// Updates the CHANGELOG.md file with the new entry at the top
Future<void> updateChangelogFile() async {
final changelogFile = File(join(projectRoot.path, 'CHANGELOG.md'));
Expand Down Expand Up @@ -184,13 +289,15 @@ class ChangelogGenerator {
required bool cache,
String tagPrefix = 'v',
String? packageName,
int concurrency = 4,
}) async {
final changelogFile = File(join(projectRoot.path, 'CHANGELOG.md'));
final content = await regenerateFullChangelog(
gitRoot: gitRoot,
cache: cache,
tagPrefix: tagPrefix,
packageName: packageName,
concurrency: concurrency,
);
await changelogFile.writeAsString(content);
logger.info('${changelogFile.absolute.path} regenerated successfully.');
Expand Down
12 changes: 12 additions & 0 deletions lib/changelog_generator/changelog_generator_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class ChangelogGeneratorCommand extends Command
'package',
help: 'Package name for workspace tags in the format package/vX.Y.Z',
valueHelp: 'name',
)
..addOption(
'concurrency',
help: 'Number of refs to analyze in parallel when regenerating',
defaultsTo: '4',
valueHelp: 'count',
);
}

Expand All @@ -65,6 +71,10 @@ class ChangelogGeneratorCommand extends Command
return argResults?['package'] as String?;
}

int get concurrency {
return int.tryParse(argResults?['concurrency'] as String? ?? '4') ?? 4;
}

@override
FutureOr? run() async {
final gitRoot = Directory.current;
Expand All @@ -82,13 +92,15 @@ class ChangelogGeneratorCommand extends Command
cache: cache,
tagPrefix: tagPrefix,
packageName: packageName,
concurrency: concurrency,
);
} else {
final changelog = await changelogGenerator.regenerateFullChangelog(
gitRoot: gitRoot,
cache: cache,
tagPrefix: tagPrefix,
packageName: packageName,
concurrency: concurrency,
);
print('\n$changelog');
}
Expand Down
5 changes: 5 additions & 0 deletions lib/doc_comparator/doc_comparator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Future<List<ApiChange>> compare({
final baseApi = await getRef(ref: baseRef, dartRoot: dartRoot, gitRoot: gitRoot, cache: cache);
final newApi = await getRef(ref: newRef, dartRoot: dartRoot, gitRoot: gitRoot, cache: cache);

return compareApis(baseApi, newApi);
}

/// Computes the API changes between two already-resolved package APIs.
List<ApiChange> compareApis(PackageApi baseApi, PackageApi newApi) {
final apiChanges = baseApi.components.compareTo(newApi.components);

apiChanges.addAll(baseApi.metadata.compareTo(newApi.metadata));
Expand Down
27 changes: 27 additions & 0 deletions test/commands/changelog_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ void main() {
expect(changelog, contains('Released on:'));
}, timeout: const Timeout(Duration(minutes: 3)));

test('regenerate tolerates an empty initial commit with no Dart project', () async {
await testSetup.setupGitRepo();

// Empty initial commit without a pubspec.yaml, mirroring the broken
// first-commit scenario that previously crashed regeneration.
await runProcess('git', ['commit', '--allow-empty', '-m', 'chore: init'], workingDir: testSetup.tempDir.path);

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);

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 changelog = await File(p.join(testSetup.tempDir.path, 'CHANGELOG.md')).readAsString();
printOnFailure('Regenerated changelog:\n$changelog');

final versionHeaders = RegExp(r'^## (.+)$', multiLine: true).allMatches(changelog).map((m) => m.group(1)).toList();
expect(versionHeaders, contains(TestConstants.initialVersion));
expect(versionHeaders, contains(TestConstants.patchVersion));
}, 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();
Expand Down
Loading