Skip to content

Commit

Permalink
feat: optionally allow generating workspace root change logs (#161)
Browse files Browse the repository at this point in the history
Co-authored-by: Salakar <[email protected]>
  • Loading branch information
russellwheatley and Salakar authored Dec 5, 2021
1 parent c63c7d1 commit 56fcdff
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 19 deletions.
25 changes: 10 additions & 15 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,17 @@ packages:
- packages/**
- "*"

command:
version:
# Generate commit links in package changelogs.
linkToCommits: true
# Only allow versioning to happen on main branch.
branch: main
# Additionally build a changelog at the root of the workspace.
workspaceChangelog: true

ide:
intellij:
true
# TODO(Salakar): more granular control
# use_workspace_tasks: true
# use_flutter_test_tasks: true
# use_flutter_run_tasks: true
# use_dart_test_tasks: false
vscode:
false
# TODO(Salakar): more granular control
# use_workspace_tasks: true
# use_flutter_test_tasks: true
# use_flutter_run_tasks: true
# use_dart_test_tasks: false
# use_recommended_settings: true
intellij: true

scripts:
analyze:
Expand Down
3 changes: 2 additions & 1 deletion packages/melos/lib/src/commands/runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import '../common/git_commit.dart';
import '../common/glob.dart';
import '../common/pending_package_update.dart';
import '../common/platform.dart';
import '../common/utils.dart' as utils;
import '../common/utils.dart';
import '../common/utils.dart' as utils;
import '../common/versioning.dart' as versioning;
import '../common/workspace_changelog.dart';
import '../package.dart';
import '../prompts/prompt.dart' as prompts;
import '../scripts.dart';
Expand Down
41 changes: 40 additions & 1 deletion packages/melos/lib/src/commands/version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ mixin _VersionMixin on _RunMixin {
filter: filter?.copyWithUpdatedSince(null),
);

if (workspace.config.commands.version.branch != null) {
final currentBranchName = await gitGetCurrentBranchName(
workingDirectory: workspace.path,
logger: logger,
);
if (currentBranchName != workspace.config.commands.version.branch) {
throw RestrictedBranchException(
workspace.config.commands.version.branch!,
currentBranchName,
);
}
}

message ??=
workspace.config.commands.version.message ?? defaultCommitMessage;

Expand Down Expand Up @@ -184,6 +197,7 @@ Hint: try running "melos version --all" to include private packages.
updateDependentsVersions: updateDependentsVersions,
updateDependentsConstraints: updateDependentsConstraints,
updateChangelog: updateChangelog,
workspace: workspace,
);

// TODO allow support for individual package lifecycle version scripts
Expand All @@ -193,7 +207,7 @@ Hint: try running "melos version --all" to include private packages.
}

if (gitTag) {
await _gitStageChanges(pendingPackageUpdates);
await _gitStageChanges(pendingPackageUpdates, workspace);
await _gitCommitChanges(
workspace,
pendingPackageUpdates,
Expand Down Expand Up @@ -524,6 +538,7 @@ Hint: try running "melos version --all" to include private packages.
required bool updateDependentsVersions,
required bool updateDependentsConstraints,
required bool updateChangelog,
required MelosWorkspace workspace,
}) async {
// Note: not pooling & parrellelzing rights to avoid possible file contention.
await Future.forEach(pendingPackageUpdates,
Expand Down Expand Up @@ -567,6 +582,22 @@ Hint: try running "melos version --all" to include private packages.
}
}
});

// Build a workspace root changelog if enabled.
if (updateChangelog &&
workspace.config.commands.version.workspaceChangelog) {
final today = DateTime.now();
final dateSlug =
"${today.year.toString()}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}";
final workspaceChangelog = WorkspaceChangelog(
workspace,
dateSlug,
pendingPackageUpdates,
logger,
);

await workspaceChangelog.write();
}
}

Set<String> _getPackagesWithVersionableCommits(
Expand Down Expand Up @@ -670,7 +701,15 @@ Hint: try running "melos version --all" to include private packages.

Future<void> _gitStageChanges(
List<MelosPendingPackageUpdate> pendingPackageUpdates,
MelosWorkspace workspace,
) async {
if (workspace.config.commands.version.workspaceChangelog) {
await gitAdd(
'CHANGELOG.md',
workingDirectory: workspace.path,
logger: logger,
);
}
await Future.forEach(pendingPackageUpdates,
(MelosPendingPackageUpdate pendingPackageUpdate) async {
await gitAdd(
Expand Down
6 changes: 5 additions & 1 deletion packages/melos/lib/src/common/changelog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ class MelosChangelog extends Changelog {
}

final commits = update.commits
.where((commit) => !commit.parsedMessage.isMergeCommit)
.where(
(commit) =>
!commit.parsedMessage.isMergeCommit &&
commit.parsedMessage.isVersionableCommit,
)
.toList();

// Sort so that Breaking Changes appear at the top.
Expand Down
11 changes: 11 additions & 0 deletions packages/melos/lib/src/common/exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,14 @@ class CancelledException implements MelosException {
return 'CancelledException: Operation was canceled.';
}
}

class RestrictedBranchException implements MelosException {
RestrictedBranchException(this.allowedBranch, this.currentBranch);
final String allowedBranch;
final String currentBranch;
@override
String toString() {
return 'RestrictedBranchException: This command is configured in melos.yaml to only be '
'allowed to run on the "$allowedBranch" but the current branch is "$currentBranch".';
}
}
209 changes: 209 additions & 0 deletions packages/melos/lib/src/common/workspace_changelog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2016-present Invertase Limited & Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this library except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import 'dart:io';

import 'package:cli_util/cli_logging.dart';
import 'package:conventional_commit/conventional_commit.dart';
import 'package:path/path.dart';

import '../../melos.dart';
import 'git_commit.dart';
import 'pending_package_update.dart';

class WorkspaceChangelog {
WorkspaceChangelog(
this.workspace,
this.title,
this.pendingPackageUpdates,
this.logger,
);

final MelosWorkspace workspace;
final String title;
final Logger logger;
final List<MelosPendingPackageUpdate> pendingPackageUpdates;

String get _changelogFileHeader {
return '# Change Log\n\nAll notable changes to this project will be documented in this file.\nSee [Conventional Commits](https://conventionalcommits.org) for commit guidelines.\n';
}

String _packageVersionTitle(MelosPendingPackageUpdate update) {
return '`${update.package.name}` - `v${update.nextVersion}`';
}

String _packageVersionMarkdownAnchor(MelosPendingPackageUpdate update) {
return '#${_packageVersionTitle(update).replaceAll(' ', '-').replaceAll(RegExp('[^a-zA-Z_0-9-]'), '')}';
}

String get markdown {
final body = StringBuffer();
final dependencyOnlyPackages = pendingPackageUpdates
.where((update) => update.reason == PackageUpdateReason.dependency);
final graduatedPackages = pendingPackageUpdates
.where((update) => update.reason == PackageUpdateReason.graduate);
final packagesWithBreakingChanges = pendingPackageUpdates.where(
(update) =>
update.reason == PackageUpdateReason.commit &&
update.semverReleaseType == SemverReleaseType.major,
);
final packagesWithOtherChanges = pendingPackageUpdates.where(
(update) =>
update.reason == PackageUpdateReason.commit &&
update.semverReleaseType != SemverReleaseType.major,
);

body.writeln(_changelogFileHeader);
body.writeln('## $title');
body.writeln();
body.writeln('### Changes');
body.writeln();
body.writeln('---');
body.writeln();
body.writeln('Packages with breaking changes:');
body.writeln();
if (packagesWithBreakingChanges.isEmpty) {
body.writeln('- There are no breaking changes in this release.');
} else {
for (final update in packagesWithBreakingChanges) {
body.writeln(
'- [${_packageVersionTitle(update)}](${_packageVersionMarkdownAnchor(update)})',
);
}
}
body.writeln();
body.writeln('Packages with other changes:');
body.writeln();
if (packagesWithOtherChanges.isEmpty) {
body.writeln('- There are no other changes in this release.');
} else {
for (final update in packagesWithOtherChanges) {
body.writeln(
'- [${_packageVersionTitle(update)}](${_packageVersionMarkdownAnchor(update)})',
);
}
}
if (graduatedPackages.isNotEmpty) {
body.writeln();
body.writeln(
'Packages graduated to a stable release (see pre-releases prior to the stable version for changelog entries):',
);
body.writeln();
for (final update in graduatedPackages) {
body.writeln(
'- ${_packageVersionTitle(update)}',
);
}
}
if (dependencyOnlyPackages.isNotEmpty) {
body.writeln();
body.writeln('Packages with dependency updates only:');
body.writeln();
body.writeln(
'> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project.',
);
body.writeln();
for (final update in dependencyOnlyPackages) {
body.writeln(
'- ${_packageVersionTitle(update)}',
);
}
}
if (packagesWithOtherChanges.isNotEmpty ||
packagesWithBreakingChanges.isNotEmpty) {
final allChanges = packagesWithBreakingChanges.toList()
..addAll(packagesWithOtherChanges);
body.writeln();
body.writeln('---');
body.writeln();

for (final update in allChanges) {
body.writeln('#### ${_packageVersionTitle(update)}');
body.writeln();

final commits = List<ConventionalCommit>.from(
update.commits
.where(
(RichGitCommit commit) =>
!commit.parsedMessage.isMergeCommit &&
commit.parsedMessage.isVersionableCommit,
)
.map((commit) => commit.parsedMessage)
.toList(),
);

// Sort so that Breaking Changes appear at the top.
commits.sort((a, b) {
final r = a.isBreakingChange
.toString()
.compareTo(b.isBreakingChange.toString());
if (r != 0) return r;
return b.type!.compareTo(a.type!);
});

for (final commit in commits) {
var entry =
'**${commit.type!.toUpperCase()}**: ${commit.description}';
// Add trailing punctuation if missing.
if (!entry.contains(RegExp(r'[\.\?\!]$'))) {
entry = '$entry.';
}
if (commit.isBreakingChange) {
entry = '**BREAKING** $entry';
}
body.writeln(' - $entry');
}

body.writeln();
}
}

return body.toString();
}

String get path {
return joinAll([workspace.path, 'CHANGELOG.md']);
}

@override
String toString() {
return markdown;
}

Future<String> read() async {
final file = File(path);
final exists = file.existsSync();
if (exists) {
final contents = await file.readAsString();
return contents.replaceFirst(_changelogFileHeader, '');
}
return '';
}

Future<void> write() async {
var contents = await read();
if (contents.contains(markdown)) {
logger.trace(
'Identical changelog content for ${workspace.name} already exists, skipping.',
);
return;
}
contents = '$markdown$contents';

await File(path).writeAsString(contents);
}
}
Loading

0 comments on commit 56fcdff

Please sign in to comment.