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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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](...)`).
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion alex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/commands/changelog/changelog_command.dart
Original file line number Diff line number Diff line change
@@ -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<int> doRun() async {
printUsage();
return 0;
}
}
48 changes: 48 additions & 0 deletions lib/commands/changelog/update_issue_links_command.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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: <base 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).');
}
}
11 changes: 6 additions & 5 deletions lib/commands/feature/finish_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -187,7 +188,7 @@ class FinishCommand extends FeatureCommandBase {
}

Future<bool> _updateChangelog(Console console, FileSystem fs, int issueId,
String? changelogLine) async {
String? changelogLine, String? issueUrl) async {
final changelog = Changelog(fs);

if (!(await changelog.exists)) {
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions lib/runner/alex_command_runner.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,6 +39,7 @@ class AlexCommandRunner extends CommandRunner<int> {
CodeCommand(),
PubspecCommand(),
FeatureCommand(),
ChangelogCommand(),
SettingsCommand(),
UpdateCommand(),
CustomCommand(),
Expand Down
53 changes: 43 additions & 10 deletions lib/src/changelog/changelog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,40 @@ class Changelog {
"$_nextVersionHeader\n\n$_versionHeaderPrefix$version - $dateStr"));
}

Future<void> addAddedEntry(String line, [int? issueId]) =>
_addEntry(_addedSubheader, line, issueId);
Future<void> addFixedEntry(String line, [int? issueId]) =>
_addEntry(_fixedSubheader, line, issueId);
Future<void> 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<int> linkIssueReferences(String issueUrl) async {
final base =
issueUrl.endsWith('/') ? issueUrl.substring(0, issueUrl.length - 1) : issueUrl;

final str = await content;
final pattern = RegExp(r'(?<!\])\(#(\d+)\)');
var count = 0;
final updated = str.replaceAllMapped(pattern, (m) {
count++;
final id = m.group(1);
return '([#$id]($base/$id))';
});

if (count > 0) _update(updated);
return count;
}
Comment thread
greymag marked this conversation as resolved.

Future<void> addAddedEntry(String line, [int? issueId, String? issueUrl]) =>
_addEntry(_addedSubheader, line, issueId, issueUrl);

Future<void> addFixedEntry(String line, [int? issueId, String? issueUrl]) =>
_addEntry(_fixedSubheader, line, issueId, issueUrl);

Future<void> addPreReleaseEntry(String line,
[int? issueId, String? issueUrl]) =>
_addEntry(_preReleaseSubheader, line, issueId, issueUrl);

String get _filepath => _filename;

Future<void> _addEntry(String subheader, String line, int? issueId) async {
Future<void> _addEntry(
String subheader, String line, int? issueId, String? issueUrl) async {
const sep = '\n';
const entryStart = '- ';
const entryEnd = '.';
Expand Down Expand Up @@ -168,21 +192,30 @@ 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--) {
if (clearedStr[i] != '.') break;
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();
}
}
Expand Down
11 changes: 11 additions & 0 deletions lib/src/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/src/version.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 64 additions & 0 deletions test/src/changelog/changelog_test.contents.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
''';
38 changes: 38 additions & 0 deletions test/src/changelog/changelog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading