diff --git a/.circleci/config.yml b/.circleci/config.yml index 8bc65c237d..2f610e3583 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,6 +42,7 @@ commands: - run: name: Install and set up aft command: | + git submodule update --init flutter pub global activate -spath packages/aft aft bootstrap activate_pana: diff --git a/.github/workflows/aft.yaml b/.github/workflows/aft.yaml index c8f04cdab4..8ed80f17a0 100644 --- a/.github/workflows/aft.yaml +++ b/.github/workflows/aft.yaml @@ -1,4 +1,3 @@ -# Generated with aft. To update, run: `aft generate workflows` name: aft on: push: @@ -6,15 +5,11 @@ on: - main - stable - next + paths: + - 'packages/aft/**/*.dart' pull_request: paths: - 'packages/aft/**/*.dart' - - 'packages/aft/**/*.yaml' - - 'packages/aft/lib/**/*' - - 'packages/aft/test/**/*' - - '.github/workflows/dart_vm.yaml' - - '.github/workflows/dart_native.yaml' - - '.github/workflows/aft.yaml' schedule: - cron: "0 0 * * 0" # Every Sunday at 00:00 defaults: @@ -24,12 +19,34 @@ permissions: read-all jobs: test: - uses: ./.github/workflows/dart_vm.yaml - with: - working-directory: packages/aft - native_test: - if: ${{ github.event_name == 'push' }} - needs: test - uses: ./.github/workflows/dart_native.yaml - with: - working-directory: packages/aft + name: Test + runs-on: ubuntu-latest + steps: + - name: Git Checkout + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # 3.1.0 + with: + submodules: true + + # Needed for `git` but only ever used locally. + - name: Git Config + run: | + git config --global user.email "amplify-flutter@amazon.com" + git config --global user.name "Amplify Flutter" + + - name: Setup Dart + uses: dart-lang/setup-dart@196f54580e9eee2797c57e85e289339f85e6779d # main + with: + sdk: stable + + - name: Get Packages + working-directory: packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub upgrade + mkdir linux + cp external/libgit2dart/linux/*.so linux + + - name: Run Tests + working-directory: packages/aft + run: dart test diff --git a/.github/workflows/dart_dart2js.yaml b/.github/workflows/dart_dart2js.yaml index 458412c74e..265fe67910 100644 --- a/.github/workflows/dart_dart2js.yaml +++ b/.github/workflows/dart_dart2js.yaml @@ -50,7 +50,11 @@ jobs: sdk: ${{ matrix.sdk }} - name: Setup aft - run: dart pub global activate -spath packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub global activate -spath packages/aft + ( cd packages/aft/external/libgit2dart; git reset --hard HEAD ) - name: Setup Firefox if: ${{ matrix.browser == 'firefox' }} diff --git a/.github/workflows/dart_ddc.yaml b/.github/workflows/dart_ddc.yaml index 0f45d67baf..7f199401d8 100644 --- a/.github/workflows/dart_ddc.yaml +++ b/.github/workflows/dart_ddc.yaml @@ -54,7 +54,11 @@ jobs: sdk: ${{ matrix.sdk }} - name: Setup aft - run: dart pub global activate -spath packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub global activate -spath packages/aft + ( cd packages/aft/external/libgit2dart; git reset --hard HEAD ) - name: Setup Firefox if: ${{ matrix.browser == 'firefox' }} diff --git a/.github/workflows/dart_native.yaml b/.github/workflows/dart_native.yaml index 6b1ebd8744..ecdd637ea3 100644 --- a/.github/workflows/dart_native.yaml +++ b/.github/workflows/dart_native.yaml @@ -51,7 +51,11 @@ jobs: sdk: stable - name: Setup aft - run: dart pub global activate -spath packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub global activate -spath packages/aft + ( cd packages/aft/external/libgit2dart; git reset --hard HEAD ) - name: Bootstrap id: bootstrap diff --git a/.github/workflows/dart_vm.yaml b/.github/workflows/dart_vm.yaml index 44c2f5f7eb..0d3d6bda2b 100644 --- a/.github/workflows/dart_vm.yaml +++ b/.github/workflows/dart_vm.yaml @@ -47,7 +47,11 @@ jobs: sdk: ${{ matrix.sdk }} - name: Setup aft - run: dart pub global activate -spath packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub global activate -spath packages/aft + ( cd packages/aft/external/libgit2dart; git reset --hard HEAD ) - name: Bootstrap id: bootstrap diff --git a/.github/workflows/flutter_vm.yaml b/.github/workflows/flutter_vm.yaml index b5dd0f60cd..049f10d7be 100644 --- a/.github/workflows/flutter_vm.yaml +++ b/.github/workflows/flutter_vm.yaml @@ -23,6 +23,9 @@ jobs: - name: Git Checkout uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # 3.1.0 + - name: Git Submodules + run: git submodule update --init + - name: Setup Flutter uses: subosito/flutter-action@dbf1fa04f4d2e52c33185153d06cdb5443aa189d # 2.8.0 with: diff --git a/.github/workflows/smoke_test.yaml b/.github/workflows/smoke_test.yaml index d487fb483f..14dd5f7ec3 100644 --- a/.github/workflows/smoke_test.yaml +++ b/.github/workflows/smoke_test.yaml @@ -25,7 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@e2f20e631ae6d7dd3b768f56a5d2af784dd54791 # v2.5.0 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # 3.1.0 + + - name: Git Submodules + run: git submodule update --init - name: Setup Dart uses: dart-lang/setup-dart@196f54580e9eee2797c57e85e289339f85e6779d # main @@ -34,6 +37,8 @@ jobs: - name: Link Packages run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) dart pub global activate -spath packages/aft aft link diff --git a/.gitmodules b/.gitmodules index 6e7923f264..81a2534ca5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -11,3 +11,6 @@ path = packages/smithy/goldens/smithy url = https://github.com/awslabs/smithy.git branch = main +[submodule "packages/aft/external/libgit2dart"] + path = packages/aft/external/libgit2dart + url = https://github.com/SkinnyMind/libgit2dart.git diff --git a/aft.yaml b/aft.yaml index a37a21f111..ebd3f305f8 100644 --- a/aft.yaml +++ b/aft.yaml @@ -17,6 +17,51 @@ dependencies: uuid: ">=3.0.6 <=3.0.7" xml: ">=6.1.0 <=6.2.2" -# Packages to ignore in all repo operations +# Packages to ignore in all repo operations. ignore: - synthetic_package + - libgit2dart + +# Strongly connected components which should have version bumps happen +# in unison, i.e. a version bump to one package cascades to all. +# +# By default, this happens only for minor version bumps. However, this +# can be modified on a per-component basis using the `propagate` flag. +components: + - name: Amplify Flutter + summary: amplify_flutter + packages: + - amplify_flutter + - amplify_flutter_ios + - amplify_flutter_android + - amplify_core + - amplify_datastore + - amplify_datastore_plugin_interface + - amplify_analytics_pinpoint + - amplify_api + - amplify_api_android + - amplify_api_ios + - amplify_auth_cognito + - amplify_auth_cognito_android + - amplify_auth_cognito_ios + - amplify_storage_s3 + - name: Amplify Dart + summary: amplify_core + propagate: none + packages: + - amplify_auth_cognito_dart + - amplify_analytics_pinpoint_dart + - amplify_storage_s3_dart + - name: Amplify UI + packages: + - amplify_authenticator + - name: Smithy + summary: smithy + packages: + - smithy + - smithy_aws + - name: Worker Bee + summary: worker_bee + packages: + - worker_bee + - worker_bee_builder diff --git a/packages/aft/README.md b/packages/aft/README.md index c6d69fd797..d617b597c2 100644 --- a/packages/aft/README.md +++ b/packages/aft/README.md @@ -18,3 +18,24 @@ A CLI tool for managing the Amplify Flutter repository. - `get`: Runs `dart pub get`/`flutter pub get` for all packages - `upgrade`: Runs `dart pub upgrade`/`flutter pub upgrade` for all packages - `publish`: Runs `dart pub publish`/`flutter pub publish` for all packages which need publishing +- `version-bump`: Bumps version using git history + +## Setup + +To run some commands, `libgit2` is required and can be installed with the following commands: + +```sh +$ brew install libgit2 +``` + +```sh +$ sudo apt-get install libgit2-dev +``` + +To activate `aft`, run: + +```sh +$ dart pub global activate -spath packages/aft +``` + +A full list of available commands and options can be found by running `aft --help`. diff --git a/packages/aft/analysis_options.yaml b/packages/aft/analysis_options.yaml index 46eb63b7b0..7d9efef6a1 100644 --- a/packages/aft/analysis_options.yaml +++ b/packages/aft/analysis_options.yaml @@ -5,3 +5,4 @@ analyzer: public_member_api_docs: ignore exclude: - '**/*.g.dart' + - external/ diff --git a/packages/aft/bin/aft.dart b/packages/aft/bin/aft.dart index 8d153aa311..712130a43b 100644 --- a/packages/aft/bin/aft.dart +++ b/packages/aft/bin/aft.dart @@ -25,7 +25,8 @@ Future main(List args) async { ..addCommand(LinkCommand()) ..addCommand(CleanCommand()) ..addCommand(PubCommand()) - ..addCommand(BootstrapCommand()); + ..addCommand(BootstrapCommand()) + ..addCommand(VersionBumpCommand()); try { await runner.run(args); } on UsageException catch (e) { diff --git a/packages/aft/doc/versioning.md b/packages/aft/doc/versioning.md new file mode 100644 index 0000000000..191d34ea6e --- /dev/null +++ b/packages/aft/doc/versioning.md @@ -0,0 +1,36 @@ +# Versioning Algorithm + +The `aft version-bump` command uses Git history + [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) formatting to determine a suitable next version for a package along with the required changes for depending packages. + +1. Let `packages` be the set of all packages in the repo which are publishable to `pub.dev`. +2. For every package `P` in `packages`: + 1. Let `component` be the component of `P`, if any. + 2. Let `baseRef` be the commit of the last release of `P`. + 3. Let `headRef` be the releaseable commit of `P` (defaults to `HEAD`). + 4. Let `history` be the list of git commits in the range `baseRef..headRef` which affected `P`, i.e. those commits which included changes to files in `P`. + 5. Let `nextVersion = currentVersion`. + 6. For each `commit` in `history`: + 1. If `commit` is a version bump (i.e. `chore(version)`), ignore it. + 2. If `commit` is a merge commit, update dependencies based on the packages changed by the commit. + 1. The thinking here is that PRs should either be squashed into a single commit or merged as a set of independent commits capped off by a merge commit. The independent commits are isolated changes which are used to update changelogs and bump versions. The merge commit is then used solely for associating previous commits and updating constraints accordingly. + 3. If `commit` is a breaking change (i.e. `feat(auth)!`), set `bumpType = breaking`. + 1. else if `commit`'s type is `feat`, set `bumpType = nonBreaking`. + 2. else, set `bumpType = patch`. + 4. If `commit` is a noteworthy change (scope is one of `feat`, `fix`, `bug`, `perf`, or `revert` or it's a breaking change), set `includeInChangelog = true`. + 5. Let `proposedVersion = currentVersion.bump(bumpType)` + 6. Let `nextVersion = max(nextVersion, proposedVersion)` + 7. If `nextVersion > currentVersion`: + 1. Update `pubspec.yaml`, set `version = nextVersion` + 2. If `includeInChangelog`: + 1. Update `CHANGELOG.md` with an entry for `commit`. + 3. If `bumpType == breaking`: + 1. For every package `Q` which directly depends on `P`: + 1. Bump the version of `Q` with `bumpType = patch` and `includeInChangelog = false`. + 2. Update `Q`'s constraint on `P`. + 4. If `bumpType == breaking` or `bumpType == nonBreaking` and `component != null`: + 1. For every package `Q` in `component`: + 1. Bump the version of `Q` with the same `bumpType` as `P` and `includeInChangelog = false`. + 8. If `component` has a summary package: + 1. Update `CHANGELOG.md` in the summary package with `commit`. + 9. For every package `Q` which was affected by `commit`: + 1. Update `Q`'s constraint on `P` using `nextVersion`. diff --git a/packages/aft/external/libgit2dart b/packages/aft/external/libgit2dart new file mode 160000 index 0000000000..34d492a9b6 --- /dev/null +++ b/packages/aft/external/libgit2dart @@ -0,0 +1 @@ +Subproject commit 34d492a9b6704a5d5bad7ece8970109df0f05752 diff --git a/packages/aft/external/libgit2dart.patch b/packages/aft/external/libgit2dart.patch new file mode 100644 index 0000000000..b9465a2206 --- /dev/null +++ b/packages/aft/external/libgit2dart.patch @@ -0,0 +1,29 @@ +diff --git a/pubspec.yaml b/pubspec.yaml +index 5acda72..2831e58 100644 +--- a/pubspec.yaml ++++ b/pubspec.yaml +@@ -8,7 +8,6 @@ homepage: https://github.com/SkinnyMind/libgit2dart + + environment: + sdk: ">=2.18.0 <3.0.0" +- flutter: ">=3.3.0" + + dependencies: + args: ^2.3.0 +@@ -23,16 +22,6 @@ dev_dependencies: + lints: ^2.0.0 + test: ^1.20.0 + +-flutter: +- plugin: +- platforms: +- linux: +- pluginClass: Libgit2dartPlugin +- macos: +- pluginClass: Libgit2dartPlugin +- windows: +- pluginClass: Libgit2dartPlugin +- + ffigen: + output: "lib/src/bindings/libgit2_bindings.dart" + headers: diff --git a/packages/aft/lib/aft.dart b/packages/aft/lib/aft.dart index 089cbef436..d627cad49b 100644 --- a/packages/aft/lib/aft.dart +++ b/packages/aft/lib/aft.dart @@ -12,6 +12,7 @@ export 'src/commands/link_command.dart'; export 'src/commands/list_packages_command.dart'; export 'src/commands/pub_command.dart'; export 'src/commands/publish_command.dart'; +export 'src/commands/version_bump_command.dart'; export 'src/models.dart'; export 'src/pub/pub_runner.dart'; export 'src/util.dart'; diff --git a/packages/aft/lib/src/changelog/changelog.dart b/packages/aft/lib/src/changelog/changelog.dart new file mode 100644 index 0000000000..16f4c4bd35 --- /dev/null +++ b/packages/aft/lib/src/changelog/changelog.dart @@ -0,0 +1,182 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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:convert'; + +import 'package:aft/src/changelog/commit_message.dart'; +import 'package:aft/src/changelog/printer.dart'; +import 'package:aws_common/aws_common.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:collection/collection.dart'; +import 'package:markdown/markdown.dart'; +import 'package:pub_semver/pub_semver.dart'; + +part 'changelog.g.dart'; +part 'parser.dart'; + +/// Marker version for the `NEXT` (unreleased) version. +final Version nextVersion = Version(0, 0, 0, pre: nextVersionTag); + +/// Tag for the `NEXT` version. +const String nextVersionTag = 'NEXT'; + +/// {@template aft.changelog.changelog} +/// A Dart representation of a `CHANGELOG.md` file. +/// {@endtemplate} +abstract class Changelog implements Built { + /// {@macro aft.changelog.changelog} + factory Changelog([void Function(ChangelogBuilder) updates]) = _$Changelog; + Changelog._(); + + /// Creates an empty changelog. + factory Changelog.empty() => Changelog((b) => b..originalText = ''); + + /// Parses [changelogMd] for a list of the versions. + /// + /// Throws a [ChangelogParseException] if there are issues processing the + /// changelog. + factory Changelog.parse( + String changelogMd, { + AWSLogger? logger, + }) { + final parser = Document(); + final lines = LineSplitter.split(changelogMd).toList(); + final ast = parser.parseLines(lines); + final visitor = _ChangelogParser(logger); + for (final node in ast) { + node.accept(visitor); + } + return (visitor.builder..originalText = changelogMd).build(); + } + + /// The original CHANGELOG.md text. + String get originalText; + + /// A map of semantic versions to their nodes. + BuiltListMultimap get versions; + + /// The latest version in the changelog, or `null` if the changelog is empty. + Version? get latestVersion => maxBy(versions.keys, (v) => v); + + /// Whether there's a `NEXT` entry in the changelog. + bool get hasNextEntry => versions.keys.any((v) => v == nextVersion); + + /// Creates a version entry which can be rendered as markdown for the given + /// list of [commits]. + /// + /// If [version] is not specified, it defaults to [nextVersion]. If [version] + /// already exists in the changelog, it is updated with the new list of + /// commits. + List makeVersionEntry({ + required Iterable commits, + Version? version, + }) { + version ??= nextVersion; + commits = commits.where((commit) => commit.includeInChangelog); + final commitsByType = + commits.groupListsBy((element) => element.group); + + final versionText = + version == nextVersion ? nextVersionTag : version.toString(); + final header = Element.text('h2', versionText); + final nodes = [header]; + + if (commits.isEmpty) { + // If there are no commits worth including, add a generic message about + // bug fixes/improvements. + nodes.add(Element.text('li', 'Minor bug fixes and improvements\n')); + } else { + for (final typedCommits in commitsByType.entries) { + nodes.add(Element.text('h3', typedCommits.key.header)); + + // Transform PR #'s into links to the main repo + const baseUrl = 'https://github.com/aws-amplify/amplify-flutter'; + final commits = typedCommits.value + .sortedBy((commit) => commit.summary) + .map((commit) { + final taggedPr = commit.taggedPr; + if (taggedPr == null) { + return commit.summary; + } + return commit.summary.replaceFirst( + '(#$taggedPr)', + '([#$taggedPr]($baseUrl/pull/$taggedPr))', + ); + }); + + final list = Element('ul', [ + for (final commit in commits) Element.text('li', commit), + ]); + nodes.add(list); + } + } + + return nodes; + } + + /// Updates the changelog with relevant entries from [commits]. + /// + /// If [version] is not specified, the default `NEXT` tag is used. + ChangelogUpdate update({ + required Iterable commits, + Version? version, + }) { + final nodes = makeVersionEntry( + commits: commits, + version: version, + ); + // Replace the text in changelogMd so that the latest version matches + // `version`, if given, else `NEXT`. + String keepText; + if (hasNextEntry || (version != null && latestVersion == version)) { + // Update latest entry, either to `version` or as a new `NEXT` entry. + keepText = LineSplitter.split(originalText) + // Skip latest version entry + .skip(1) + // Find previous version header + .skipWhile((line) => !line.startsWith('## ')) + .join('\n'); + } else { + // No `NEXT` or `version` entry exists yet. + keepText = originalText; + } + if (!keepText.endsWith('\n')) { + keepText = '$keepText\n'; + } + return ChangelogUpdate(keepText, commits: commits, newText: render(nodes)); + } + + @override + String toString() { + return render(versions.values); + } +} + +class ChangelogUpdate { + const ChangelogUpdate( + this.keepText, { + required this.commits, + this.newText, + }); + + final String keepText; + final Iterable commits; + final String? newText; + + bool get hasUpdate => newText != null; + + @override + String toString() => newText == null ? keepText : '$newText$keepText'; +} diff --git a/packages/aft/lib/src/changelog/changelog.g.dart b/packages/aft/lib/src/changelog/changelog.g.dart new file mode 100644 index 0000000000..b3e67817c5 --- /dev/null +++ b/packages/aft/lib/src/changelog/changelog.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'changelog.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Changelog extends Changelog { + @override + final String originalText; + @override + final BuiltListMultimap versions; + + factory _$Changelog([void Function(ChangelogBuilder)? updates]) => + (new ChangelogBuilder()..update(updates))._build(); + + _$Changelog._({required this.originalText, required this.versions}) + : super._() { + BuiltValueNullFieldError.checkNotNull( + originalText, r'Changelog', 'originalText'); + BuiltValueNullFieldError.checkNotNull(versions, r'Changelog', 'versions'); + } + + @override + Changelog rebuild(void Function(ChangelogBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ChangelogBuilder toBuilder() => new ChangelogBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Changelog && + originalText == other.originalText && + versions == other.versions; + } + + @override + int get hashCode { + return $jf($jc($jc(0, originalText.hashCode), versions.hashCode)); + } +} + +class ChangelogBuilder implements Builder { + _$Changelog? _$v; + + String? _originalText; + String? get originalText => _$this._originalText; + set originalText(String? originalText) => _$this._originalText = originalText; + + ListMultimapBuilder? _versions; + ListMultimapBuilder get versions => + _$this._versions ??= new ListMultimapBuilder(); + set versions(ListMultimapBuilder? versions) => + _$this._versions = versions; + + ChangelogBuilder(); + + ChangelogBuilder get _$this { + final $v = _$v; + if ($v != null) { + _originalText = $v.originalText; + _versions = $v.versions.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(Changelog other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$Changelog; + } + + @override + void update(void Function(ChangelogBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Changelog build() => _build(); + + _$Changelog _build() { + _$Changelog _$result; + try { + _$result = _$v ?? + new _$Changelog._( + originalText: BuiltValueNullFieldError.checkNotNull( + originalText, r'Changelog', 'originalText'), + versions: versions.build()); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'versions'; + versions.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + r'Changelog', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,no_leading_underscores_for_local_identifiers,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new,unnecessary_lambdas diff --git a/packages/aft/lib/src/changelog/commit_message.dart b/packages/aft/lib/src/changelog/commit_message.dart new file mode 100644 index 0000000000..b24e96cfa3 --- /dev/null +++ b/packages/aft/lib/src/changelog/commit_message.dart @@ -0,0 +1,374 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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:convert'; + +import 'package:aft/aft.dart'; +import 'package:aws_common/aws_common.dart'; +import 'package:collection/collection.dart'; + +final RegExp _mergeCommitRegex = RegExp(r'^Merge .+$'); +final RegExp _commitRegex = RegExp( + r'(?build|chore|ci|docs|feat|fix|bug|perf|refactor|revert|style|test)?' + r'(?\([a-zA-Z0-9_,\s\*]+\)?((?=:\s?)|(?=!:\s?)))?' + r'(?!)?' + r'(?:\s?.*)?', +); +final RegExp _trailerRegex = RegExp(r'^[^:\s]+:[^:]+$'); + +enum CommitTypeGroup { + breaking('Breaking Changes'), + fixes('Fixes'), + features('Features'), + other('Other Changes'); + + const CommitTypeGroup(this.header); + + final String header; +} + +enum CommitType { + unconventional.other(), + merge.other(), + build.other(), + chore.other(), + ci.other(), + docs.other(), + feat.features(), + fix.fixes(), + bug.fixes(), + perf.fixes(), + refactor.other(), + revert.other(), + style.other(), + test.other(), + version.other(); + + const CommitType.fixes() : group = CommitTypeGroup.fixes; + const CommitType.features() : group = CommitTypeGroup.features; + const CommitType.other() : group = CommitTypeGroup.other; + + final CommitTypeGroup group; +} + +/// {@template aft.changelog.commit_message} +/// A parsed git commit message. +/// {@endtemplate} +abstract class CommitMessage with AWSEquatable { + /// {@macro aft.changelog.commit_message} + const CommitMessage({ + required this.sha, + required this.summary, + required this.dateTime, + }); + + /// Parses a commit message [summary]. + factory CommitMessage.parse( + String sha, + String summary, { + required String body, + int? commitTimeSecs, + }) { + final dateTime = commitTimeSecs == null + ? DateTime.now().toUtc() + : DateTime.fromMillisecondsSinceEpoch( + commitTimeSecs * 1000, + ).toUtc(); + final mergeCommit = _mergeCommitRegex.firstMatch(summary); + if (mergeCommit != null) { + return MergeCommitMessage( + sha: sha, + summary: mergeCommit.group(0)!, + dateTime: dateTime, + ); + } + + final commitMessage = _commitRegex.firstMatch(summary); + if (commitMessage == null) { + throw ArgumentError.value( + summary, + 'summary', + 'Not a valid commit message', + ); + } + + final typeStr = commitMessage.namedGroup('type'); + if (typeStr == null) { + return UnconventionalCommitMessage( + sha: sha, + summary: summary, + dateTime: dateTime, + ); + } + + final type = CommitType.values.byName(typeStr); + final isBreakingChange = commitMessage.namedGroup('breaking') != null; + final scopes = commitMessage + .namedGroup('scope') + ?.replaceAll(RegExp(r'[\(\)]'), '') + .split(',') + .map((scope) => scope.trim()) + .toList() ?? + const []; + final description = commitMessage + .namedGroup('description') + ?.replaceAll(RegExp(r'^:\s'), '') + .trim(); + // Fall back for malformed messages. + if (description == null) { + return UnconventionalCommitMessage( + sha: sha, + summary: summary, + dateTime: dateTime, + ); + } + + if (type == CommitType.chore && scopes.singleOrNull == 'version') { + return VersionCommitMessage.parse( + sha: sha, + summary: summary, + body: body, + dateTime: dateTime, + ); + } + return ConventionalCommitMessage( + sha: sha, + summary: summary, + description: description, + type: type, + isBreakingChange: isBreakingChange, + scopes: scopes, + dateTime: dateTime, + ); + } + + /// The commit's OID SHA. + final String sha; + + /// The full, unmodified, commit summary. + final String summary; + + /// The date/time the commit was made. + final DateTime dateTime; + + /// The parsed commit description. + String get description => summary; + + /// The type of commit message. + CommitType get type; + + /// The group for the commit type. + CommitTypeGroup get group { + if (isBreakingChange) { + return CommitTypeGroup.breaking; + } + return type.group; + } + + /// Whether this commit message is for a version bump. + bool get isVersionBump => false; + + /// Whether a commit of this type should be included in a CHANGELOG by + /// default. + bool get includeInChangelog => false; + + /// Whether this is a breaking change, denoted by a `!` after the scope, e.g. + /// `fix(auth)!`. + bool get isBreakingChange => false; + + /// How to bump the package's version based off this commit. + VersionBumpType? get bumpType => null; + + /// The PR tagged in this commit, e.g. `(#2012)`. + int? get taggedPr { + final match = RegExp(r'#(\d+)').firstMatch(summary)?.group(1); + if (match == null) { + return null; + } + return int.parse(match); + } + + @override + List get props => [summary, dateTime]; + + @override + String toString() => summary; +} + +/// {@template aft.changelog.merge_commit_message} +/// A commit message representing a merge commit. +/// {@endtemplate} +class MergeCommitMessage extends CommitMessage { + /// {@macro aft.changelog.merge_commit_message} + const MergeCommitMessage({ + required super.sha, + required super.summary, + required super.dateTime, + }); + + @override + CommitType get type => CommitType.merge; +} + +/// {@template aft.changelog.conventional_commit_message} +/// A commit message representing a [conventional commit](https://www.conventionalcommits.org/). +/// {@endtemplate} +class ConventionalCommitMessage extends CommitMessage { + /// {@macro aft.changelog.conventional_commit_message} + const ConventionalCommitMessage({ + required super.sha, + required super.summary, + required this.description, + required this.type, + required this.isBreakingChange, + required this.scopes, + required super.dateTime, + }); + + @override + final String description; + + @override + final CommitType type; + + @override + final bool isBreakingChange; + + /// The list of scopes, or tags, which this commit covers. + final List scopes; + + @override + bool get isVersionBump => + type == CommitType.chore && scopes.singleOrNull == 'version'; + + @override + VersionBumpType get bumpType { + switch (type) { + case CommitType.version: + case CommitType.unconventional: + case CommitType.merge: + case CommitType.build: + case CommitType.chore: + case CommitType.ci: + case CommitType.docs: + case CommitType.refactor: + case CommitType.style: + case CommitType.test: + case CommitType.fix: + case CommitType.bug: + case CommitType.perf: + case CommitType.revert: + return isBreakingChange + ? VersionBumpType.breaking + : VersionBumpType.patch; + case CommitType.feat: + return isBreakingChange + ? VersionBumpType.breaking + : VersionBumpType.nonBreaking; + } + } + + @override + bool get includeInChangelog { + if (isBreakingChange) { + return true; + } + switch (type) { + case CommitType.unconventional: + case CommitType.merge: + case CommitType.build: + case CommitType.chore: + case CommitType.ci: + case CommitType.docs: + case CommitType.refactor: + case CommitType.style: + case CommitType.test: + case CommitType.version: + return false; + case CommitType.feat: + case CommitType.fix: + case CommitType.bug: + case CommitType.perf: + case CommitType.revert: + return true; + } + } +} + +/// {@template aft.changelog.unconventional_commit_message} +/// A commit message which is not a [ConventionalCommitMessage], i.e. a regular +/// commit message with no special formatting or meaning. +/// {@endtemplate} +class UnconventionalCommitMessage extends CommitMessage { + /// {@macro aft.changelog.unconventional_commit_message} + const UnconventionalCommitMessage({ + required super.sha, + required super.summary, + required super.dateTime, + }); + + @override + CommitType get type => CommitType.unconventional; + + @override + VersionBumpType get bumpType => VersionBumpType.patch; +} + +/// {@template aft.changelog.version_commit_message} +/// A commit message which identifies a version bump performed by `aft`. +/// +/// These messages take the form `chore(version): ...` and list out the +/// relevant version bump information including a complete feature log. +/// {@endtemplate} +class VersionCommitMessage extends CommitMessage { + /// {@macro aft.changelog.version_commit_message} + factory VersionCommitMessage.parse({ + required String sha, + required String summary, + required String body, + required DateTime dateTime, + }) { + final trailers = Map.fromEntries( + LineSplitter.split(body).where(_trailerRegex.hasMatch).map( + (line) => MapEntry( + line.split(':')[0], + line.split(':')[1].trim(), + ), + ), + ); + final updatedComponentsStr = trailers['Updated-Components']; + final updatedComponents = updatedComponentsStr == null + ? const [] + : updatedComponentsStr.split(',').map((el) => el.trim()).toList(); + return VersionCommitMessage._( + sha: sha, + summary: summary, + updatedComponents: updatedComponents, + dateTime: dateTime, + ); + } + + const VersionCommitMessage._({ + required super.sha, + required super.summary, + required this.updatedComponents, + required super.dateTime, + }); + + /// The list of updated components or packages. + final List updatedComponents; + + @override + CommitType get type => CommitType.version; +} diff --git a/packages/aft/lib/src/changelog/parser.dart b/packages/aft/lib/src/changelog/parser.dart new file mode 100644 index 0000000000..ff9adafe6e --- /dev/null +++ b/packages/aft/lib/src/changelog/parser.dart @@ -0,0 +1,88 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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. + +part of 'changelog.dart'; + +/// Matcher for a semantic version. +final RegExp semverRegex = RegExp(r'\d+\.\d+\.\d+[\d\w\.\+\-]*'); + +class _ChangelogParser implements NodeVisitor { + _ChangelogParser(this.logger); + + final AWSLogger? logger; + + final builder = ChangelogBuilder(); + + late Version _currentVersion; + + @override + void visitElementAfter(Element element) {} + + @override + bool visitElementBefore(Element element) { + switch (element.type) { + case ElementType.h2: + final versionText = element.textContent; + if (equalsIgnoreAsciiCase(versionText, nextVersionTag)) { + _currentVersion = nextVersion; + break; + } + final versionMatch = semverRegex.firstMatch(versionText)?.group(0); + if (versionMatch == null) { + logger?.debug('Could not parse version: $versionText'); + break; + } + _currentVersion = Version.parse(versionMatch); + break; + default: + break; + } + builder.versions.add(_currentVersion, element); + return false; + } + + @override + void visitText(Text text) {} +} + +/// {@template aft.changelog.changelog_parse_exception} +/// Exception thrown while parsing a changelog. +/// {@endtemplate} +class ChangelogParseException implements Exception { + /// {@macro aft.changelog.changelog_parse_exception} + const ChangelogParseException(this.message); + + final String message; + + @override + String toString() => 'ChangelogParseException: $message'; +} + +/// The type of [Element] tag. +enum ElementType { h1, h2, h3, h4, h5, h6, ul, li, unknown } + +extension ElementX on Element { + /// The type of [Element] tag. + ElementType get type { + if (tag == 'h1') return ElementType.h1; + if (tag == 'h2') return ElementType.h2; + if (tag == 'h3') return ElementType.h3; + if (tag == 'h4') return ElementType.h4; + if (tag == 'h5') return ElementType.h5; + if (tag == 'h6') return ElementType.h6; + if (tag == 'ul') return ElementType.ul; + if (tag == 'li') return ElementType.li; + return ElementType.unknown; + } +} diff --git a/packages/aft/lib/src/changelog/printer.dart b/packages/aft/lib/src/changelog/printer.dart new file mode 100644 index 0000000000..f5d65c0426 --- /dev/null +++ b/packages/aft/lib/src/changelog/printer.dart @@ -0,0 +1,69 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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 'package:aft/src/changelog/changelog.dart'; +import 'package:markdown/markdown.dart'; + +/// Renders the [markdown] AST as a string. +String render(Iterable markdown) { + final renderer = _MarkdownRenderer(); + for (final node in markdown) { + node.accept(renderer); + } + return renderer.output; +} + +class _MarkdownRenderer implements NodeVisitor { + final StringBuffer _builder = StringBuffer(); + String get output => _builder.toString(); + + @override + void visitElementAfter(Element element) { + switch (element.type) { + case ElementType.ul: + _builder.writeln(); + break; + default: + break; + } + } + + @override + bool visitElementBefore(Element element) { + switch (element.type) { + case ElementType.h1: + case ElementType.h2: + case ElementType.h3: + case ElementType.h4: + case ElementType.h5: + case ElementType.h6: + final headerNum = int.parse(element.type.name.substring(1)); + _builder.writeln('${'#' * headerNum} ${element.textContent}'); + if (headerNum < 3) { + _builder.writeln(); + } + break; + case ElementType.li: + _builder.writeln('- ${element.textContent}'); + break; + case ElementType.ul: + case ElementType.unknown: + break; + } + return true; + } + + @override + void visitText(Text text) {} +} diff --git a/packages/aft/lib/src/commands/amplify_command.dart b/packages/aft/lib/src/commands/amplify_command.dart index 9416474ccc..33e7919907 100644 --- a/packages/aft/lib/src/commands/amplify_command.dart +++ b/packages/aft/lib/src/commands/amplify_command.dart @@ -1,28 +1,72 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import 'dart:collection'; +import 'dart:convert'; import 'dart:io'; import 'package:aft/aft.dart'; +import 'package:aft/src/repo.dart'; import 'package:args/command_runner.dart'; -import 'package:async/async.dart'; import 'package:aws_common/aws_common.dart'; import 'package:checked_yaml/checked_yaml.dart'; -import 'package:cli_util/cli_logging.dart'; -import 'package:collection/collection.dart'; +import 'package:git/git.dart' as git; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub/pub.dart'; -import 'package:stream_transform/stream_transform.dart'; +import 'package:pub_semver/pub_semver.dart'; /// Base class for all commands in this package providing common functionality. -abstract class AmplifyCommand extends Command implements Closeable { - /// Whether verbose logging is enabled. - late final bool verbose = globalResults!['verbose'] as bool; +abstract class AmplifyCommand extends Command + implements AWSLoggerPlugin, Closeable { + AmplifyCommand() { + init(); + } + + /// Initializer which runs when this command is instantiated. + /// + /// This can be overridden for setting additional flags or subcommands + /// via mixins or direct overrides. + @mustCallSuper + void init() { + AWSLogger() + ..unregisterAllPlugins() + ..registerPlugin(this); + } + + late final AWSLogger logger = () { + final allCommands = []; + for (Command? cmd = this; cmd != null; cmd = cmd.parent) { + allCommands.add(cmd.name); + } + return AWSLogger().createChild(allCommands.reversed.join('.')); + }(); + + @override + void handleLogEntry(LogEntry logEntry) { + final message = verbose + ? '${logEntry.loggerName} | ${logEntry.message}' + : logEntry.message; + switch (logEntry.level) { + case LogLevel.verbose: + case LogLevel.debug: + case LogLevel.info: + stdout.writeln(message); + break; + case LogLevel.warn: + case LogLevel.error: + stderr.writeln(message); + break; + case LogLevel.none: + break; + } + } - /// The configured logger for the command. - late final Logger logger = verbose ? Logger.verbose() : Logger.standard(); + /// Whether verbose logging is enabled. + bool get verbose => + globalResults?['verbose'] as bool? ?? + AWSLogger().logLevel == LogLevel.verbose; /// The current working directory. late final Directory workingDirectory = () { @@ -38,72 +82,95 @@ abstract class AmplifyCommand extends Command implements Closeable { /// HTTP client for remote operations. http.Client get httpClient => _httpClient ??= _PubHttpClient(); - final _rootDirMemo = AsyncMemoizer(); - /// The root directory of the Amplify Flutter repo. - Future get rootDir => _rootDirMemo.runOnce(() async { - var dir = workingDirectory; - while (p.absolute(dir.parent.path) != p.absolute(dir.path)) { - final files = dir.list(followLinks: false).whereType(); - await for (final file in files) { - if (p.basename(file.path) == 'aft.yaml') { - return dir; - } - } - dir = dir.parent; + late final Directory rootDir = () { + var dir = workingDirectory; + while (p.absolute(dir.parent.path) != p.absolute(dir.path)) { + final files = dir.listSync(followLinks: false).whereType(); + for (final file in files) { + if (p.basename(file.path) == 'aft.yaml') { + return dir; } - throw StateError( - 'Root directory not found. Make sure to run this command ' - 'from within the Amplify Flutter repo', - ); - }); - - final _allPackagesMemo = AsyncMemoizer>(); + } + dir = dir.parent; + } + throw StateError( + 'Root directory not found. Make sure to run this command ' + 'from within the Amplify Flutter repo', + ); + }(); /// All packages in the Amplify Flutter repo. - Future> get allPackages => - _allPackagesMemo.runOnce(() async { - final allDirs = (await rootDir) - .list(recursive: true, followLinks: false) - .whereType(); - final aftConfig = await this.aftConfig; - - final allPackages = []; - await for (final dir in allDirs) { - final package = PackageInfo.fromDirectory(dir); - if (package == null) { - continue; - } - final pubspec = package.pubspecInfo.pubspec; - if (aftConfig.ignore.contains(pubspec.name)) { - continue; - } - allPackages.add(package); - } - return UnmodifiableMapView({ - for (final package in allPackages..sort()) package.name: package, - }); - }); + late final Map allPackages = () { + final allDirs = rootDir + .listSync(recursive: true, followLinks: false) + .whereType(); + final allPackages = []; + for (final dir in allDirs) { + final pubspecInfo = dir.pubspec; + if (pubspecInfo == null) { + continue; + } + final pubspec = pubspecInfo.pubspec; + if (aftConfig.ignore.contains(pubspec.name)) { + continue; + } + allPackages.add( + PackageInfo( + name: pubspec.name, + path: dir.path, + pubspecInfo: pubspecInfo, + flavor: pubspec.flavor, + ), + ); + } + return UnmodifiableMapView({ + for (final package in allPackages..sort()) package.name: package, + }); + }(); /// The absolute path to the `aft.yaml` document. - Future get aftConfigPath async { - final rootDir = await this.rootDir; + late final String aftConfigPath = () { + final rootDir = this.rootDir; return p.join(rootDir.path, 'aft.yaml'); - } + }(); + + /// The global `aft` configuration for the repo. + late final AftConfig aftConfig = () { + final configFile = File(p.join(rootDir.path, 'aft.yaml')); + assert(configFile.existsSync(), 'Could not find aft.yaml'); + final configYaml = configFile.readAsStringSync(); + final config = checkedYamlDecode(configYaml, AftConfig.fromJson); + logger.verbose('$config'); + return config; + }(); + + late final Repo repo = Repo( + rootDir, + allPackages: allPackages, + aftConfig: aftConfig, + logger: logger, + ); + + /// Runs `git` with the given [args] from the repo's root directory. + Future runGit( + List args, { + bool echoOutput = false, + }) => + git.runGit( + args, + processWorkingDir: rootDir.path, + throwOnError: true, + echoOutput: echoOutput, + ); /// The `aft.yaml` document. - Future get aftConfigYaml async { - final configFile = File(await aftConfigPath); + String get aftConfigYaml { + final configFile = File(aftConfigPath); assert(configFile.existsSync(), 'Could not find aft.yaml'); return configFile.readAsStringSync(); } - /// The global `aft` configuration for the repo. - Future get aftConfig async { - final configYaml = await aftConfigYaml; - return checkedYamlDecode(configYaml, AftConfig.fromJson); - } - /// A command runner for `pub`. PubCommandRunner createPubRunner() => PubCommandRunner( pubCommand(isVerbose: () => verbose), @@ -119,6 +186,52 @@ abstract class AmplifyCommand extends Command implements Closeable { return response; } + /// Displays a yes/no prompt and returns whether the answer was positive. + bool promptYesNo(String message) { + final answer = prompt(message).toLowerCase(); + return answer == 'y' || answer == 'yes'; + } + + /// Resolves the latest version information from `pub.dev`. + Future resolveVersionInfo(String package) async { + // Get the currently published version of the package. + final uri = Uri.parse('https://pub.dev/api/packages/$package'); + final resp = await httpClient.get( + uri, + headers: {AWSHeaders.accept: 'application/vnd.pub.v2+json'}, + ); + + // Package is unpublished + if (resp.statusCode == 404) { + return null; + } + if (resp.statusCode != 200) { + throw http.ClientException(resp.body, uri); + } + + final respJson = jsonDecode(resp.body) as Map; + final versions = (respJson['versions'] as List?) ?? []; + final semvers = []; + for (final version in versions) { + final map = (version as Map).cast(); + final semver = map['version'] as String?; + if (semver == null) { + continue; + } + semvers.add(Version.parse(semver)); + } + + return PubVersionInfo(semvers..sort()); + } + + @override + @mustCallSuper + Future run() async { + if (globalResults?['verbose'] as bool? ?? false) { + AWSLogger().logLevel = LogLevel.verbose; + } + } + @override @mustCallSuper void close() { diff --git a/packages/aft/lib/src/commands/bootstrap_command.dart b/packages/aft/lib/src/commands/bootstrap_command.dart index 07f9a12343..406162caea 100644 --- a/packages/aft/lib/src/commands/bootstrap_command.dart +++ b/packages/aft/lib/src/commands/bootstrap_command.dart @@ -41,8 +41,7 @@ class BootstrapCommand extends AmplifyCommand { /// Creates an empty `amplifyconfiguration.dart` file. Future _createEmptyConfig(PackageInfo package) async { // Only create for example apps. - if (package.pubspecInfo.pubspec.publishTo == null || - falsePositiveExamples.contains(package.name)) { + if (!package.isExample) { return; } final file = File( @@ -67,7 +66,7 @@ const amplifyEnvironments = {}; @override Future run() async { - final allPackages = await this.allPackages; + await super.run(); await linkPackages(allPackages); await pubAction( action: upgrade ? PubAction.upgrade : PubAction.get, diff --git a/packages/aft/lib/src/commands/clean_command.dart b/packages/aft/lib/src/commands/clean_command.dart index 47aac1a0c4..bfdd21e09c 100644 --- a/packages/aft/lib/src/commands/clean_command.dart +++ b/packages/aft/lib/src/commands/clean_command.dart @@ -31,8 +31,8 @@ class CleanCommand extends AmplifyCommand { cleanCmd.captureStderr(sink: errors.writeln); if (await cleanCmd.exitCode != 0) { logger - ..stderr('Could not clean ${package.path}: ') - ..stderr(errors.toString()); + ..error('Could not clean ${package.path}: ') + ..error(errors.toString()); } break; case PackageFlavor.dart: @@ -43,9 +43,9 @@ class CleanCommand extends AmplifyCommand { @override Future run() async { + await super.run(); await Future.wait([ - for (final package in (await allPackages).values) - _cleanBuildFolder(package) + for (final package in allPackages.values) _cleanBuildFolder(package), ]); stdout.writeln('Project successfully cleaned'); diff --git a/packages/aft/lib/src/commands/deps_command.dart b/packages/aft/lib/src/commands/deps_command.dart index 4d629b05ab..4835da776c 100644 --- a/packages/aft/lib/src/commands/deps_command.dart +++ b/packages/aft/lib/src/commands/deps_command.dart @@ -1,11 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import 'dart:convert'; import 'dart:io'; import 'package:aft/aft.dart'; -import 'package:aws_common/aws_common.dart'; import 'package:collection/collection.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -106,8 +104,8 @@ class _DepsSubcommand extends AmplifyCommand { } Future _run(_DepsAction action) async { - final globalDependencyConfig = (await aftConfig).dependencies; - for (final package in (await allPackages).values) { + final globalDependencyConfig = aftConfig.dependencies; + for (final package in allPackages.values) { for (final globalDep in globalDependencyConfig.entries) { _checkDependency( package, @@ -137,15 +135,16 @@ class _DepsSubcommand extends AmplifyCommand { } if (_mismatchedDependencies.isNotEmpty) { for (final mismatched in _mismatchedDependencies) { - logger.stderr(mismatched); + logger.error(mismatched); } exit(1); } - logger.stdout(action.successMessage); + logger.info(action.successMessage); } @override Future run() async { + await super.run(); return _run(action); } } @@ -155,36 +154,24 @@ class _DepsUpdateCommand extends _DepsSubcommand { @override Future run() async { - final globalDependencyConfig = (await aftConfig).dependencies; + await super.run(); + final globalDependencyConfig = aftConfig.dependencies; - final aftEditor = YamlEditor(await aftConfigYaml); + final aftEditor = YamlEditor(aftConfigYaml); final failedUpdates = []; for (final entry in globalDependencyConfig.entries) { final package = entry.key; final versionConstraint = entry.value; VersionConstraint? newVersionConstraint; - // TODO(dnys1): Merge with publish logic // Get the currently published version of the package. - final uri = Uri.parse('https://pub.dev/api/packages/$package'); - logger.trace('GET $uri'); try { - final resp = await httpClient.get( - uri, - headers: {AWSHeaders.accept: 'application/vnd.pub.v2+json'}, - ); - if (resp.statusCode != 200) { - failedUpdates.add('$package: Could not reach server'); - continue; - } - final respJson = jsonDecode(resp.body) as Map; - final latestVersionStr = - (respJson['latest'] as Map?)?['version'] as String?; - if (latestVersionStr == null) { - failedUpdates.add('$package: No versions found for package'); + final versionInfo = await resolveVersionInfo(package); + final latestVersion = versionInfo?.latestVersion; + if (latestVersion == null) { + failedUpdates.add('No versions found for package: $package'); continue; } - final latestVersion = Version.parse(latestVersionStr); // Update the constraint to include `latestVersion` as its new upper // bound. @@ -234,17 +221,17 @@ class _DepsUpdateCommand extends _DepsSubcommand { } if (aftEditor.edits.isNotEmpty) { - File(await aftConfigPath).writeAsStringSync( + File(aftConfigPath).writeAsStringSync( aftEditor.toString(), flush: true, ); - logger.stdout(action.successMessage); + logger.info(action.successMessage); } else { - logger.stderr('No dependencies updated'); + logger.info('No dependencies updated'); } for (final failedUpdate in failedUpdates) { - logger.stderr('Could not update $failedUpdate'); + logger.error('Could not update $failedUpdate'); exitCode = 1; } diff --git a/packages/aft/lib/src/commands/generate/generate_sdk_command.dart b/packages/aft/lib/src/commands/generate/generate_sdk_command.dart index 974ae71400..ee5b345b4c 100644 --- a/packages/aft/lib/src/commands/generate/generate_sdk_command.dart +++ b/packages/aft/lib/src/commands/generate/generate_sdk_command.dart @@ -56,7 +56,9 @@ class GenerateSdkCommand extends AmplifyCommand { /// Downloads AWS models from GitHub into a temporary directory. Future _downloadModels(String ref) async { final cloneDir = await Directory.systemTemp.createTemp('models'); - logger.trace('Cloning models to ${cloneDir.path}'); + logger + ..info('Downloading models...') + ..verbose('Cloning models to ${cloneDir.path}'); await runGit([ 'clone', 'https://github.com/aws/aws-models.git', @@ -66,7 +68,7 @@ class GenerateSdkCommand extends AmplifyCommand { ['checkout', ref], processWorkingDir: cloneDir.path, ); - logger.trace('Successfully cloned models'); + logger.info('Successfully cloned models'); return cloneDir; } @@ -75,7 +77,7 @@ class GenerateSdkCommand extends AmplifyCommand { /// Returns the new directory. Future _organizeModels(Directory baseDir) async { final modelsDir = await Directory.systemTemp.createTemp('models'); - logger.trace('Organizing models in ${modelsDir.path}'); + logger.debug('Organizing models in ${modelsDir.path}'); final services = baseDir.list(followLinks: false).whereType(); await for (final serviceDir in services) { final serviceName = p.basename(serviceDir.path); @@ -88,7 +90,7 @@ class GenerateSdkCommand extends AmplifyCommand { } final smithyModel = File(p.join(smithyDir.path, 'model.json')); final copyToPath = p.join(modelsDir.path, '$serviceName.json'); - logger.trace('Copying $serviceName.json to $copyToPath'); + logger.verbose('Copying $serviceName.json to $copyToPath'); await smithyModel.copy(copyToPath); } return modelsDir; @@ -96,6 +98,7 @@ class GenerateSdkCommand extends AmplifyCommand { @override Future run() async { + await super.run(); final args = argResults!; final configFilepath = args['config'] as String; final configFile = File(configFilepath); @@ -105,7 +108,7 @@ class GenerateSdkCommand extends AmplifyCommand { final configYaml = await configFile.readAsString(); final config = checkedYamlDecode(configYaml, SdkConfig.fromJson); - logger.stdout('Got config: $config'); + logger.verbose('Got config: $config'); final modelsPath = args['models'] as String?; final Directory modelsDir; @@ -191,7 +194,7 @@ class GenerateSdkCommand extends AmplifyCommand { } for (final plugin in config.plugins) { - logger.stdout('Running plugin $plugin...'); + logger.info('Running plugin $plugin...'); final generatedShapes = ShapeMap( Map.fromEntries( output.values.expand((out) => out.context.shapes.entries), @@ -234,10 +237,10 @@ class GenerateSdkCommand extends AmplifyCommand { } logger - ..stdout('Successfully generated SDK') - ..trace('Make sure to add the following dependencies:'); + ..info('Successfully generated SDK') + ..verbose('Make sure to add the following dependencies:'); for (final dep in dependencies.toList()..sort()) { - logger.trace('- $dep'); + logger.verbose('- $dep'); } } } diff --git a/packages/aft/lib/src/commands/generate/generate_workflows_command.dart b/packages/aft/lib/src/commands/generate/generate_workflows_command.dart index bada78d9dc..fc51a024e8 100644 --- a/packages/aft/lib/src/commands/generate/generate_workflows_command.dart +++ b/packages/aft/lib/src/commands/generate/generate_workflows_command.dart @@ -18,8 +18,7 @@ class GenerateWorkflowsCommand extends AmplifyCommand { @override Future run() async { - final allPackages = await this.allPackages; - final repoRoot = await rootDir; + await super.run(); for (final package in allPackages.values) { if (package.pubspecInfo.pubspec.publishTo == 'none' && !falsePositiveExamples.contains(package.name)) { @@ -32,13 +31,13 @@ class GenerateWorkflowsCommand extends AmplifyCommand { continue; } final workflowFilepath = p.join( - repoRoot.path, + rootDir.path, '.github', 'workflows', '${package.name}.yaml', ); final workflowFile = File(workflowFilepath); - final repoRelativePath = p.relative(package.path, from: repoRoot.path); + final repoRelativePath = p.relative(package.path, from: rootDir.path); final customWorkflow = File(p.join(package.path, 'workflow.yaml')); if (customWorkflow.existsSync()) { customWorkflow.copySync(workflowFilepath); @@ -73,7 +72,7 @@ class GenerateWorkflowsCommand extends AmplifyCommand { final workflowPaths = [ if (needsWebTest) '.github/composite_actions/setup_firefox/action.yaml', ...workflows.map((workflow) => '.github/workflows/$workflow'), - p.relative(workflowFilepath, from: repoRoot.path), + p.relative(workflowFilepath, from: rootDir.path), ].map((path) => " - '$path'").join('\n'); final workflowContents = StringBuffer( diff --git a/packages/aft/lib/src/commands/link_command.dart b/packages/aft/lib/src/commands/link_command.dart index e229b1e149..e1e8cc37a1 100644 --- a/packages/aft/lib/src/commands/link_command.dart +++ b/packages/aft/lib/src/commands/link_command.dart @@ -21,7 +21,7 @@ class LinkCommand extends AmplifyCommand { @override Future run() async { - final allPackages = await this.allPackages; + await super.run(); await linkPackages(allPackages); stdout.writeln('Packages successfully linked!'); } diff --git a/packages/aft/lib/src/commands/list_packages_command.dart b/packages/aft/lib/src/commands/list_packages_command.dart index bd86d4cf97..0124bb32a2 100644 --- a/packages/aft/lib/src/commands/list_packages_command.dart +++ b/packages/aft/lib/src/commands/list_packages_command.dart @@ -13,8 +13,9 @@ class ListPackagesCommand extends AmplifyCommand { @override Future run() async { - for (final package in (await allPackages).keys) { - logger.stdout(package); + await super.run(); + for (final package in allPackages.keys) { + logger.info(package); } } } diff --git a/packages/aft/lib/src/commands/pub_command.dart b/packages/aft/lib/src/commands/pub_command.dart index 81d329fe1a..9c5fffcbd1 100644 --- a/packages/aft/lib/src/commands/pub_command.dart +++ b/packages/aft/lib/src/commands/pub_command.dart @@ -6,7 +6,7 @@ import 'dart:io'; import 'package:aft/aft.dart'; import 'package:async/async.dart'; -import 'package:cli_util/cli_logging.dart'; +import 'package:aws_common/aws_common.dart'; import 'package:http/http.dart' as http; import 'package:pub/src/http.dart' as pub_http; @@ -18,6 +18,10 @@ enum PubAction { upgrade( 'Upgrades packages and the pubspec.lock file to their latest versions', 'Successfully upgraded packages', + ), + publish( + 'Publishes package to pub.dev', + 'Successfully published package', ); const PubAction(this.description, this.successMessage); @@ -64,7 +68,7 @@ class PubSubcommand extends AmplifyCommand { @override Future run() async { - final allPackages = await this.allPackages; + await super.run(); await pubAction( action: action, allPackages: allPackages.values, @@ -87,19 +91,19 @@ Future pubAction({ required bool verbose, required PubCommandRunner Function() createPubRunner, required http.Client httpClient, - Logger? logger, + AWSLogger? logger, }) async { // Set the internal HTTP client to one that can be reused multiple times. pub_http.innerHttpClient = httpClient; + logger ??= AWSLogger('pubAction'); - final progress = - logger?.progress('Running `pub ${action.name}` in all packages'); + logger.info('Running `pub ${action.name}` in all packages...'); final results = >{}; for (final package in allPackages) { if (package.skipChecks) { continue; } - final packageProgress = logger?.progress(package.name); + logger.info('${package.name}...'); switch (package.flavor) { case PackageFlavor.flutter: results[package.name] = await Result.capture( @@ -122,19 +126,17 @@ Future pubAction({ ); break; } - packageProgress?.finish(); } - progress?.finish(message: action.successMessage, showTiming: true); final failed = results.entries.where((entry) => entry.value.isError); if (failed.isNotEmpty) { - logger?.stderr('The following packages failed: '); + logger.error('The following packages failed: '); for (final failedPackage in failed) { final error = failedPackage.value.asError!; logger - ?..stderr(failedPackage.key) - ..stderr(error.error.toString()) - ..stderr(error.stackTrace.toString()); + ..error(failedPackage.key) + ..error(error.error.toString()) + ..error(error.stackTrace.toString()); } exitCode = 1; } diff --git a/packages/aft/lib/src/commands/publish_command.dart b/packages/aft/lib/src/commands/publish_command.dart index d643ff8e8d..337c2238cd 100644 --- a/packages/aft/lib/src/commands/publish_command.dart +++ b/packages/aft/lib/src/commands/publish_command.dart @@ -5,10 +5,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:aft/aft.dart'; -import 'package:cli_util/cli_logging.dart'; +import 'package:aws_common/aws_common.dart'; import 'package:graphs/graphs.dart'; -import 'package:pub_semver/pub_semver.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:path/path.dart' as p; /// Command to publish all Dart/Flutter packages in the repo. class PublishCommand extends AmplifyCommand { @@ -49,71 +48,82 @@ class PublishCommand extends AmplifyCommand { Never fail(String error) { logger - ..stderr('Could not retrieve package info for ${package.name}: ') - ..stderr(error) - ..stderr('Retry with `--force` to ignore this error'); + ..error('Could not retrieve package info for ${package.name}: ') + ..error(error) + ..error('Retry with `--force` to ignore this error'); exit(1); } - // Get the currently published version of the package. - final uri = Uri.parse(publishTo ?? 'https://pub.dev') - .replace(path: '/api/packages/${package.name}'); - logger.trace('GET $uri'); - final resp = await httpClient.get( - uri, - headers: {'Accept': 'application/vnd.pub.v2+json'}, - ); - - // Package is unpublished - if (resp.statusCode == 404) { - return package; - } - if (resp.statusCode != 200) { - if (force) { + try { + final versionInfo = await resolveVersionInfo(package.name); + final publishedVersion = + versionInfo?.latestPrerelease ?? versionInfo?.latestVersion; + if (publishedVersion == null) { + logger.info('No published info for package ${package.name}'); return package; - } else { - fail(resp.body); } - } - final respJson = jsonDecode(resp.body) as Map; - final latestVersionStr = - (respJson['latest'] as Map?)?['version'] as String?; - if (latestVersionStr == null) { + final currentVersion = package.pubspecInfo.pubspec.version!; + return currentVersion > publishedVersion ? package : null; + } on Exception catch (e) { + logger.error('Error retrieving info for package ${package.name}', e); if (force) { return package; } else { - fail('Could not determine latest version'); + fail(e.toString()); } } - - final latestVersion = Version.parse(latestVersionStr); - return latestVersion < package.pubspecInfo.pubspec.version! - ? package - : null; } /// Runs pre-publish operations for [package], most importantly any necessary /// `build_runner` tasks. Future _prePublish(PackageInfo package) async { - final progress = - logger.progress('Running pre-publish checks for ${package.name}...'); + logger.info('Running pre-publish checks for ${package.name}...'); + if (!dryRun) { + // Remove any overrides so that `pub` commands resolve against + // `pubspec.yaml`, allowing us to verify we've set our constraints + // correctly. + final pubspecOverrideFile = File( + p.join(package.path, 'pubspec_overrides.yaml'), + ); + if (pubspecOverrideFile.existsSync()) { + pubspecOverrideFile.deleteSync(); + } + } + final res = await Process.run( + package.flavor.entrypoint, + ['pub', 'upgrade'], + stdoutEncoding: utf8, + stderrEncoding: utf8, + workingDirectory: package.path, + ); + if (res.exitCode != 0) { + stdout.write(res.stdout); + stderr.write(res.stderr); + exit(res.exitCode); + } await runBuildRunner(package, logger: logger, verbose: verbose); - progress.finish(message: 'Success!'); } static final _validationStartRegex = RegExp( r'Package validation found the following', ); static final _validationConstraintRegex = RegExp( - r'\* Your dependency on ".+" should allow more than one version.', + r'\* Your dependency on ".+" should allow more than one version\.', + ); + static final _validationCheckedInFilesRegex = RegExp( + r'\* \d+ checked-in files? (is|are) ignored by a `\.gitignore`\.', + ); + // TODO(dnys1): Remove when we hit 1.0. For now this is appropriate since + // we have 0.x versions depending on 1.x-pre versions. + static final _validationPreReleaseRegex = RegExp( + r'\* Packages dependent on a pre-release of another package should themselves be published as a pre-release version\.', ); static final _validationErrorRegex = RegExp(r'^\s*\*'); /// Publishes the package using `pub`. Future _publish(PackageInfo package) async { - final progress = logger - .progress('Publishing ${package.name}${dryRun ? ' (dry run)' : ''}...'); + logger.info('Publishing ${package.name}${dryRun ? ' (dry run)' : ''}...'); final publishCmd = await Process.start( package.flavor.entrypoint, [ @@ -122,6 +132,7 @@ class PublishCommand extends AmplifyCommand { if (dryRun) '--dry-run', ], workingDirectory: package.path, + runInShell: true, ); final output = StringBuffer(); publishCmd @@ -137,52 +148,51 @@ class PublishCommand extends AmplifyCommand { final exitCode = await publishCmd.exitCode; if (exitCode != 0) { // Find any error lines which are not dependency constraint-related. - final failures = output - .toString() - .split('\n') + final failures = LineSplitter.split(output.toString()) .skipWhile((line) => !_validationStartRegex.hasMatch(line)) .where(_validationErrorRegex.hasMatch) - .where((line) => !_validationConstraintRegex.hasMatch(line)); + .where((line) => !_validationConstraintRegex.hasMatch(line)) + .where((line) => !_validationPreReleaseRegex.hasMatch(line)) + .where((line) => !_validationCheckedInFilesRegex.hasMatch(line)); if (failures.isNotEmpty) { - progress.cancel(); logger - ..stderr( + ..error( 'Failed to publish package ${package.name} ' 'due to the following errors: ', ) - ..stderr(failures.join('\n')); + ..error(failures.join('\n')); exit(exitCode); } } - progress.finish(message: 'Success!'); } @override Future run() async { - final allPackages = await this.allPackages; - + await super.run(); // Gather packages which can be published. - final publishablePackages = (await Future.wait([ - for (final package in allPackages.values) _checkPublishable(package), + final publishablePackages = repo.publishablePackages + .where((pkg) => pkg.pubspecInfo.pubspec.publishTo != 'none'); + + // Gather packages which need to be published. + final packagesNeedingPublish = (await Future.wait([ + for (final package in publishablePackages) _checkPublishable(package), ])) .whereType() .toList(); - // Non-example packages which are being held back - final unpublishablePackages = allPackages.values.where( - (pkg) => - pkg.pubspecInfo.pubspec.publishTo == null && - !publishablePackages.contains(pkg), + // Publishable packages which are being held back. + final unpublishablePackages = publishablePackages.where( + (pkg) => !packagesNeedingPublish.contains(pkg), ); - if (publishablePackages.isEmpty) { - logger.stdout('No packages need publishing!'); + if (packagesNeedingPublish.isEmpty) { + logger.info('No packages need publishing!'); return; } try { sortPackagesTopologically( - publishablePackages, + packagesNeedingPublish, (pkg) => pkg.pubspecInfo.pubspec, ); } on CycleException { @@ -194,7 +204,7 @@ class PublishCommand extends AmplifyCommand { stdout ..writeln('Preparing to publish${dryRun ? ' (dry run)' : ''}: ') ..writeln( - publishablePackages + packagesNeedingPublish .map((pkg) => '${pkg.pubspecInfo.pubspec.version} ${pkg.name}') .join('\n'), ) @@ -213,13 +223,14 @@ class PublishCommand extends AmplifyCommand { } } - // Run pre-publish checks before publishing any packages. - for (final package in publishablePackages) { + // Run pre-publish checks then publish package. + // + // Do not split up this step. Since packages are iterated in topological + // ordering, it is okay for later packages to fail. While this means that + // some packages will not be published, it also means that the command + // can be re-run to pick up where it left off. + for (final package in packagesNeedingPublish) { await _prePublish(package); - } - - // Publish each package sequentially. - for (final package in publishablePackages) { await _publish(package); } @@ -234,13 +245,13 @@ class PublishCommand extends AmplifyCommand { /// Runs `build_runner` for [package]. Future runBuildRunner( PackageInfo package, { - required Logger logger, + required AWSLogger logger, required bool verbose, }) async { if (!package.needsBuildRunner) { return; } - logger.stdout('Running build_runner for ${package.name}...'); + logger.info('Running build_runner for ${package.name}...'); final buildRunnerCmd = await Process.start( package.flavor.entrypoint, [ @@ -263,34 +274,10 @@ Future runBuildRunner( ..captureStderr(); } if (await buildRunnerCmd.exitCode != 0) { - logger.stderr('Failed to run `build_runner` for ${package.name}: '); + logger.error('Failed to run `build_runner` for ${package.name}: '); if (!verbose) { - logger.stderr(output.toString()); + logger.error(output.toString()); } exit(1); } } - -/// Sorts packages in topological order so they may be published in the order -/// they're sorted. -/// -/// Packages with inter-dependencies cannot be topologically sorted and will -/// throw a [CycleException]. -void sortPackagesTopologically( - List packages, - Pubspec Function(T) getPubspec, -) { - final pubspecs = packages.map(getPubspec); - final packageNames = pubspecs.map((el) => el.name).toList(); - final graph = >{ - for (var package in pubspecs) - package.name: package.dependencies.keys.where(packageNames.contains), - }; - final ordered = topologicalSort(graph.keys, (key) => graph[key]!); - packages.sort((a, b) { - // `ordered` is in reverse ordering to our desired publish precedence. - return ordered - .indexOf(getPubspec(b).name) - .compareTo(ordered.indexOf(getPubspec(a).name)); - }); -} diff --git a/packages/aft/lib/src/commands/version_bump_command.dart b/packages/aft/lib/src/commands/version_bump_command.dart new file mode 100644 index 0000000000..e6313436b7 --- /dev/null +++ b/packages/aft/lib/src/commands/version_bump_command.dart @@ -0,0 +1,161 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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:convert'; +import 'dart:io'; + +import 'package:aft/aft.dart'; +import 'package:aft/src/changelog/changelog.dart'; +import 'package:aft/src/changelog/printer.dart'; +import 'package:aft/src/options/git_ref_options.dart'; +import 'package:aft/src/options/glob_options.dart'; +import 'package:aft/src/repo.dart'; +import 'package:path/path.dart' as p; + +/// Command for bumping package versions across the repo. +class VersionBumpCommand extends AmplifyCommand + with GitRefOptions, GlobOptions { + VersionBumpCommand() { + argParser + ..addFlag( + 'preview', + help: 'Preview version changes without applying', + defaultsTo: false, + negatable: false, + ) + ..addFlag( + 'yes', + abbr: 'y', + help: 'Responds "yes" to all prompts', + defaultsTo: false, + negatable: false, + ); + } + + @override + String get description => + 'Bump package versions automatically using git magic'; + + @override + String get name => 'version-bump'; + + late final bool yes = argResults!['yes'] as bool; + + late final bool preview = argResults!['preview'] as bool; + + GitChanges _changesForPackage(PackageInfo package) { + final baseRef = this.baseRef ?? repo.latestBumpRef(package.name); + if (baseRef == null) { + exitError( + 'No previous version bumps for package (${package.name}). ' + 'Supply a base ref manually using --base-ref', + ); + } + return repo.changes(baseRef, headRef); + } + + Future> _updateVersions() async { + repo.bumpAllVersions( + changesForPackage: _changesForPackage, + ); + final changelogUpdates = repo.changelogUpdates; + + final bumpedPackages = []; + for (final package in repo.allPackages.values) { + final edits = package.pubspecInfo.pubspecYamlEditor.edits; + if (edits.isNotEmpty) { + bumpedPackages.add(package); + if (preview) { + logger.info('pubspec.yaml'); + for (final edit in edits) { + final originalText = package.pubspecInfo.pubspecYaml + .substring(edit.offset, edit.offset + edit.length); + logger.info('$originalText --> ${edit.replacement}'); + } + } else { + await File(p.join(package.path, 'pubspec.yaml')) + .writeAsString(package.pubspecInfo.pubspecYamlEditor.toString()); + } + } + final changelogUpdate = changelogUpdates[package]; + if (changelogUpdate != null && changelogUpdate.hasUpdate) { + if (preview) { + logger + ..info('CHANGELOG.md') + ..info(changelogUpdate.newText!); + } else { + await File(p.join(package.path, 'CHANGELOG.md')) + .writeAsString(changelogUpdate.toString()); + } + } + } + + return bumpedPackages; + } + + @override + Future run() async { + await super.run(); + + // Link packages so that we can run build_runner + await linkPackages(repo.allPackages); + + final bumpedPackages = await _updateVersions(); + + if (!preview) { + for (final package in bumpedPackages) { + // Run build_runner for packages which generate their version number. + final needsBuildRunner = package.pubspecInfo.pubspec.devDependencies + .containsKey('build_version'); + if (!needsBuildRunner) { + continue; + } + await runBuildRunner( + package, + logger: logger, + verbose: verbose, + ); + } + } + + logger.info('Version successfully bumped'); + // Stage changes + final mergedChangelog = Changelog.empty().makeVersionEntry( + commits: { + for (final package in bumpedPackages) + ...?repo.changelogUpdates[package]?.commits, + }, + ); + final updatedComponents = List.of(bumpedPackages.map((pkg) => pkg.name)); + for (final component in repo.components.values) { + final componentPackages = + component.packages.map((pkg) => pkg.name).toList(); + if (componentPackages.every(updatedComponents.contains)) { + updatedComponents + ..removeWhere(componentPackages.contains) + ..add(component.name); + } + } + final changelog = + LineSplitter.split(render(mergedChangelog)).skip(2).join('\n'); + final commitMsg = ''' +chore(version): Bump version + +$changelog + +Updated-Components: ${updatedComponents.join(', ')} +'''; + stdout.writeln(commitMsg); + } +} diff --git a/packages/aft/lib/src/models.dart b/packages/aft/lib/src/models.dart index 439122f243..5e005f060d 100644 --- a/packages/aft/lib/src/models.dart +++ b/packages/aft/lib/src/models.dart @@ -3,8 +3,10 @@ import 'dart:io'; +import 'package:aft/src/changelog/changelog.dart'; import 'package:aft/src/util.dart'; import 'package:aws_common/aws_common.dart'; +import 'package:collection/collection.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -47,11 +49,23 @@ enum PackageFlavor { final String entrypoint; } +class PubVersionInfo { + const PubVersionInfo(this.allVersions); + + final List allVersions; + + Version? get latestVersion => + allVersions.where((version) => !version.isPreRelease).lastOrNull; + + Version? get latestPrerelease => + allVersions.where((version) => version.isPreRelease).lastOrNull; +} + /// {@template amplify_tools.package_info} /// Information about a Dart/Flutter package in the repo. /// {@endtemplate} class PackageInfo - with AWSEquatable + with AWSEquatable, AWSDebuggable implements Comparable { /// {@macro amplify_tools.package_info} const PackageInfo({ @@ -105,10 +119,41 @@ class PackageInfo bool get needsBuildRunner { return pubspecInfo.pubspec.devDependencies.containsKey('build_runner') && // aft should not be used to run `build_runner` in example projects - (pubspecInfo.pubspec.publishTo != 'none' || - falsePositiveExamples.contains(name)); + !isExample; + } + + /// Whether the package is publishable. + bool get isPublishable => pubspecInfo.pubspec.publishTo == null; + + /// Whether the package is used in development. + bool get isDevelopmentPackage => !isExample && !isTestPackage; + + /// Whether the package is an example package. + bool get isExample { + return p.basename(path) == 'example'; } + /// Whether the package is a test package. + bool get isTestPackage { + return p.basename(path).endsWith('_test') || + path.contains('goldens') || + p.basename(path).contains('e2e'); + } + + /// The parsed `CHANGELOG.md`. + Changelog get changelog { + final changelogMd = File(p.join(path, 'CHANGELOG.md')).readAsStringSync(); + return Changelog.parse(changelogMd); + } + + /// The current version in `pubspec.yaml`. + Version get version => + pubspecInfo.pubspec.version ?? + (throw StateError('No version for package: $name')); + + @override + String get runtimeTypeName => 'PackageInfo'; + /// Skip package checks for Flutter packages when running in CI without /// Flutter, which may happen when testing Dart-only packages or specific /// Dart versions. @@ -120,12 +165,7 @@ class PackageInfo } @override - List get props => [ - name, - path, - pubspecInfo, - flavor, - ]; + List get props => [name, path]; @override int compareTo(PackageInfo other) { @@ -141,6 +181,15 @@ class PubspecInfo { required this.uri, }); + factory PubspecInfo.fromUri(Uri uri) { + final yaml = File.fromUri(uri).readAsStringSync(); + return PubspecInfo( + pubspec: Pubspec.parse(yaml), + pubspecYaml: yaml, + uri: uri, + ); + } + /// The package's parsed pubspec. final Pubspec pubspec; @@ -154,6 +203,86 @@ class PubspecInfo { late final YamlEditor pubspecYamlEditor = YamlEditor(pubspecYaml); } +extension AmplifyVersion on Version { + Version get nextPreRelease => Version( + major, + minor, + patch, + pre: preRelease.map((el) { + if (el is! int) return el; + return el + 1; + }).join('.'), + ); + + /// The next version according to Amplify rules for incrementing. + Version nextAmplifyVersion(VersionBumpType type) { + final newBuild = (build.singleOrNull as int? ?? 0) + 1; + if (preRelease.isEmpty) { + switch (type) { + case VersionBumpType.patch: + return major == 0 ? replace(build: [newBuild]) : nextPatch; + case VersionBumpType.nonBreaking: + return major == 0 ? nextPatch : nextMinor; + case VersionBumpType.breaking: + return major == 0 ? nextMinor : nextMajor; + } + } + if (type == VersionBumpType.breaking) { + return nextPreRelease; + } + return replace(build: [newBuild]); + } + + /// The constraint to use for this version in pubspecs. + String amplifyConstraint({Version? minVersion}) { + final Version maxVersion; + if (preRelease.isEmpty) { + final currentMinor = Version(major, minor, 0); + minVersion ??= currentMinor; + maxVersion = Version(major, minor + 1, 0); + } else { + final currentPreRelease = Version( + major, + minor, + patch, + pre: preRelease.join('.'), + ); + minVersion ??= currentPreRelease; + maxVersion = nextPreRelease; + } + return '>=$minVersion <$maxVersion'; + } + + /// Creates a copy of this version with the given fields replaced. + Version replace({ + int? major, + int? minor, + int? patch, + List? preRelease, + List? build, + }) { + String? pre; + if (preRelease != null) { + pre = preRelease.join('.'); + } else if (this.preRelease.isNotEmpty) { + pre = this.preRelease.join('.'); + } + String? buildString; + if (build != null) { + buildString = build.join('.'); + } else if (this.build.isNotEmpty) { + buildString = this.build.join('.'); + } + return Version( + major ?? this.major, + minor ?? this.minor, + patch ?? this.patch, + pre: pre, + build: buildString, + ); + } +} + enum DependencyType { dependency('dependencies', 'dependency'), devDependency('dev_dependencies', 'dev dependency'), @@ -205,21 +334,155 @@ const yamlSerializable = JsonSerializable( /// The typed representation of the `aft.yaml` file. @yamlSerializable @_VersionConstraintConverter() -class AftConfig { +class AftConfig with AWSSerializable>, AWSDebuggable { const AftConfig({ this.dependencies = const {}, this.ignore = const [], + this.components = const [], }); factory AftConfig.fromJson(Map? json) => _$AftConfigFromJson(json ?? const {}); + /// Global dependency versions for third-party dependencies representing the + /// values which have been vetted by manual review and/or those should be used + /// consistently across all packages. final Map dependencies; + + /// Packages to ignore in all repo operations. final List ignore; + /// {@macro aft.models.aft_component} + final List components; + + /// Retrieves the component for [packageName], if any. + String componentForPackage(String packageName) { + return components + .firstWhereOrNull( + (component) => component.packages.contains(packageName), + ) + ?.name ?? + packageName; + } + + @override + String get runtimeTypeName => 'AftConfig'; + + @override Map toJson() => _$AftConfigToJson(this); } +/// Specifies how to propagate version changes within a component. +enum VersionPropagation { + /// Propagates only major version changes. + major, + + /// Propagates only minor version changes. + minor, + + /// Propagates all version changes. + all, + + /// Propagates no version changes. + none; + + /// Whether to propagate a version change from [oldVersion] to [newVersion] + /// within its component. + bool propagateToComponent(Version oldVersion, Version newVersion) { + if (oldVersion == newVersion) { + return false; + } + final majorVersionChanged = () { + if (newVersion.isPreRelease) { + if (oldVersion.isPreRelease) { + return newVersion == oldVersion.nextPreRelease; + } + return true; + } + return newVersion.major > oldVersion.major; + }(); + switch (this) { + case VersionPropagation.major: + return majorVersionChanged; + case VersionPropagation.minor: + if (majorVersionChanged) { + return true; + } + return newVersion.minor > oldVersion.minor; + case VersionPropagation.all: + return true; + case VersionPropagation.none: + return false; + } + } +} + +/// {@template aft.models.aft_component} +/// Strongly connected components which should have minor/major version bumps +/// happen in unison, i.e. a version bump to one package cascades to all. +/// {@endtemplate} +@yamlSerializable +class AftComponent with AWSSerializable>, AWSDebuggable { + const AftComponent({ + required this.name, + this.summary, + required this.packages, + this.propagate = VersionPropagation.minor, + }); + + factory AftComponent.fromJson(Map json) => + _$AftComponentFromJson(json); + + /// The name of the component. + final String name; + + /// The package name which summarizes all component changes in its changleog. + final String? summary; + + /// The list of packages in the component. + final List packages; + + /// How to align package versions in this component when one changes. + final VersionPropagation propagate; + + @override + String get runtimeTypeName => 'AftComponent'; + + @override + Map toJson() => _$AftComponentToJson(this); +} + +class AftRepoComponent with AWSEquatable, AWSDebuggable { + const AftRepoComponent({ + required this.name, + this.summary, + required this.packages, + required this.packageGraph, + required this.propagate, + }); + + /// The name of the component. + final String name; + + /// The package name which summarizes all component changes in its changleog. + final PackageInfo? summary; + + /// The list of packages in the component. + final List packages; + + /// The graph of packages to their dependencies. + final Map> packageGraph; + + /// How to align package versions in this component when one changes. + final VersionPropagation propagate; + + @override + List get props => [name]; + + @override + String get runtimeTypeName => 'AftRepoComponent'; +} + /// Typed representation of the `sdk.yaml` file. @yamlSerializable @ShapeIdConverter() @@ -264,3 +527,57 @@ class _VersionConstraintConverter @override String toJson(VersionConstraint object) => object.toString(); } + +/// The type of version change to perform. +enum VersionBumpType { + /// Library packages are allowed to vary in their version, meaning a small + /// change to one package (e.g. Update README) should be isolated to the + /// affected package. + /// + /// Examples: + /// * If the current version of a 0-based `aws_common` is `0.1.0` and its + /// README is updated, it and it alone should be bumped to `0.1.1`. + /// Note: a bump to `0.1.1` is technically a “minor” version bump in + /// 0-based SemVer, but for consistency we can choose not to use build + /// numbers (+). + /// * If the current version of a 1-based `amplify_flutter` is `1.0.0` and its + /// README is updated, it and it alone should be bumped to `1.0.1`. + /// + /// This version change is reserved for chores and bug fixes as denoted by + /// the following conventional commit tags: `fix`, `bug`, `perf`, `chore`, + /// `build`, `ci`, `docs`, `refactor`, `revert`, `style`, `test`. + patch, + + /// A non-breaking version bump for a package represents a divergence from + /// the previous version in terms of behavior or functionality in the form of + /// a new feature. + /// + /// Examples: + /// * If the current version of a 0-based aws_common is `0.1.0` and it is part + /// of a feature change, it is bumped to `0.1.1` alongside all other package + /// bumps. + /// * If the current version of a 1-based amplify_flutter is `1.0.0` and it is + /// part of a feature change, it is bumped to `1.1.0` alongside all other + /// package bumps. + /// + /// This version change is reserved for new features denoted by the `feat` + /// conventional commit tag. + nonBreaking, + + /// A breaking version bump is reserved for breaking API changes. **These are + /// rarely done.** + /// + /// * 0-based packages are allowed to break their APIs while 0-based and + /// follow the non-breaking version scheme described above, e.g. + /// `0.1.0` → `0.2.0`. + /// + /// * Stable packages (>1.0.0) bump to the next SemVer major version, e.g. + /// `1.0.0` → `2.0.0`. + /// + /// Packages opt in to this behavior by suffixing an exclamation point to + /// their commit message title tag, e.g. + /// + /// - `feat(auth): A new feature` would be a non-breaking feature change. + /// - `feat(auth)!: A new feature` would be a breaking feature change. + breaking, +} diff --git a/packages/aft/lib/src/models.g.dart b/packages/aft/lib/src/models.g.dart index 14fad58134..8c5c16a5ac 100644 --- a/packages/aft/lib/src/models.g.dart +++ b/packages/aft/lib/src/models.g.dart @@ -12,7 +12,7 @@ AftConfig _$AftConfigFromJson(Map json) => $checkedCreate( ($checkedConvert) { $checkKeys( json, - allowedKeys: const ['dependencies', 'ignore'], + allowedKeys: const ['dependencies', 'ignore', 'components'], ); final val = AftConfig( dependencies: $checkedConvert( @@ -30,6 +30,14 @@ AftConfig _$AftConfigFromJson(Map json) => $checkedCreate( (v) => (v as List?)?.map((e) => e as String).toList() ?? const []), + components: $checkedConvert( + 'components', + (v) => + (v as List?) + ?.map((e) => AftComponent.fromJson( + Map.from(e as Map))) + .toList() ?? + const []), ); return val; }, @@ -39,8 +47,47 @@ Map _$AftConfigToJson(AftConfig instance) => { 'dependencies': instance.dependencies.map( (k, e) => MapEntry(k, const _VersionConstraintConverter().toJson(e))), 'ignore': instance.ignore, + 'components': instance.components, + }; + +AftComponent _$AftComponentFromJson(Map json) => $checkedCreate( + 'AftComponent', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['name', 'summary', 'packages', 'propagate'], + ); + final val = AftComponent( + name: $checkedConvert('name', (v) => v as String), + summary: $checkedConvert('summary', (v) => v as String?), + packages: $checkedConvert('packages', + (v) => (v as List).map((e) => e as String).toList()), + propagate: $checkedConvert( + 'propagate', + (v) => + $enumDecodeNullable(_$VersionPropagationEnumMap, v) ?? + VersionPropagation.minor), + ); + return val; + }, + ); + +Map _$AftComponentToJson(AftComponent instance) => + { + 'name': instance.name, + 'summary': instance.summary, + 'packages': instance.packages, + 'propagate': _$VersionPropagationEnumMap[instance.propagate]!, }; +const _$VersionPropagationEnumMap = { + VersionPropagation.major: 'major', + VersionPropagation.minor: 'minor', + VersionPropagation.all: 'all', + VersionPropagation.none: 'none', +}; + SdkConfig _$SdkConfigFromJson(Map json) => $checkedCreate( 'SdkConfig', json, diff --git a/packages/aft/lib/src/options/git_ref_options.dart b/packages/aft/lib/src/options/git_ref_options.dart new file mode 100644 index 0000000000..fd6cd9879d --- /dev/null +++ b/packages/aft/lib/src/options/git_ref_options.dart @@ -0,0 +1,51 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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:aft/aft.dart'; + +/// Adds git ref options and functionality to a command. +mixin GitRefOptions on AmplifyCommand { + @override + void init() { + super.init(); + argParser + ..addOption( + 'base-ref', + help: 'The base ref to update against', + ) + ..addOption( + 'head-ref', + help: 'The head ref to update against', + ); + } + + /// The base reference git operations should be based on. + /// + /// By default, this is the latest release tag. + String? get baseRef { + return Platform.environment['GITHUB_BASE_REF'] ?? + argResults!['base-ref'] as String?; + } + + /// The head reference git operations should be based on. + /// + /// By default, this is the current `HEAD`. + String get headRef { + return Platform.environment['GITHUB_HEAD_REF'] ?? + argResults!['head-ref'] as String? ?? + 'HEAD'; + } +} diff --git a/packages/aft/lib/src/options/glob_options.dart b/packages/aft/lib/src/options/glob_options.dart new file mode 100644 index 0000000000..98168bd2b1 --- /dev/null +++ b/packages/aft/lib/src/options/glob_options.dart @@ -0,0 +1,65 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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 'package:aft/aft.dart'; + +/// Adds globbing options to a command. +mixin GlobOptions on AmplifyCommand { + @override + void init() { + super.init(); + argParser + ..addMultiOption( + 'include', + help: 'Package or component names to include', + ) + ..addMultiOption( + 'exclude', + help: 'Package or component names to exclude', + ); + } + + /// List of packages or components which should be included in versioning. + late final include = argResults?['include'] as List? ?? const []; + + /// List of packages or components which should be excluded from versioning. + late final exclude = argResults?['exclude'] as List? ?? const []; + + @override + Map get allPackages { + return Map.fromEntries( + super.allPackages.entries.where((entry) { + final package = entry.value; + if (include.isNotEmpty) { + for (final packageOrComponent in include) { + if (package.name == packageOrComponent || + aftConfig.componentForPackage(package.name) == + packageOrComponent) { + return true; + } + } + return false; + } + for (final packageOrComponent in exclude) { + if (package.name == packageOrComponent || + aftConfig.componentForPackage(package.name) == + packageOrComponent) { + return false; + } + } + return true; + }), + ); + } +} diff --git a/packages/aft/lib/src/pub/pub_runner.dart b/packages/aft/lib/src/pub/pub_runner.dart index d566266d62..8225b5305e 100644 --- a/packages/aft/lib/src/pub/pub_runner.dart +++ b/packages/aft/lib/src/pub/pub_runner.dart @@ -3,7 +3,7 @@ import 'package:aft/aft.dart'; import 'package:args/command_runner.dart'; -import 'package:cli_util/cli_logging.dart'; +import 'package:aws_common/aws_common.dart'; import 'package:cli_util/cli_util.dart'; import 'package:flutter_tools/src/base/template.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -63,20 +63,28 @@ Future runDartPub( Future runFlutterPub( PubAction action, PackageInfo package, { - Logger? logger, + AWSLogger? logger, }) async { + logger ??= AWSLogger().createChild('runFlutterPub'); final flutterRoot = getEnv('FLUTTER_ROOT'); Cache.flutterRoot = flutterRoot ?? // Assumes using Dart SDK from Flutter path.normalize( path.absolute(path.dirname(path.dirname(path.dirname(getSdkPath())))), ); - logger?.trace('Resolved flutter root: ${Cache.flutterRoot}'); + logger.verbose('Resolved flutter root: ${Cache.flutterRoot}'); await flutter.runInContext( () async { final runner = FlutterCommandRunner() ..addCommand( PackagesGetCommand(action.name, action == PubAction.upgrade), + ) + ..addCommand( + PackagesForwardCommand( + 'publish', + 'Publish the current package to pub.dartlang.org.', + requiresPubspec: true, + ), ); await runner.run([action.name, package.path]); }, diff --git a/packages/aft/lib/src/repo.dart b/packages/aft/lib/src/repo.dart new file mode 100644 index 0000000000..79b96ccc1f --- /dev/null +++ b/packages/aft/lib/src/repo.dart @@ -0,0 +1,504 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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:aft/aft.dart'; +import 'package:aft/src/changelog/changelog.dart'; +import 'package:aft/src/changelog/commit_message.dart'; +import 'package:aws_common/aws_common.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:collection/collection.dart'; +import 'package:libgit2dart/libgit2dart.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; + +/// Encapsulates all repository functionality including package and Git +/// management. +class Repo { + Repo( + this.rootDir, { + required this.allPackages, + required this.aftConfig, + AWSLogger? logger, + }) : logger = logger ?? AWSLogger().createChild('Repo'); + + /// The root directory of the repository. + final Directory rootDir; + + final AWSLogger logger; + + final Map allPackages; + + final AftConfig aftConfig; + + /// All packages which can be published to `pub.dev`. + late final List publishablePackages = UnmodifiableListView( + allPackages.values.where((pkg) => pkg.isPublishable).toList(), + ); + + /// The components of the repository. + late final Map components = () { + final components = Map.fromEntries( + aftConfig.components.map((component) { + final summaryPackage = + component.summary == null ? null : allPackages[component.summary]!; + final packages = + component.packages.map((name) => allPackages[name]!).toList(); + final packageGraph = UnmodifiableMapView({ + for (final package in packages) + package: package.pubspecInfo.pubspec.dependencies.keys + .map( + (packageName) => packages.firstWhereOrNull( + (pkg) => pkg.name == packageName, + ), + ) + .whereType() + .toList(), + }); + return MapEntry( + component.name, + AftRepoComponent( + name: component.name, + summary: summaryPackage, + packages: packages, + packageGraph: packageGraph, + propagate: component.propagate, + ), + ); + }), + ); + logger.verbose('Components: $components'); + return components; + }(); + + /// The libgit repository. + late final Repository repo = Repository.open(rootDir.path); + + /// Returns the latest version bump commit for [packageOrComponent], or `null` + /// if no such commit exists. + /// + /// This is the marker of the last time [packageOrComponent] was released and + /// is used as the base git reference for calculating changes relevant to this + /// version bump. + String? latestBumpRef(String packageOrComponent) { + final component = components[packageOrComponent]?.name ?? + components.values + .firstWhereOrNull( + (component) => component.packages + .map((pkg) => pkg.name) + .contains(packageOrComponent), + ) + ?.name ?? + packageOrComponent; + var commit = Commit.lookup(repo: repo, oid: repo.head.target); + while (commit.parents.isNotEmpty) { + final commitMessage = CommitMessage.parse( + commit.oid.sha, + commit.summary, + body: commit.body, + commitTimeSecs: commit.time, + ); + if (commitMessage is VersionCommitMessage && + (commitMessage.updatedComponents.contains(component) || + commitMessage.updatedComponents.isEmpty)) { + return commitMessage.sha; + } + commit = commit.parent(0); + } + return null; + } + + /// The directed graph of packages to the packages it depends on. + late final Map> packageGraph = + UnmodifiableMapView({ + for (final package in allPackages.values) + package: package.pubspecInfo.pubspec.dependencies.keys + .map((packageName) => allPackages[packageName]) + .whereType() + .toList(), + }); + + /// The reversed (transposed) [packageGraph]. + /// + /// Provides a mapping from each packages to the packages which directly + /// depend on it. + late final Map> reversedPackageGraph = () { + final packageGraph = this.packageGraph; + final reversedPackageGraph = >{ + for (final package in allPackages.values) package: [], + }; + for (final entry in packageGraph.entries) { + for (final dep in entry.value) { + reversedPackageGraph[dep]!.add(entry.key); + } + } + return UnmodifiableMapView(reversedPackageGraph); + }(); + + /// The git diff between [oldTree] and [newTree]. + /// + /// **NOTE**: This is an expensive operation and its result should be cached. + Diff diffTrees(Tree oldTree, Tree newTree) => Diff.treeToTree( + repo: repo, + oldTree: oldTree, + newTree: newTree, + ); + + final _changesCache = <_DiffMarker, GitChanges>{}; + + /// Collect all the packages which have changed between [baseRef]..[headRef] + /// and the commits which changed them. + GitChanges changes(String baseRef, String headRef) { + // TODO(dnys1): Diff with index if headRef is null to include uncommitted + // changes? + final baseTree = RevParse.single( + repo: repo, + spec: '$baseRef^{tree}', + ) as Tree; + final headTree = RevParse.single( + repo: repo, + spec: '$headRef^{tree}', + ) as Tree; + final diffMarker = _DiffMarker(baseTree, headTree); + if (_changesCache.containsKey(diffMarker)) { + return _changesCache[diffMarker]!; + } + final diff = diffTrees(baseTree, headTree); + final changedPaths = diff.deltas.expand( + (delta) => [delta.oldFile.path, delta.newFile.path], + ); + final changedPackages = {}; + for (final changedPath in changedPaths) { + final changedPackage = allPackages.values.firstWhereOrNull( + (pkg) { + final relativePkgPath = p.relative(pkg.path, from: rootDir.path); + return changedPath.contains('$relativePkgPath/'); + }, + ); + if (changedPackage != null && + // Do not track example packages for git ops + changedPackage.isDevelopmentPackage) { + changedPackages.add(changedPackage); + } + } + + // For each package, gather all the commits between baseRef..headRef which + // affected the package. + final commitsByPackage = SetMultimapBuilder(); + final packagesByCommit = SetMultimapBuilder(); + for (final package in changedPackages) { + final walker = RevWalk(repo)..pushRange('$baseRef..$headRef'); + for (final commit in walker.walk()) { + for (var i = 0; i < commit.parents.length; i++) { + final parent = commit.parent(i); + final commitDiff = diffTrees(parent.tree, commit.tree); + final commitPaths = commitDiff.deltas.expand( + (delta) => [delta.oldFile.path, delta.newFile.path], + ); + final relativePath = p.relative(package.path, from: rootDir.path); + final changedPath = commitPaths.firstWhereOrNull( + (path) => path.contains('$relativePath/'), + ); + if (changedPath != null) { + final commitMessage = CommitMessage.parse( + commit.oid.sha, + commit.summary, + body: commit.body, + commitTimeSecs: commit.time, + ); + logger.verbose( + 'Package ${package.name} changed by $changedPath ' + '(${commitMessage.summary})', + ); + commitsByPackage.add(package, commitMessage); + packagesByCommit.add(commitMessage, package); + } + } + } + } + + return _changesCache[diffMarker] = GitChanges( + commitsByPackage: commitsByPackage.build(), + packagesByCommit: packagesByCommit.build(), + ); + } + + late final versionChanges = VersionChanges(this); + + /// Changelog updates. by package. + final Map changelogUpdates = {}; + + /// Bumps the version for all packages in the repo. + void bumpAllVersions({ + required GitChanges Function(PackageInfo) changesForPackage, + }) { + final sortedPackages = List.of(publishablePackages); + sortPackagesTopologically( + sortedPackages, + (PackageInfo pkg) => pkg.pubspecInfo.pubspec, + ); + for (final package in sortedPackages) { + final changes = changesForPackage(package); + final commits = (changes.commitsByPackage[package]?.toList() ?? const []) + ..sort((a, b) => a.dateTime.compareTo(b.dateTime)); + for (final commit in commits) { + if (commit.type == CommitType.version) { + continue; + } + final bumpType = commit.bumpType; + if (bumpType != null) { + bumpVersion( + package, + commit: commit, + type: bumpType, + includeInChangelog: commit.includeInChangelog, + ); + } + // Propagate the version change to all packages affected by the same + // commit as if they were a component. + // + // Even if _this_ commit didn't trigger a version bump, it may be a + // merge commit, in which case, it's important to propagate the changes + // of previous commits. + for (final commitPackage in changes.packagesByCommit[commit]!) { + updateConstraint(package, commitPackage); + } + } + } + } + + /// Bumps the version and changelog in [package] and its component packages + /// using [commit] and returns the new version. + /// + /// If [type] is [VersionBumpType.nonBreaking] or [VersionBumpType.breaking], + /// the version change is propagated to all packages which depend on + /// [package] at the type which is next least severe. + /// + /// If [propagateToComponent] is `true`, all component packages are bumped to + /// the same version. + Version bumpVersion( + PackageInfo package, { + required CommitMessage commit, + required VersionBumpType type, + required bool includeInChangelog, + bool? propagateToComponent, + }) { + logger.verbose('bumpVersion ${package.name} $commit'); + final componentName = aftConfig.componentForPackage(package.name); + final component = components[componentName]; + final currentVersion = package.version; + final proposedPackageVersion = + versionChanges.proposedVersion(package.name) ?? currentVersion; + final proposedComponentVersion = + versionChanges.proposedVersion(componentName); + final newProposedVersion = currentVersion.nextAmplifyVersion(type); + final newVersion = maxBy( + [ + proposedPackageVersion, + if (proposedComponentVersion != null) proposedComponentVersion, + newProposedVersion, + ], + (version) => version, + )!; + propagateToComponent ??= component != null && + component.propagate.propagateToComponent( + currentVersion, + newVersion, + ); + versionChanges.updateProposedVersion( + package, + newVersion, + propagateToComponent: propagateToComponent, + ); + + final currentChangelogUpdate = changelogUpdates[package]; + changelogUpdates[package] = package.changelog.update( + commits: { + ...?currentChangelogUpdate?.commits, + if (includeInChangelog) commit, + }, + version: newVersion, + ); + logger + ..verbose(' component: $componentName') + ..verbose(' currentVersion: $currentVersion') + ..verbose(' proposedPackageVersion: $proposedPackageVersion') + ..verbose(' proposedComponentVersion: $proposedComponentVersion') + ..verbose(' newProposedVersion: $newProposedVersion') + ..verbose(' newVersion: $newVersion'); + + if (newVersion > proposedPackageVersion) { + logger.debug( + 'Bumping ${package.name} from $currentVersion to $newVersion: ' + '${commit.summary}', + ); + package.pubspecInfo.pubspecYamlEditor.update( + ['version'], + newVersion.toString(), + ); + if (commit.isBreakingChange) { + // Back-propagate to all dependent packages for breaking changes. + // + // Since we set semantic version constraints, only a breaking change + // in a direct dependency necessitates a version bump. + logger.verbose( + 'Breaking change. Performing dfs on dependent packages...', + ); + for (final dependent in allPackages.values.where( + (pkg) => + pkg.pubspecInfo.pubspec.dependencies.keys + .contains(package.name) || + pkg.pubspecInfo.pubspec.devDependencies.keys + .contains(package.name), + )) { + logger.verbose('found dependent package ${dependent.name}'); + if (dependent.isPublishable && type != VersionBumpType.patch) { + bumpVersion( + dependent, + commit: commit, + type: VersionBumpType.patch, + includeInChangelog: false, + ); + } + updateConstraint(package, dependent); + } + } + + // Propagate to all component packages. + final componentPackages = component?.packageGraph; + if (propagateToComponent && componentPackages != null) { + dfs( + componentPackages, + (componentPackage) { + if (componentPackage == package) return; + logger.verbose( + 'Bumping component package ${componentPackage.name}', + ); + bumpVersion( + componentPackage, + commit: commit, + type: type, + includeInChangelog: false, + propagateToComponent: false, + ); + }, + ); + } + } + + // Update summary package's changelog if it exists. + final summaryPackage = component?.summary; + if (summaryPackage != null && includeInChangelog) { + logger.debug( + 'Updating summary package `${summaryPackage.name}` ' + 'with commit: $commit', + ); + final packageVersion = + versionChanges.proposedVersion(summaryPackage.name) ?? + versionChanges.proposedVersion(componentName); + final currentChangelogUpdate = changelogUpdates[summaryPackage]; + changelogUpdates[summaryPackage] = summaryPackage.changelog.update( + commits: { + ...?currentChangelogUpdate?.commits, + commit, + }, + version: packageVersion, + ); + } + + return newVersion; + } + + /// Updates the constraint for [package] in [dependent]. + void updateConstraint(PackageInfo package, PackageInfo dependent) { + final newVersion = versionChanges.proposedVersion(package.name)!; + final hasDependency = + dependent.pubspecInfo.pubspec.dependencies.containsKey(package.name); + final hasDevDependency = + dependent.pubspecInfo.pubspec.devDependencies.containsKey(package.name); + if (hasDependency || hasDevDependency) { + final newConstraint = newVersion.amplifyConstraint( + minVersion: newVersion, + ); + logger.verbose( + 'Bumping dependency on ${dependent.name} in ${package.name} ' + 'to $newConstraint', + ); + dependent.pubspecInfo.pubspecYamlEditor.update( + [ + if (hasDependency) 'dependencies' else 'dev_dependencies', + package.name + ], + newConstraint, + ); + } + } +} + +class GitChanges { + const GitChanges({ + required this.commitsByPackage, + required this.packagesByCommit, + }); + + final BuiltSetMultimap commitsByPackage; + final BuiltSetMultimap packagesByCommit; +} + +class _DiffMarker with AWSEquatable<_DiffMarker> { + const _DiffMarker(this.baseTree, this.headTree); + + final Tree baseTree; + final Tree headTree; + + @override + List get props => [baseTree, headTree]; +} + +class VersionChanges { + VersionChanges(this._repo); + + final Repo _repo; + + /// Version updates, by component. + final Map _versionUpdates = {}; + + /// The latest proposed version for [packageOrComponent]. + Version? proposedVersion(String packageOrComponent) { + final isComponent = _repo.components.containsKey(packageOrComponent); + final componentVersion = _versionUpdates['component_$packageOrComponent']; + if (isComponent) { + return componentVersion; + } + return _versionUpdates[packageOrComponent]; + } + + /// Updates the proposed version for [package]. + void updateProposedVersion( + PackageInfo package, + Version version, { + required bool propagateToComponent, + }) { + final currentVersion = proposedVersion(package.name); + if (currentVersion != null && version <= currentVersion) { + return; + } + if (propagateToComponent) { + final component = _repo.aftConfig.componentForPackage(package.name); + _versionUpdates['component_$component'] = version; + } + _versionUpdates[package.name] = version; + } +} diff --git a/packages/aft/lib/src/util.dart b/packages/aft/lib/src/util.dart index a25c95c37f..72c0d8fcb1 100644 --- a/packages/aft/lib/src/util.dart +++ b/packages/aft/lib/src/util.dart @@ -5,6 +5,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:async/async.dart'; +import 'package:graphs/graphs.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; String? getEnv(String envName) { final value = Platform.environment[envName]; @@ -46,3 +48,54 @@ extension ProcessUtil on Process { .listen((line) => sink('$prefix$line')); } } + +/// Performs a depth-first search on [graph] calling [visit] for every node. +/// +/// If [root] is specified, the search is started there. +void dfs( + Map> graph, + void Function(Node) visit, { + Node? root, +}) { + final visited = {}; + void search(Node node, List edges) { + visited.add(node); + visit(node); + for (final edge in edges) { + if (!visited.contains(edge)) { + search(edge, graph[edge]!); + } + } + } + + if (root != null) { + assert(graph.containsKey(root), 'Root is not in graph'); + search(root, graph[root]!); + } else { + graph.forEach(search); + } +} + +/// Sorts packages in topological order so they may be published in the order +/// they're sorted. +/// +/// Packages with inter-dependencies cannot be topologically sorted and will +/// throw a [CycleException]. +void sortPackagesTopologically( + List packages, + Pubspec Function(T) getPubspec, +) { + final pubspecs = packages.map(getPubspec); + final packageNames = pubspecs.map((el) => el.name).toList(); + final graph = >{ + for (var package in pubspecs) + package.name: package.dependencies.keys.where(packageNames.contains), + }; + final ordered = topologicalSort(graph.keys, (key) => graph[key]!); + packages.sort((a, b) { + // `ordered` is in reverse ordering to our desired publish precedence. + return ordered + .indexOf(getPubspec(b).name) + .compareTo(ordered.indexOf(getPubspec(a).name)); + }); +} diff --git a/packages/aft/pubspec.yaml b/packages/aft/pubspec.yaml index bf5da68350..0fdca05b83 100644 --- a/packages/aft/pubspec.yaml +++ b/packages/aft/pubspec.yaml @@ -11,24 +11,30 @@ dependencies: async: ^2.8.0 aws_common: path: ../aws_common + built_collection: ^5.0.0 + built_value: ">=8.4.0 <8.5.0" checked_yaml: ^2.0.0 cli_util: ^0.3.5 collection: ^1.16.0 flutter_tools: git: url: https://github.com/flutter/flutter.git - ref: f186e1bd3ed0c8471ad9b52929f70292621ab796 + ref: 7a743c8816fd8ff7df99858c545c1dbe396d1103 path: packages/flutter_tools git: ^2.0.0 + glob: ^2.1.0 graphs: ^2.1.0 http: ^0.13.0 json_annotation: ^4.7.0 + libgit2dart: + path: external/libgit2dart + markdown: ^5.0.0 meta: ^1.7.0 path: any pub: git: url: https://github.com/dart-lang/pub.git - ref: 5527068c31a3b92beb8f9bfe0953ff9580b3dc86 + ref: 6cbbec2abbf6a54074ae1005c06a26dfb14a86c8 pub_semver: ^2.1.1 pubspec_parse: ^1.2.0 smithy: @@ -45,6 +51,7 @@ dependency_overrides: aws_signature_v4: path: ../aws_signature_v4 built_value: ">=8.4.0 <8.5.0" + frontend_server_client: ^3.2.0 smithy: path: ../smithy/smithy smithy_aws: @@ -55,7 +62,8 @@ dependency_overrides: dev_dependencies: amplify_lints: path: ../amplify_lints - build_runner: ^2.0.0 + build_runner: ^2.2.1 + built_value_generator: 8.4.2 json_serializable: 6.5.4 test: ^1.16.0 diff --git a/packages/aft/test/amplify_command_test.dart b/packages/aft/test/amplify_command_test.dart deleted file mode 100644 index ac82236d42..0000000000 --- a/packages/aft/test/amplify_command_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import 'package:aft/aft.dart'; -import 'package:test/test.dart'; - -class MockCommand extends AmplifyCommand { - @override - String get description => throw UnimplementedError(); - - @override - String get name => throw UnimplementedError(); -} - -void main() { - group('AmplifyCommand', () { - final command = MockCommand(); - - test('rootDir', () { - expect(command.rootDir, completes); - }); - - test('allPackages', () async { - final allPackages = await command.allPackages; - expect( - allPackages, - contains('amplify_flutter'), - ); - }); - - test('globalDependencyConfig', () async { - final config = await command.aftConfig; - expect(config.dependencies, contains('uuid')); - }); - }); -} diff --git a/packages/aft/test/changelog/parser_test.dart b/packages/aft/test/changelog/parser_test.dart new file mode 100644 index 0000000000..6c7c8c3e94 --- /dev/null +++ b/packages/aft/test/changelog/parser_test.dart @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:aft/src/changelog/changelog.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + group('Changelog', () { + group('parses semver', () { + final semverStrings = { + '1.0.0': Version(1, 0, 0), + 'v1.0.0': Version(1, 0, 0), + 'v1.0.0 (08-02-2022)': Version(1, 0, 0), + '1.0.0-tag.1': Version(1, 0, 0, pre: 'tag.1'), + '1.0.0+1': Version(1, 0, 0, build: '1'), + 'NEXT': nextVersion, + }; + + for (final semver in semverStrings.entries) { + test(semver.key, () { + final changlog = Changelog.parse('## ${semver.key}'); + expect(changlog.versions.keys.single, semver.value); + }); + } + }); + }); +} diff --git a/packages/aft/test/commit_message_test.dart b/packages/aft/test/commit_message_test.dart new file mode 100644 index 0000000000..12f072875a --- /dev/null +++ b/packages/aft/test/commit_message_test.dart @@ -0,0 +1,80 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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 'package:aft/src/changelog/commit_message.dart'; +import 'package:test/test.dart'; + +void main() { + group('CommitMessage', () { + test('parses merge commits', () { + final parsed = CommitMessage.parse( + 'sha', + 'Merge pull request #1234 from user/chore/some-fix', + body: '', + ); + expect( + parsed, + isA().having( + (msg) => msg.taggedPr, + 'taggedPr', + 1234, + ), + ); + expect(parsed.bumpType, isNull); + }); + + group('VersionCommitMessage', () { + test('parses version commit', () { + final parsed = CommitMessage.parse( + 'sha', + 'chore(version): Next version', + body: '', + ); + expect( + parsed, + isA().having( + (msg) => msg.updatedComponents, + 'updatedComponents', + isEmpty, + ), + ); + expect(parsed.bumpType, isNull); + }); + + test('parses updated components', () { + final parsed = CommitMessage.parse( + 'sha', + 'chore(version): Next version', + body: ''' +New features +- a +- b +- c + +Test-Trailer:123 +Updated-Components:component_1,component_2 +''', + ); + expect( + parsed, + isA().having( + (msg) => msg.updatedComponents, + 'updatedComponents', + unorderedEquals(['component_1', 'component_2']), + ), + ); + }); + }); + }); +} diff --git a/packages/aft/test/e2e_test.dart b/packages/aft/test/e2e_test.dart new file mode 100644 index 0000000000..e738db67bb --- /dev/null +++ b/packages/aft/test/e2e_test.dart @@ -0,0 +1,491 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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. + +// ignore_for_file: overridden_fields + +import 'dart:io'; + +import 'package:aft/aft.dart'; +import 'package:aft/src/repo.dart'; +import 'package:aws_common/aws_common.dart'; +import 'package:git/git.dart' as git; +import 'package:libgit2dart/libgit2dart.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +class MockRepo extends Repo { + MockRepo( + super.rootDir, { + required this.repo, + required super.aftConfig, + super.logger, + }) : super(allPackages: {}); + + @override + final Repository repo; +} + +void main() { + final logger = AWSLogger()..logLevel = LogLevel.verbose; + + group('Repo', () { + late Repo repo; + late String baseRef; + final packageBumps = {}; + + Future runGit(List args) async { + final result = await git.runGit( + args, + processWorkingDir: repo.rootDir.path, + ); + return (result.stdout as String).trim(); + } + + PackageInfo createPackage( + String packageName, { + Map? dependencies, + Version? version, + }) { + version ??= Version(0, 1, 0); + final packagePath = p.join(repo.rootDir.path, 'packages', packageName); + final pubspec = StringBuffer( + ''' +name: $packageName +version: $version + +environment: + sdk: '>=2.17.0 <3.0.0' +''', + ); + if (dependencies != null && dependencies.isNotEmpty) { + pubspec.writeln('dependencies:'); + for (final dependency in dependencies.entries) { + pubspec.writeln(' ${dependency.key}: "${dependency.value}"'); + } + } + final changelog = ''' +## $version + +Initial version. +'''; + final pubspecUri = Uri.file(p.join(packagePath, 'pubspec.yaml')); + File.fromUri(pubspecUri) + ..createSync(recursive: true) + ..writeAsStringSync(pubspec.toString()); + File(p.join(packagePath, 'CHANGELOG.md')) + ..createSync(recursive: true) + ..writeAsStringSync(changelog); + + final package = PackageInfo( + name: packageName, + path: packagePath, + pubspecInfo: PubspecInfo.fromUri(pubspecUri), + flavor: PackageFlavor.dart, + ); + + repo.allPackages[packageName] = package; + return package; + } + + Future makeChange( + String commitTitle, + List packages, { + Map? trailers, + }) async { + for (final package in packages) { + final newDir = Directory(p.join(repo.rootDir.path, 'packages', package)) + .createTempSync(); + File(p.join(newDir.path, 'file.txt')).createSync(); + } + + await runGit(['add', '.']); + await runGit([ + 'commit', + '-m', + commitTitle, + if (trailers != null) + ...trailers.entries + .expand((e) => ['--trailer', '${e.key}:${e.value}']), + ]); + return runGit(['rev-parse', 'HEAD']); + } + + setUp(() async { + final gitDir = Directory.systemTemp.createTempSync('aft'); + repo = MockRepo( + gitDir, + repo: Repository.init(path: gitDir.path), + logger: logger, + aftConfig: const AftConfig( + components: [ + AftComponent( + name: 'Amplify Flutter', + packages: [ + 'amplify_auth_cognito', + 'amplify_auth_cognito_ios', + ], + ), + ], + ), + ); + await runGit( + ['commit', '--allow-empty', '-m', 'Initial commit'], + ); + await runGit(['rev-parse', 'HEAD']); + }); + + group('E2E', () { + final nextVersion = Version(1, 0, 0, pre: 'next.0'); + final coreVersion = Version(0, 1, 0); + final nextConstraint = VersionRange( + min: nextVersion, + max: Version(1, 0, 0, pre: 'next.1'), + includeMin: true, + includeMax: false, + ); + final coreConstraint = VersionConstraint.compatibleWith(coreVersion); + + setUp(() async { + createPackage( + 'amplify_auth_cognito', + version: nextVersion, + dependencies: { + 'amplify_auth_cognito_ios': nextConstraint, + 'amplify_auth_cognito_dart': coreConstraint, + 'amplify_core': nextConstraint, + 'aws_common': coreConstraint, + }, + ); + createPackage('amplify_auth_cognito_ios', version: nextVersion); + createPackage( + 'amplify_auth_cognito_dart', + version: coreVersion, + dependencies: { + 'amplify_core': coreConstraint, + 'aws_common': coreConstraint, + }, + ); + createPackage( + 'amplify_core', + version: nextVersion, + dependencies: { + 'aws_common': coreConstraint, + }, + ); + createPackage('aws_common', version: coreVersion); + + await runGit(['add', '.']); + await runGit(['commit', '-m', 'Add packages']); + baseRef = await runGit(['rev-parse', 'HEAD']); + + // Make changes that affect: + // - Only a leaf package + // - Only one package of a component + // - Only a root package + await makeChange('fix(aws_common): Fix type', ['aws_common']); + await makeChange( + 'chore(amplify_auth_cognito_ios): Update iOS dependency', + ['amplify_auth_cognito_ios'], + ); + await makeChange( + 'fix(amplify_auth_cognito_ios)!: Change iOS dependency', + ['amplify_auth_cognito_ios'], + ); + await makeChange( + 'feat(amplify_core): New hub events', + ['amplify_core'], + ); + await makeChange( + 'feat(auth): New feature', + [ + 'amplify_core', + 'amplify_auth_cognito', + 'amplify_auth_cognito_dart', + ], + ); + final coreBump = await makeChange( + 'chore(version): Release core', + [ + 'amplify_core', + 'amplify_auth_cognito_dart', + ], + trailers: { + 'Updated-Components': 'amplify_core, amplify_auth_cognito_dart', + }, + ); + packageBumps['amplify_core'] = coreBump; + packageBumps['amplify_auth_cognito_dart'] = coreBump; + final flutterBump = await makeChange( + 'chore(version): Release flutter', + [ + 'amplify_auth_cognito', + 'amplify_auth_cognito_ios', + ], + trailers: { + 'Updated-Components': 'Amplify Flutter', + }, + ); + packageBumps['amplify_auth_cognito'] = flutterBump; + packageBumps['amplify_auth_cognito_ios'] = flutterBump; + }); + + GitChanges changesForPackage( + String package, { + required String baseRef, + }) { + return repo.changes(baseRef, 'HEAD'); + } + + group('changesForPackage', () { + group('w/ no version bump', () { + final packages = { + 'aws_common': 1, + 'amplify_core': 3, + 'amplify_auth_cognito_dart': 2, + 'amplify_auth_cognito': 2, + 'amplify_auth_cognito_ios': 3, + }; + for (final entry in packages.entries) { + test(entry.key, () { + final numCommits = entry.value; + final package = repo.allPackages[entry.key]!; + expect( + changesForPackage( + package.name, + baseRef: baseRef, + ).commitsByPackage[package], + hasLength(numCommits), + ); + }); + } + }); + + group('w/ version bump', () { + final packages = { + 'aws_common': 1, // since it was never released + 'amplify_core': 0, + 'amplify_auth_cognito_dart': 0, + 'amplify_auth_cognito': 0, + 'amplify_auth_cognito_ios': 0, + }; + for (final entry in packages.entries) { + test(entry.key, () { + final package = repo.allPackages[entry.key]!; + final lastBump = repo.latestBumpRef(package.name); + expect(lastBump, packageBumps[package.name]); + + final numCommits = entry.value; + expect( + changesForPackage( + package.name, + baseRef: lastBump ?? baseRef, + ).commitsByPackage[package], + hasLength(numCommits), + ); + }); + } + }); + }); + + group('calculates changes', () { + final numCommits = { + 'aws_common': 1, + 'amplify_core': 3, + 'amplify_auth_cognito_dart': 2, + 'amplify_auth_cognito': 2, + 'amplify_auth_cognito_ios': 3, + }; + final changelogs = { + 'aws_common': ''' +## NEXT + +### Fixes +- fix(aws_common): Fix type +''', + 'amplify_core': ''' +## NEXT + +### Features +- feat(amplify_core): New hub events +- feat(auth): New feature +''', + 'amplify_auth_cognito_dart': ''' +## NEXT + +### Features +- feat(auth): New feature +''', + 'amplify_auth_cognito': ''' +## NEXT + +### Features +- feat(auth): New feature +''', + 'amplify_auth_cognito_ios': ''' +## NEXT + +### Breaking Changes +- fix(amplify_auth_cognito_ios)!: Change iOS dependency +''', + }; + + for (final check in numCommits.entries) { + final packageName = check.key; + + test(packageName, () { + final package = repo.allPackages[packageName]!; + final changes = changesForPackage(package.name, baseRef: baseRef); + final commits = changes.commitsByPackage[package]!; + expect(commits, hasLength(check.value)); + + // Bump changelogs to NEXT + final updateChangelog = package.changelog.update( + commits: commits, + ); + expect(updateChangelog.hasUpdate, true); + expect( + updateChangelog.newText, + equalsIgnoringWhitespace(changelogs[packageName]!), + ); + }); + } + }); + + group('bumps versions', () { + final finalVersions = { + 'aws_common': '0.1.0+1', + 'amplify_core': '1.0.0-next.0+1', + 'amplify_auth_cognito_dart': '0.1.1', + 'amplify_auth_cognito': '1.0.0-next.1', + 'amplify_auth_cognito_ios': '1.0.0-next.1', + }; + final updatedChangelogs = { + 'aws_common': ''' +## 0.1.0+1 + +### Fixes +- fix(aws_common): Fix type +''', + 'amplify_core': ''' +## 1.0.0-next.0+1 + +### Features +- feat(amplify_core): New hub events +- feat(auth): New feature +''', + 'amplify_auth_cognito_dart': ''' +## 0.1.1 + +### Features +- feat(auth): New feature +''', + 'amplify_auth_cognito': ''' +## 1.0.0-next.1 + +### Features +- feat(auth): New feature +''', + 'amplify_auth_cognito_ios': ''' +## 1.0.0-next.1 + +### Breaking Changes +- fix(amplify_auth_cognito_ios)!: Change iOS dependency +''', + }; + final updatedPubspecs = { + 'aws_common': ''' +name: aws_common +version: 0.1.0+1 + +environment: + sdk: '>=2.17.0 <3.0.0' +''', + 'amplify_core': ''' +name: amplify_core +version: 1.0.0-next.0+1 + +environment: + sdk: '>=2.17.0 <3.0.0' + +dependencies: + aws_common: "^0.1.0" +''', + 'amplify_auth_cognito_dart': ''' +name: amplify_auth_cognito_dart +version: 0.1.1 + +environment: + sdk: '>=2.17.0 <3.0.0' + +dependencies: + amplify_core: ">=1.0.0-next.0+1 <1.0.0-next.1" + aws_common: "^0.1.0" +''', + 'amplify_auth_cognito': ''' +name: amplify_auth_cognito +version: 1.0.0-next.1 + +environment: + sdk: '>=2.17.0 <3.0.0' + +dependencies: + amplify_auth_cognito_ios: ">=1.0.0-next.1 <1.0.0-next.2" + amplify_auth_cognito_dart: ">=0.1.1 <0.2.0" + amplify_core: ">=1.0.0-next.0+1 <1.0.0-next.1" + aws_common: "^0.1.0" +''', + 'amplify_auth_cognito_ios': ''' +name: amplify_auth_cognito_ios +version: 1.0.0-next.1 + +environment: + sdk: '>=2.17.0 <3.0.0' +''', + }; + + setUp(() async { + repo.bumpAllVersions( + changesForPackage: (pkg) => changesForPackage( + pkg.name, + baseRef: baseRef, + ), + ); + }); + + for (final check in finalVersions.entries) { + final packageName = check.key; + test(packageName, () { + final package = repo.allPackages[packageName]!; + final newVersion = + repo.versionChanges.proposedVersion(package.name); + expect(newVersion.toString(), finalVersions[packageName]); + + final changelog = repo.changelogUpdates[package]!.newText; + expect( + changelog, + equalsIgnoringWhitespace(updatedChangelogs[packageName]!), + ); + + final pubspec = package.pubspecInfo.pubspecYamlEditor.toString(); + expect( + pubspec, + equalsIgnoringWhitespace(updatedPubspecs[packageName]!), + ); + }); + } + }); + }); + }); +} diff --git a/packages/aft/test/model_test.dart b/packages/aft/test/model_test.dart new file mode 100644 index 0000000000..01c039fa04 --- /dev/null +++ b/packages/aft/test/model_test.dart @@ -0,0 +1,113 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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 'package:aft/src/models.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + group('AmplifyVersion', () { + const proagation = VersionPropagation.minor; + + test('0-version', () { + final version = Version(0, 1, 0); + + final patch = version.nextAmplifyVersion(VersionBumpType.patch); + expect(patch, Version(0, 1, 0, build: '1')); + expect(proagation.propagateToComponent(version, patch), false); + + final nextPatch = patch.nextAmplifyVersion(VersionBumpType.patch); + expect(nextPatch, Version(0, 1, 0, build: '2')); + expect(proagation.propagateToComponent(version, nextPatch), false); + + final nonBreaking = + version.nextAmplifyVersion(VersionBumpType.nonBreaking); + expect(nonBreaking, Version(0, 1, 1)); + expect(proagation.propagateToComponent(version, nonBreaking), false); + + final breaking = version.nextAmplifyVersion(VersionBumpType.breaking); + expect(breaking, Version(0, 2, 0)); + expect(proagation.propagateToComponent(version, breaking), true); + }); + + test('pre-release (untagged)', () { + final version = Version(1, 0, 0, pre: 'next.0'); + + final patch = version.nextAmplifyVersion(VersionBumpType.patch); + expect( + patch, + Version(1, 0, 0, pre: 'next.0', build: '1'), + ); + expect(proagation.propagateToComponent(version, patch), false); + + final nonBreaking = + version.nextAmplifyVersion(VersionBumpType.nonBreaking); + expect( + nonBreaking, + Version(1, 0, 0, pre: 'next.0', build: '1'), + ); + expect(proagation.propagateToComponent(version, nonBreaking), false); + + final breaking = version.nextAmplifyVersion(VersionBumpType.breaking); + expect( + breaking, + Version(1, 0, 0, pre: 'next.1'), + ); + expect(proagation.propagateToComponent(version, breaking), true); + }); + + test('pre-release (tagged)', () { + final version = Version(1, 0, 0, pre: 'next.0', build: '1'); + + final patch = version.nextAmplifyVersion(VersionBumpType.patch); + expect( + patch, + Version(1, 0, 0, pre: 'next.0', build: '2'), + ); + expect(proagation.propagateToComponent(version, patch), false); + + final nonBreaking = + version.nextAmplifyVersion(VersionBumpType.nonBreaking); + expect( + nonBreaking, + Version(1, 0, 0, pre: 'next.0', build: '2'), + ); + expect(proagation.propagateToComponent(version, nonBreaking), false); + + final breaking = version.nextAmplifyVersion(VersionBumpType.breaking); + expect( + breaking, + Version(1, 0, 0, pre: 'next.1'), + ); + expect(proagation.propagateToComponent(version, breaking), true); + }); + + test('release', () { + final version = Version(1, 0, 0); + + final patch = version.nextAmplifyVersion(VersionBumpType.patch); + expect(patch, Version(1, 0, 1)); + expect(proagation.propagateToComponent(version, patch), false); + + final nonBreaking = + version.nextAmplifyVersion(VersionBumpType.nonBreaking); + expect(nonBreaking, Version(1, 1, 0)); + expect(proagation.propagateToComponent(version, nonBreaking), true); + + final breaking = version.nextAmplifyVersion(VersionBumpType.breaking); + expect(breaking, Version(2, 0, 0)); + expect(proagation.propagateToComponent(version, breaking), true); + }); + }); +} diff --git a/packages/aft/test/publish_command_test.dart b/packages/aft/test/util_test.dart similarity index 98% rename from packages/aft/test/publish_command_test.dart rename to packages/aft/test/util_test.dart index b0268aeb50..129201d53d 100644 --- a/packages/aft/test/publish_command_test.dart +++ b/packages/aft/test/util_test.dart @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import 'package:aft/aft.dart'; +import 'package:aft/src/util.dart'; import 'package:graphs/graphs.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; diff --git a/packages/aft/workflow.yaml b/packages/aft/workflow.yaml new file mode 100644 index 0000000000..8ed80f17a0 --- /dev/null +++ b/packages/aft/workflow.yaml @@ -0,0 +1,52 @@ +name: aft +on: + push: + branches: + - main + - stable + - next + paths: + - 'packages/aft/**/*.dart' + pull_request: + paths: + - 'packages/aft/**/*.dart' + schedule: + - cron: "0 0 * * 0" # Every Sunday at 00:00 +defaults: + run: + shell: bash +permissions: read-all + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Git Checkout + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # 3.1.0 + with: + submodules: true + + # Needed for `git` but only ever used locally. + - name: Git Config + run: | + git config --global user.email "amplify-flutter@amazon.com" + git config --global user.name "Amplify Flutter" + + - name: Setup Dart + uses: dart-lang/setup-dart@196f54580e9eee2797c57e85e289339f85e6779d # main + with: + sdk: stable + + - name: Get Packages + working-directory: packages/aft + run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd external/libgit2dart; git apply ../libgit2dart.patch ) + dart pub upgrade + mkdir linux + cp external/libgit2dart/linux/*.so linux + + - name: Run Tests + working-directory: packages/aft + run: dart test diff --git a/packages/aws_common/lib/src/logging/aws_logger.dart b/packages/aws_common/lib/src/logging/aws_logger.dart index 6d33f836d7..42a9cce16a 100644 --- a/packages/aws_common/lib/src/logging/aws_logger.dart +++ b/packages/aws_common/lib/src/logging/aws_logger.dart @@ -39,15 +39,15 @@ class AWSLogger implements Closeable { /// {@macro aws_common.logging.aws_logger} @protected AWSLogger.protected(this._logger) { - _init(); + _init(this); } static bool _initialized = false; - static void _init() { + static void _init(AWSLogger rootLogger) { if (_initialized) return; _initialized = true; hierarchicalLoggingEnabled = true; - AWSLogger().registerPlugin(const SimpleLogPrinter()); + rootLogger.registerPlugin(const SimpleLogPrinter()); } /// The root namespace for all [AWSLogger] instances. diff --git a/packages/aws_sdk/smoke_test/workflow.yaml b/packages/aws_sdk/smoke_test/workflow.yaml index d487fb483f..14dd5f7ec3 100644 --- a/packages/aws_sdk/smoke_test/workflow.yaml +++ b/packages/aws_sdk/smoke_test/workflow.yaml @@ -25,7 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@e2f20e631ae6d7dd3b768f56a5d2af784dd54791 # v2.5.0 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # 3.1.0 + + - name: Git Submodules + run: git submodule update --init - name: Setup Dart uses: dart-lang/setup-dart@196f54580e9eee2797c57e85e289339f85e6779d # main @@ -34,6 +37,8 @@ jobs: - name: Link Packages run: | + # Patch libgit2dart (see https://github.com/dart-lang/pub/issues/3563) + ( cd packages/aft/external/libgit2dart; git apply ../libgit2dart.patch ) dart pub global activate -spath packages/aft aft link