896 lines
26 KiB
Dart
896 lines
26 KiB
Dart
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/planning/xworkmate-ui-feature-matrix.md',
|
||
_renderFeatureMatrix(manifest, git),
|
||
);
|
||
_writeDoc(
|
||
'docs/planning/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/planning/xworkmate-ui-feature-matrix.md, '
|
||
'docs/planning/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);
|
||
}
|