diff --git a/CHANGELOG.md b/CHANGELOG.md index 01854dc..f89a791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.11.0 + +* [Feature] `finish`: append issue references as a markdown link (`[#N](url/N)`) when `issue_url` is set in alex config; falls back to the plain `(#N)` suffix otherwise. +* New command `alex changelog update-issue-links` (alias `cl uil`) — replaces plain `(#N)` issue references in `CHANGELOG.md` with markdown links built from `issue_url`. Reports an error if `issue_url` is not configured. +* New optional config option `issue_url` (in `alex.yaml` or the `alex:` section of `pubspec.yaml`). + ## 1.10.1 * [Feature] `finish`: skip appending issue number suffix if the changelog line already contains a markdown link to the issue (e.g. `[#1234](...)`). diff --git a/CLAUDE.md b/CLAUDE.md index 74890cf..07a5a2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ To run a single test file: `dart test test/src/changelog/changelog_test.dart` **Command hierarchy:** - `lib/commands/release/` — gitflow-based releases, changelog updates, optional ChatGPT release notes - `lib/commands/feature/` — feature branch finish (merge to develop, delete remote) +- `lib/commands/changelog/` — utilities for `CHANGELOG.md` (e.g. `update-issue-links`) - `lib/commands/l10n/` — 8 subcommands for ARB/XML/iOS strings localization - `lib/commands/code/` — runs `build_runner` (supports subprojects) - `lib/commands/pubspec/` — `pub get` and dependency updates (supports workspaces) diff --git a/alex.yaml b/alex.yaml index 09e7d15..088d90a 100644 --- a/alex.yaml +++ b/alex.yaml @@ -2,7 +2,13 @@ # If you define it in pubspec.yaml, # you should add it in section alex: -# Localization +# Base URL for issue tracker (optional). +# If set, changelog entries will reference issues with a markdown link +# instead of a plain `(#N)` suffix. Trailing `/` is allowed. +# Example: https://site/project/issue +issue_url: + +# Localization l10n: output_dir: lib/application/l10n source_file: lib/application/localization.dart diff --git a/lib/commands/changelog/changelog_command.dart b/lib/commands/changelog/changelog_command.dart new file mode 100644 index 0000000..b02cc7e --- /dev/null +++ b/lib/commands/changelog/changelog_command.dart @@ -0,0 +1,16 @@ +import 'package:alex/runner/alex_command.dart'; + +import 'update_issue_links_command.dart'; + +class ChangelogCommand extends AlexCommand { + ChangelogCommand() + : super('changelog', 'Work with CHANGELOG.md', const ['cl']) { + addSubcommand(UpdateIssueLinksCommand()); + } + + @override + Future doRun() async { + printUsage(); + return 0; + } +} diff --git a/lib/commands/changelog/update_issue_links_command.dart b/lib/commands/changelog/update_issue_links_command.dart new file mode 100644 index 0000000..22c3ce2 --- /dev/null +++ b/lib/commands/changelog/update_issue_links_command.dart @@ -0,0 +1,48 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/changelog/changelog.dart'; +import 'package:alex/src/fs/fs.dart'; +import 'package:alex/src/pub_spec.dart'; + +/// Replaces plain `(#N)` issue references in CHANGELOG.md with markdown links +/// using `issue_url` from alex config. +class UpdateIssueLinksCommand extends AlexCommand { + UpdateIssueLinksCommand() + : super( + 'update-issue-links', + 'Replace plain (#N) issue references in CHANGELOG.md ' + 'with markdown links using issue_url from config.', + const ['uil'], + ); + + @override + Future doRun() async { + const fs = IOFileSystem(); + + final pubspecExists = await Spec.exists(fs); + if (!pubspecExists) { + return error(1, + message: 'You should run command from project root directory.'); + } + + final issueUrl = config.issueUrl; + if (issueUrl == null) { + return error(1, + message: 'issue_url is not set in alex config. ' + 'Add `issue_url: ` to alex.yaml or to the alex section ' + 'of pubspec.yaml.'); + } + + final changelog = Changelog(fs); + if (!(await changelog.exists)) { + return error(1, message: 'CHANGELOG.md is not found.'); + } + + final count = await changelog.linkIssueReferences(issueUrl); + if (count == 0) { + return success(message: 'No plain issue references to update.'); + } + + await changelog.save(); + return success(message: 'Updated $count issue reference(s).'); + } +} diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index 16709c8..c71501b 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -109,7 +109,8 @@ class FinishCommand extends FeatureCommandBase { git.gitflowFeatureFinish(branchName, deleteBranch: false); printVerbose('Add entry in changelog'); - final changed = await _updateChangelog(console, fs, issueId, changelog); + final changed = await _updateChangelog( + console, fs, issueId, changelog, config.issueUrl); if (changed) { printVerbose('Commit changelog'); @@ -187,7 +188,7 @@ class FinishCommand extends FeatureCommandBase { } Future _updateChangelog(Console console, FileSystem fs, int issueId, - String? changelogLine) async { + String? changelogLine, String? issueUrl) async { final changelog = Changelog(fs); if (!(await changelog.exists)) { @@ -243,13 +244,13 @@ Which section to add: printVerbose('Write to changelog: $line'); switch (section) { case 1: - await changelog.addAddedEntry(line, issueId); + await changelog.addAddedEntry(line, issueId, issueUrl); break; case 2: - await changelog.addFixedEntry(line, issueId); + await changelog.addFixedEntry(line, issueId, issueUrl); break; case 3: - await changelog.addPreReleaseEntry(line, issueId); + await changelog.addPreReleaseEntry(line, issueId, issueUrl); break; } await changelog.save(); diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index c5a0cf9..979939b 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:alex/commands/changelog/changelog_command.dart'; import 'package:alex/commands/code/code_command.dart'; import 'package:alex/commands/custom/custom_command.dart'; import 'package:alex/commands/custom/user_custom_command.dart'; @@ -38,6 +39,7 @@ class AlexCommandRunner extends CommandRunner { CodeCommand(), PubspecCommand(), FeatureCommand(), + ChangelogCommand(), SettingsCommand(), UpdateCommand(), CustomCommand(), diff --git a/lib/src/changelog/changelog.dart b/lib/src/changelog/changelog.dart index a99bc69..42feeb2 100644 --- a/lib/src/changelog/changelog.dart +++ b/lib/src/changelog/changelog.dart @@ -95,16 +95,40 @@ class Changelog { "$_nextVersionHeader\n\n$_versionHeaderPrefix$version - $dateStr")); } - Future addAddedEntry(String line, [int? issueId]) => - _addEntry(_addedSubheader, line, issueId); - Future addFixedEntry(String line, [int? issueId]) => - _addEntry(_fixedSubheader, line, issueId); - Future addPreReleaseEntry(String line, [int? issueId]) => - _addEntry(_preReleaseSubheader, line, issueId); + /// Replaces plain `(#N)` issue references with markdown links to + /// `[issueUrl]/N`. Skips occurrences that are already part of a markdown + /// link. Returns the number of replacements. + Future linkIssueReferences(String issueUrl) async { + final base = + issueUrl.endsWith('/') ? issueUrl.substring(0, issueUrl.length - 1) : issueUrl; + + final str = await content; + final pattern = RegExp(r'(? 0) _update(updated); + return count; + } + + Future addAddedEntry(String line, [int? issueId, String? issueUrl]) => + _addEntry(_addedSubheader, line, issueId, issueUrl); + + Future addFixedEntry(String line, [int? issueId, String? issueUrl]) => + _addEntry(_fixedSubheader, line, issueId, issueUrl); + + Future addPreReleaseEntry(String line, + [int? issueId, String? issueUrl]) => + _addEntry(_preReleaseSubheader, line, issueId, issueUrl); String get _filepath => _filename; - Future _addEntry(String subheader, String line, int? issueId) async { + Future _addEntry( + String subheader, String line, int? issueId, String? issueUrl) async { const sep = '\n'; const entryStart = '- '; const entryEnd = '.'; @@ -168,11 +192,20 @@ class Changelog { var str = line; String? issueSuffix; if (issueId != null) { - issueSuffix = '(#$issueId)'; if (str.contains('[#$issueId](')) { // Line already contains a markdown link to the issue, skip suffix. issueSuffix = null; } else { + if (issueUrl != null && issueUrl.isNotEmpty) { + final base = issueUrl.endsWith('/') + ? issueUrl.substring(0, issueUrl.length - 1) + : issueUrl; + issueSuffix = '([#$issueId]($base/$issueId))'; + } else { + issueSuffix = '(#$issueId)'; + } + + final plainSuffix = '(#$issueId)'; var clearedStr = str.trimRight(); var clearedEnd = clearedStr.length; for (var i = clearedEnd - 1; i >= 0; i--) { @@ -180,9 +213,9 @@ class Changelog { clearedEnd--; } clearedStr = clearedStr.substring(0, clearedEnd); - if (clearedStr.endsWith(issueSuffix)) { + if (clearedStr.endsWith(plainSuffix)) { str = clearedStr - .substring(0, clearedStr.length - issueSuffix.length) + .substring(0, clearedStr.length - plainSuffix.length) .trimRight(); } } diff --git a/lib/src/config.dart b/lib/src/config.dart index 5ff6ec8..8516c25 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -100,6 +100,17 @@ class AlexConfig { AlexConfig._(this._path, this._data); + /// Base URL for issue tracker. If set, changelog entries will reference + /// issues with a markdown link instead of a plain `(#N)` suffix. + /// + /// Example: `https://site/project/issue`. + String? get issueUrl { + const key = 'issue_url'; + final value = _data[key]; + if (value is String && value.isNotEmpty) return value; + return null; + } + L10nConfig get l10n { const key = 'l10n'; return _l10n ??= _data.containsKey(key) diff --git a/lib/src/version.dart b/lib/src/version.dart index b958a73..4f8f0c1 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.10.1'; +const packageVersion = '1.11.0'; diff --git a/pubspec.yaml b/pubspec.yaml index b3a9583..7e956cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/innim/ repository: https://github.com/Innim/alex issue_tracker: https://github.com/Innim/alex/issues -version: 1.10.1 +version: 1.11.0 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/src/changelog/changelog_test.contents.dart b/test/src/changelog/changelog_test.contents.dart index bdbf2ec..b98e717 100644 --- a/test/src/changelog/changelog_test.contents.dart +++ b/test/src/changelog/changelog_test.contents.dart @@ -191,3 +191,67 @@ const nextReleaseWithNoReleasedVersion = ''' - New bug fix. '''; + +const changelogWithPlainIssueReferences = ''' +## Next release + +### Added + +- Some new feature (#123). +- Another feature (#456). + +### Fixed + +- Bug fix (#789). + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1 (#100). +- Feature 2 (#200). + +### Fixed + +- Bug fix (#300). +'''; + +const changelogWithMixedIssueReferences = ''' +## Next release + +### Added + +- Some new feature (#123). +- Already linked [#456](https://example.com/456). + +### Fixed + +- Bug fix (#789). +- Another fix [#999](https://example.com/999). + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1 (#100). +'''; + +const changelogWithNoIssueReferences = ''' +## Next release + +### Added + +- Some new feature. +- Another feature. + +### Fixed + +- Bug fix. + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1. +- Feature 2. +'''; diff --git a/test/src/changelog/changelog_test.dart b/test/src/changelog/changelog_test.dart index f597779..5d53b93 100644 --- a/test/src/changelog/changelog_test.dart +++ b/test/src/changelog/changelog_test.dart @@ -186,6 +186,44 @@ void main() { ); }); }); + + group('linkIssueReferences()', () { + test('should convert plain (#N) to markdown links', () async { + final changelog = Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); + + final count = await changelog.linkIssueReferences('https://example.com/issue'); + + expect(count, 6); + expect(await changelog.content, linkIssueReferencesResult); + }); + + test('should handle trailing slash in issueUrl', () async { + final changelog = Changelog(_FileSystemMock(changelogWithPlainIssueReferences)); + + final count = await changelog.linkIssueReferences('https://example.com/issue/'); + + expect(count, 6); + expect(await changelog.content, linkIssueReferencesResultWithTrailingSlash); + }); + + test('should not modify already-linked issues', () async { + final changelog = Changelog(_FileSystemMock(changelogWithMixedIssueReferences)); + + final count = await changelog.linkIssueReferences('https://example.com/issue'); + + expect(count, 3); + expect(await changelog.content, linkIssueReferencesMixedResult); + }); + + test('should return 0 when no plain issue references exist', () async { + final changelog = Changelog(_FileSystemMock(changelogWithNoIssueReferences)); + + final count = await changelog.linkIssueReferences('https://example.com/issue'); + + expect(count, 0); + expect(await changelog.content, changelogWithNoIssueReferences); + }); + }); } class _FileSystemMock extends Mock implements FileSystem { diff --git a/test/src/changelog/changelog_test.results.dart b/test/src/changelog/changelog_test.results.dart index 247288b..72f960c 100644 --- a/test/src/changelog/changelog_test.results.dart +++ b/test/src/changelog/changelog_test.results.dart @@ -243,3 +243,71 @@ const addFixedResultWithAddedAndPreRelease = ''' - Old bug fix 1. - Old bug fix 2. '''; + +const linkIssueReferencesResult = ''' +## Next release + +### Added + +- Some new feature ([#123](https://example.com/issue/123)). +- Another feature ([#456](https://example.com/issue/456)). + +### Fixed + +- Bug fix ([#789](https://example.com/issue/789)). + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1 ([#100](https://example.com/issue/100)). +- Feature 2 ([#200](https://example.com/issue/200)). + +### Fixed + +- Bug fix ([#300](https://example.com/issue/300)). +'''; + +const linkIssueReferencesResultWithTrailingSlash = ''' +## Next release + +### Added + +- Some new feature ([#123](https://example.com/issue/123)). +- Another feature ([#456](https://example.com/issue/456)). + +### Fixed + +- Bug fix ([#789](https://example.com/issue/789)). + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1 ([#100](https://example.com/issue/100)). +- Feature 2 ([#200](https://example.com/issue/200)). + +### Fixed + +- Bug fix ([#300](https://example.com/issue/300)). +'''; + +const linkIssueReferencesMixedResult = ''' +## Next release + +### Added + +- Some new feature ([#123](https://example.com/issue/123)). +- Already linked [#456](https://example.com/456). + +### Fixed + +- Bug fix ([#789](https://example.com/issue/789)). +- Another fix [#999](https://example.com/999). + +## v0.8.0+4064 - 2021-09-24 + +### Added + +- Feature 1 ([#100](https://example.com/issue/100)). +''';