xworkmate-app/tool/render_release_docs.dart
2026-03-30 15:56:47 +08:00

896 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:yaml/yaml.dart';
const _platformOrder = <String>['mobile', 'desktop', 'web'];
const _tierOrder = <String>['stable', 'beta', 'experimental'];
const _buildModeOrder = <String>['debug', 'profile', 'release'];
void main() {
final manifest = FeatureManifest.load();
final git = GitSnapshot.capture();
_writeDoc(
'docs/plans/xworkmate-ui-feature-matrix.md',
_renderFeatureMatrix(manifest, git),
);
_writeDoc(
'docs/plans/xworkmate-ui-feature-roadmap.md',
_renderFeatureRoadmap(manifest, git),
);
_writeDoc(
'docs/releases/xworkmate-release-notes.md',
_renderReleaseNotes(manifest, git),
);
_writeDoc('docs/releases/xworkmate-changelog.md', _renderChangelog(git));
stdout.writeln(
'Rendered docs/plans/xworkmate-ui-feature-matrix.md, '
'docs/plans/xworkmate-ui-feature-roadmap.md, '
'docs/releases/xworkmate-release-notes.md, '
'and docs/releases/xworkmate-changelog.md',
);
}
void _writeDoc(String relativePath, String contents) {
final file = File(relativePath);
file.parent.createSync(recursive: true);
file.writeAsStringSync(contents);
}
String _renderFeatureMatrix(FeatureManifest manifest, GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate UI Feature Matrix')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Release Policy')
..writeln()
..writeln('| Build Mode | 可见 Tier | 说明 |')
..writeln('| --- | --- | --- |');
for (final buildMode in _buildModeOrder) {
final tiers = manifest.releasePolicy[buildMode] ?? const <String>[];
final note = switch (buildMode) {
'debug' => '内部开发与功能联调',
'profile' => '预发布验收与性能验证',
'release' => '面向用户交付的正式版本',
_ => '-',
};
buffer.writeln(
'| `${_escapeMarkdown(buildMode)}` | `${tiers.join(', ')}` | $note |',
);
}
buffer
..writeln()
..writeln(
'`release_policy` 是全局上限;单个 flag 还必须同时满足 '
'`enabled: true` 和自身 `build_modes` 才会真正出现在 UI 中。',
)
..writeln()
..writeln('## Snapshot Summary')
..writeln()
..writeln(
'| 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled |',
)
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
var total = 0;
var totalEnabled = 0;
var totalDisabled = 0;
final tierTotals = <String, int>{for (final tier in _tierOrder) tier: 0};
for (final platform in _platformOrder) {
final records = manifest.recordsFor(platform);
final enabled = records.where((record) => record.enabled).length;
final disabled = records.length - enabled;
total += records.length;
totalEnabled += enabled;
totalDisabled += disabled;
final perTier = <String, int>{for (final tier in _tierOrder) tier: 0};
for (final record in records.where((record) => record.enabled)) {
perTier[record.releaseTier] = (perTier[record.releaseTier] ?? 0) + 1;
tierTotals[record.releaseTier] =
(tierTotals[record.releaseTier] ?? 0) + 1;
}
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${records.length} | $enabled | '
'${perTier['stable']} | ${perTier['beta']} | '
'${perTier['experimental']} | $disabled |',
);
}
buffer
..writeln(
'| `total` | $total | $totalEnabled | ${tierTotals['stable']} | '
'${tierTotals['beta']} | ${tierTotals['experimental']} | $totalDisabled |',
)
..writeln();
for (final platform in _platformOrder) {
buffer
..writeln('## ${_titleCase(platform)}')
..writeln()
..writeln('| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |')
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
for (final record in manifest.recordsFor(platform)) {
final modes = record.buildModes.isEmpty
? '-'
: _escapeMarkdown(record.buildModes.join(', '));
final state = record.enabled ? 'enabled' : 'disabled';
buffer.writeln(
'| `${_escapeMarkdown(record.module)}` | '
'`${_escapeMarkdown(record.name)}` | $state | '
'`${_escapeMarkdown(record.releaseTier)}` | '
'`$modes` | '
'`${_escapeMarkdown(record.uiSurface)}` | '
'${_escapeMarkdown(record.description)} |',
);
}
buffer.writeln();
}
return buffer.toString();
}
String _renderFeatureRoadmap(FeatureManifest manifest, GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate UI Feature Flag Roadmap')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## 规划规则')
..writeln()
..writeln(
'- `release_policy` 决定 build mode 的总开关上限:`debug` 可见 '
'`stable / beta / experimental``profile` 可见 `stable / beta`'
'`release` 仅可见 `stable`。',
)
..writeln('- 单个 flag 的交付状态由三层共同决定:`enabled`、`release_tier`、`build_modes`。')
..writeln(
'- `enabled: false` 或 `build_modes: []` 的项,会在文档里继续保留,'
'但不会进入当前 build mode 的用户可见范围。',
)
..writeln()
..writeln('## Build Visibility Summary')
..writeln()
..writeln(
'| 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed |',
)
..writeln('| --- | --- | --- | --- | --- |');
for (final platform in _platformOrder) {
final debugVisible = manifest.visibleFlags(platform, 'debug').length;
final profileVisible = manifest.visibleFlags(platform, 'profile').length;
final releaseVisible = manifest.visibleFlags(platform, 'release').length;
final suppressed = manifest
.recordsFor(platform)
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
.length;
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | $debugVisible | $profileVisible | '
'$releaseVisible | $suppressed |',
);
}
buffer
..writeln()
..writeln('## Release Baseline')
..writeln()
..writeln('| 平台 | 数量 | Flag 列表 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final releaseFlags = manifest.visibleFlags(platform, 'release');
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${releaseFlags.length} | '
'${_flagList(releaseFlags)} |',
);
}
buffer
..writeln()
..writeln('## Profile-only Lane')
..writeln()
..writeln('| 平台 | 数量 | 相比 Release 新增 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final profileOnly = _difference(
manifest.visibleFlags(platform, 'profile'),
manifest.visibleFlags(platform, 'release'),
);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${profileOnly.length} | '
'${_flagList(profileOnly)} |',
);
}
buffer
..writeln()
..writeln('## Debug-only Experimental Lane')
..writeln()
..writeln('| 平台 | 数量 | 相比 Profile 新增 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final debugOnly = _difference(
manifest.visibleFlags(platform, 'debug'),
manifest.visibleFlags(platform, 'profile'),
);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${debugOnly.length} | '
'${_flagList(debugOnly)} |',
);
}
buffer
..writeln()
..writeln('## Explicitly Suppressed')
..writeln()
..writeln('| 平台 | 数量 | Flag 列表 |')
..writeln('| --- | --- | --- |');
for (final platform in _platformOrder) {
final suppressed = manifest
.recordsFor(platform)
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
.toList(growable: false);
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | ${suppressed.length} | '
'${_flagList(suppressed)} |',
);
}
buffer
..writeln()
..writeln('## Tier Inventory')
..writeln();
for (final platform in _platformOrder) {
buffer.writeln('### ${_titleCase(platform)}');
buffer.writeln();
for (final tier in [..._tierOrder, 'disabled']) {
final records = manifest
.recordsFor(platform)
.where((record) {
if (!record.enabled) {
return tier == 'disabled';
}
return record.releaseTier == tier;
})
.toList(growable: false);
if (records.isEmpty) {
continue;
}
buffer.writeln('- `$tier`: ${_flagList(records)}');
}
buffer.writeln();
}
return buffer.toString();
}
String _renderReleaseNotes(FeatureManifest manifest, GitSnapshot git) {
final profileOnlyAll = <FeatureFlagRecord>[];
final debugOnlyAll = <FeatureFlagRecord>[];
for (final platform in _platformOrder) {
profileOnlyAll.addAll(
_difference(
manifest.visibleFlags(platform, 'profile'),
manifest.visibleFlags(platform, 'release'),
),
);
debugOnlyAll.addAll(
_difference(
manifest.visibleFlags(platform, 'debug'),
manifest.visibleFlags(platform, 'profile'),
),
);
}
final categorized = _categorizeCommits(git.commits);
final buffer = StringBuffer()
..writeln('# XWorkmate Release Notes')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Git Snapshot')
..writeln()
..writeln('| 字段 | 值 |')
..writeln('| --- | --- |')
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
..writeln(
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
)
..writeln('| Commit Count | ${git.commits.length} |')
..writeln()
..writeln('## Feature Snapshot')
..writeln()
..writeln('| 平台 | Debug | Profile | Release | Suppressed |')
..writeln('| --- | --- | --- | --- | --- |');
for (final platform in _platformOrder) {
buffer.writeln(
'| `${_escapeMarkdown(platform)}` | '
'${manifest.visibleFlags(platform, 'debug').length} | '
'${manifest.visibleFlags(platform, 'profile').length} | '
'${manifest.visibleFlags(platform, 'release').length} | '
'${manifest.recordsFor(platform).where((record) => !record.visibleIn('debug', manifest.releasePolicy)).length} |',
);
}
buffer
..writeln()
..writeln('## Current Focus')
..writeln()
..writeln(
'- `release` 当前面向用户暴露 ${manifest.visibleFlagCount('release')} 个 UI feature flags'
'全部来自 `stable` tier。',
)
..writeln(
'- `profile` 相比 `release` 额外开放 ${profileOnlyAll.length} 个预发布条目:'
' ${_flagList(profileOnlyAll, includePlatform: true)}',
)
..writeln(
'- `debug` 相比 `profile` 额外开放 ${debugOnlyAll.length} 个实验条目:'
' ${_flagList(debugOnlyAll, includePlatform: true)}',
)
..writeln()
..writeln('## Commit Highlights')
..writeln();
if (git.commits.isEmpty) {
buffer.writeln('当前比较范围没有可渲染的 commits。');
return buffer.toString();
}
for (final entry in categorized.entries) {
if (entry.value.isEmpty) {
continue;
}
buffer.writeln('### ${entry.key}');
buffer.writeln();
for (final commit in entry.value) {
buffer.writeln(
'- `${_escapeMarkdown(commit.hash)}` ${_escapeMarkdown(commit.subject)}',
);
}
buffer.writeln();
}
return buffer.toString();
}
String _renderChangelog(GitSnapshot git) {
final buffer = StringBuffer()
..writeln('# XWorkmate Changelog')
..writeln()
..writeln(_generatedPreamble(git))
..writeln()
..writeln('## Git Snapshot')
..writeln()
..writeln('| 字段 | 值 |')
..writeln('| --- | --- |')
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
..writeln(
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
)
..writeln()
..writeln('## Recent Releases')
..writeln()
..writeln('| Version | Date | Branch | Tag |')
..writeln('| --- | --- | --- | --- |');
for (final release in git.recentReleases) {
buffer.writeln(
'| `${_escapeMarkdown(release.version)}` | '
'`${_escapeMarkdown(release.date)}` | '
'`${_escapeMarkdown(release.branch)}` | '
'`${_escapeMarkdown(release.tag)}` |',
);
}
buffer
..writeln()
..writeln('## Commits')
..writeln()
..writeln('| Hash | Date | Author | Subject |')
..writeln('| --- | --- | --- | --- |');
if (git.commits.isEmpty) {
buffer.writeln(
'| `-` | `-` | `-` | No commits found for the selected range |',
);
return buffer.toString();
}
for (final commit in git.commits) {
buffer.writeln(
'| `${_escapeMarkdown(commit.hash)}` | '
'`${_escapeMarkdown(commit.date)}` | '
'${_escapeMarkdown(commit.author)} | '
'${_escapeMarkdown(commit.subject)} |',
);
}
return buffer.toString();
}
String _generatedPreamble(GitSnapshot git) {
return [
'> Generated by `tool/render_release_docs.dart`',
'> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)',
'> Generated at: `${_escapeMarkdown(git.generatedAt)}`',
].join('\n');
}
String _flagList(
List<FeatureFlagRecord> records, {
bool includePlatform = false,
}) {
if (records.isEmpty) {
return '-';
}
return records
.map(
(record) =>
'`${_escapeMarkdown(includePlatform ? record.qualifiedId : record.id)}`',
)
.join(', ');
}
List<FeatureFlagRecord> _difference(
List<FeatureFlagRecord> left,
List<FeatureFlagRecord> right,
) {
final rightIds = right.map((record) => record.id).toSet();
return left
.where((record) => !rightIds.contains(record.id))
.toList(growable: false);
}
Map<String, List<GitCommit>> _categorizeCommits(List<GitCommit> commits) {
final ordered = <String, List<GitCommit>>{
'Features': <GitCommit>[],
'Fixes': <GitCommit>[],
'Build / Release': <GitCommit>[],
'Docs': <GitCommit>[],
'Tests': <GitCommit>[],
'Refactors': <GitCommit>[],
'Merges': <GitCommit>[],
'Other': <GitCommit>[],
};
for (final commit in commits) {
final subject = commit.subject.toLowerCase();
final bucket = switch (true) {
_ when subject.startsWith('merge ') => 'Merges',
_
when subject.startsWith('feat') ||
subject.startsWith('add ') ||
subject.startsWith('implement ') =>
'Features',
_ when subject.startsWith('fix') || subject.contains(' bug') => 'Fixes',
_ when subject.startsWith('docs') || subject.startsWith('readme') =>
'Docs',
_ when subject.startsWith('test') => 'Tests',
_ when subject.startsWith('refactor') => 'Refactors',
_
when subject.startsWith('build') ||
subject.startsWith('release') ||
subject.startsWith('ci') ||
subject.startsWith('package') ||
subject.contains('workflow') =>
'Build / Release',
_ => 'Other',
};
ordered[bucket]!.add(commit);
}
return ordered;
}
String _inlineValue(String value) {
final normalized = value.trim().isEmpty ? '-' : value.trim();
return '`${_escapeMarkdown(normalized)}`';
}
String _escapeMarkdown(String value) {
return value.replaceAll('|', r'\|');
}
String _titleCase(String value) {
if (value.isEmpty) {
return value;
}
return '${value[0].toUpperCase()}${value.substring(1)}';
}
class FeatureManifest {
FeatureManifest({required this.releasePolicy, required this.records});
factory FeatureManifest.load() {
final yaml = loadYaml(File('config/feature_flags.yaml').readAsStringSync());
final root = yaml as YamlMap;
final releasePolicyRoot = root['release_policy'] as YamlMap? ?? YamlMap();
final releasePolicy = <String, List<String>>{
for (final buildMode in _buildModeOrder)
buildMode: (releasePolicyRoot[buildMode] as YamlList? ?? YamlList())
.map((value) => value.toString())
.toList(growable: false),
};
final records = <FeatureFlagRecord>[];
for (final platform in _platformOrder) {
final platformRoot = root[platform] as YamlMap?;
if (platformRoot == null) {
continue;
}
for (final moduleEntry in platformRoot.entries) {
final module = moduleEntry.key.toString();
final featureRoot = moduleEntry.value as YamlMap;
for (final featureEntry in featureRoot.entries) {
final name = featureEntry.key.toString();
final raw = featureEntry.value as YamlMap;
records.add(
FeatureFlagRecord(
platform: platform,
module: module,
name: name,
enabled: raw['enabled'] == true,
releaseTier: raw['release_tier'].toString(),
buildModes: (raw['build_modes'] as YamlList? ?? YamlList())
.map((value) => value.toString())
.toList(growable: false),
description: raw['description'].toString(),
uiSurface: raw['ui_surface'].toString(),
),
);
}
}
}
return FeatureManifest(releasePolicy: releasePolicy, records: records);
}
final Map<String, List<String>> releasePolicy;
final List<FeatureFlagRecord> records;
List<FeatureFlagRecord> recordsFor(String platform) {
return records
.where((record) => record.platform == platform)
.toList(growable: false);
}
List<FeatureFlagRecord> visibleFlags(String platform, String buildMode) {
return recordsFor(platform)
.where((record) => record.visibleIn(buildMode, releasePolicy))
.toList(growable: false);
}
int visibleFlagCount(String buildMode) {
return _platformOrder.fold<int>(
0,
(total, platform) => total + visibleFlags(platform, buildMode).length,
);
}
}
class FeatureFlagRecord {
const FeatureFlagRecord({
required this.platform,
required this.module,
required this.name,
required this.enabled,
required this.releaseTier,
required this.buildModes,
required this.description,
required this.uiSurface,
});
final String platform;
final String module;
final String name;
final bool enabled;
final String releaseTier;
final List<String> buildModes;
final String description;
final String uiSurface;
String get id => '$module.$name';
String get qualifiedId => '$platform.$module.$name';
bool visibleIn(String buildMode, Map<String, List<String>> releasePolicy) {
final allowedTiers = releasePolicy[buildMode] ?? const <String>[];
return enabled &&
buildModes.contains(buildMode) &&
allowedTiers.contains(releaseTier);
}
}
class GitSnapshot {
GitSnapshot({
required this.branch,
required this.headShort,
required this.headLong,
required this.headTags,
required this.latestTag,
required this.previousTag,
required this.comparisonRangeLabel,
required this.generatedAt,
required this.commits,
required this.recentTags,
required this.recentReleases,
});
factory GitSnapshot.capture() {
final branch =
_git(['branch', '--show-current'], allowFailure: true).trim().isEmpty
? 'detached-head'
: _git(['branch', '--show-current']);
final headShort = _git(['rev-parse', '--short', 'HEAD']);
final headLong = _git(['rev-parse', 'HEAD']);
final headTags = _gitLines(['tag', '--points-at', 'HEAD']);
final allTags = _gitTagRefs();
final recentTags = allTags.take(5).toList(growable: false);
final recentReleases = _gitReleaseRefs(
allTags,
).take(8).toList(growable: false);
final latestTag = recentTags.isEmpty ? null : recentTags.first.name;
String? previousTag;
String comparisonRangeLabel;
String? comparisonRange;
if (headTags.isNotEmpty) {
previousTag = recentTags
.map((tag) => tag.name)
.firstWhere((tag) => !headTags.contains(tag), orElse: () => '')
.trim();
if (previousTag.isEmpty) {
previousTag = null;
}
final activeTag = headTags.first;
comparisonRange = previousTag == null ? null : '$previousTag..$activeTag';
comparisonRangeLabel = comparisonRange ?? activeTag;
} else if (latestTag != null) {
previousTag = recentTags.length > 1 ? recentTags[1].name : null;
comparisonRange = '$latestTag..HEAD';
comparisonRangeLabel = comparisonRange;
} else {
comparisonRange = null;
comparisonRangeLabel = 'HEAD (latest 20 commits)';
}
final commits = _gitCommitLog(comparisonRange);
return GitSnapshot(
branch: branch,
headShort: headShort,
headLong: headLong,
headTags: headTags,
latestTag: latestTag,
previousTag: previousTag,
comparisonRangeLabel: comparisonRangeLabel,
generatedAt: DateTime.now().toIso8601String(),
commits: commits,
recentTags: recentTags,
recentReleases: recentReleases,
);
}
final String branch;
final String headShort;
final String headLong;
final List<String> headTags;
final String? latestTag;
final String? previousTag;
final String comparisonRangeLabel;
final String generatedAt;
final List<GitCommit> commits;
final List<GitTagRef> recentTags;
final List<GitReleaseRef> recentReleases;
}
class GitCommit {
const GitCommit({
required this.hash,
required this.date,
required this.author,
required this.subject,
});
final String hash;
final String date;
final String author;
final String subject;
}
class GitTagRef {
const GitTagRef({required this.name, required this.date});
final String name;
final String date;
}
class GitReleaseRef {
const GitReleaseRef({
required this.version,
required this.date,
required this.branch,
required this.tag,
});
final String version;
final String date;
final String branch;
final String tag;
}
List<GitCommit> _gitCommitLog(String? comparisonRange) {
final args = <String>[
'log',
'--date=short',
'--pretty=format:%h%x09%ad%x09%an%x09%s',
];
if (comparisonRange == null) {
args.addAll(<String>['-n', '20']);
} else {
args.add(comparisonRange);
}
final lines = _gitLines(args, allowFailure: true);
return lines
.map((line) => line.split('\t'))
.where((parts) => parts.length >= 4)
.map(
(parts) => GitCommit(
hash: parts[0],
date: parts[1],
author: parts[2],
subject: parts.sublist(3).join('\t'),
),
)
.toList(growable: false);
}
List<GitTagRef> _gitTagRefs() {
final lines = _gitLines(<String>[
'for-each-ref',
'--sort=-creatordate',
'--format=%(refname:short)%09%(creatordate:short)',
'refs/tags',
], allowFailure: true);
return lines
.map((line) => line.split('\t'))
.where((parts) => parts.length >= 2)
.map((parts) => GitTagRef(name: parts[0], date: parts[1]))
.toList(growable: false);
}
List<GitReleaseRef> _gitReleaseRefs(List<GitTagRef> tags) {
final releases = <String, GitReleaseRef>{};
for (final tag in tags) {
final version = _normalizeReleaseVersion(tag.name);
if (version == null) {
continue;
}
releases[version] = GitReleaseRef(
version: version,
date: tag.date,
branch: _releaseBranchName(version),
tag: tag.name,
);
}
final branchLines = _gitLines(<String>[
'for-each-ref',
'--sort=-committerdate',
'--format=%(refname:short)%09%(committerdate:short)',
'refs/heads/release',
], allowFailure: true);
for (final line in branchLines) {
final parts = line.split('\t');
if (parts.length < 2) {
continue;
}
final branch = parts[0];
final date = parts[1];
final version = _normalizeReleaseVersion(branch);
if (version == null) {
continue;
}
releases[version] = GitReleaseRef(
version: version,
date: releases[version]?.date ?? date,
branch: branch,
tag: releases[version]?.tag ?? '-',
);
}
final values = releases.values.toList(growable: false);
values.sort(
(left, right) => _compareReleaseVersions(right.version, left.version),
);
return values;
}
String? _normalizeReleaseVersion(String refName) {
final match = RegExp(r'^(?:release/)?(v\d+(?:\.\d+)*)$').firstMatch(refName);
return match?.group(1);
}
String _releaseBranchName(String version) => 'release/$version';
int _compareReleaseVersions(String left, String right) {
final leftParts = _releaseVersionParts(left);
final rightParts = _releaseVersionParts(right);
final maxLength = leftParts.length > rightParts.length
? leftParts.length
: rightParts.length;
for (var index = 0; index < maxLength; index += 1) {
final leftPart = index < leftParts.length ? leftParts[index] : 0;
final rightPart = index < rightParts.length ? rightParts[index] : 0;
if (leftPart != rightPart) {
return leftPart.compareTo(rightPart);
}
}
return 0;
}
List<int> _releaseVersionParts(String version) {
return version
.replaceFirst('v', '')
.split('.')
.map(int.parse)
.toList(growable: false);
}
String _git(List<String> args, {bool allowFailure = false}) {
final result = Process.runSync('git', args);
if (result.exitCode != 0) {
if (allowFailure) {
return '';
}
throw ProcessException(
'git',
args,
(result.stderr as String).trim(),
result.exitCode,
);
}
return (result.stdout as String).trim();
}
List<String> _gitLines(List<String> args, {bool allowFailure = false}) {
final output = _git(args, allowFailure: allowFailure);
if (output.trim().isEmpty) {
return const <String>[];
}
return output
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList(growable: false);
}