|
| 1 | +#!/usr/bin/env dart |
| 2 | +/// 生成 PR 文档 diff 评论(develop 已提交 vs PR tools 重新生成)。 |
| 3 | +/// |
| 4 | +/// dart run .github/scripts/build_doc_diff_comment.dart \ |
| 5 | +/// --out doc-diff-comment.md \ |
| 6 | +/// --preview-url https://preview-pr-1-xxx.surge.sh \ |
| 7 | +/// --compare /tmp/api-baseline path/to/api "API 文档" "*_api.md" \ |
| 8 | +/// --compare /tmp/site-baseline path/to/src "站点文档" "README.md" |
| 9 | +import 'dart:io'; |
| 10 | + |
| 11 | +const _maxLinesPerFile = 150; |
| 12 | +const _maxBytes = 55 * 1024; |
| 13 | + |
| 14 | +void main(List<String> args) async { |
| 15 | + final config = _Config.parse(args); |
| 16 | + if (config == null) { |
| 17 | + stderr.writeln(_usage); |
| 18 | + exit(1); |
| 19 | + } |
| 20 | + |
| 21 | + final slugs = <String>{}; |
| 22 | + final sections = StringBuffer(); |
| 23 | + var omitted = 0; |
| 24 | + |
| 25 | + for (final pair in config.pairs) { |
| 26 | + final changed = await _diffPair(pair); |
| 27 | + for (final c in changed) { |
| 28 | + final slug = _componentSlug(c.path); |
| 29 | + if (slug != null) slugs.add(slug); |
| 30 | + } |
| 31 | + |
| 32 | + sections.writeln('### ${pair.title}'); |
| 33 | + sections.writeln(); |
| 34 | + if (changed.isEmpty) { |
| 35 | + sections.writeln('_无变更_'); |
| 36 | + sections.writeln(); |
| 37 | + continue; |
| 38 | + } |
| 39 | + |
| 40 | + for (final file in changed) { |
| 41 | + final block = _detailsBlock(file); |
| 42 | + if (sections.length + block.length > _maxBytes) { |
| 43 | + omitted++; |
| 44 | + continue; |
| 45 | + } |
| 46 | + sections.write(block); |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + final out = StringBuffer(''' |
| 51 | +## 文档变更预览 |
| 52 | +
|
| 53 | +> 对比基准:[Tencent/tdesign-flutter](https://github.com/Tencent/tdesign-flutter) @ develop 已提交文档 vs 本 PR tools 重新生成 |
| 54 | +
|
| 55 | +'''); |
| 56 | + |
| 57 | + if (slugs.isNotEmpty && config.previewUrl != null) { |
| 58 | + final url = config.previewUrl!.replaceAll(RegExp(r'/+$'), ''); |
| 59 | + out.writeln('**有变更的组件预览**:'); |
| 60 | + for (final s in slugs.toList()..sort()) { |
| 61 | + out.writeln('- [$s]($url/flutter/components/$s)'); |
| 62 | + } |
| 63 | + out.writeln(); |
| 64 | + } |
| 65 | + |
| 66 | + out.write(sections); |
| 67 | + if (omitted > 0) { |
| 68 | + out.writeln('> 还有 **$omitted** 个文件未展示(GitHub 评论长度限制)。\n'); |
| 69 | + } |
| 70 | + |
| 71 | + File(config.outPath).writeAsStringSync(out.toString()); |
| 72 | + stdout.writeln('Wrote ${config.outPath} (${out.length} bytes)'); |
| 73 | +} |
| 74 | + |
| 75 | +class _Pair { |
| 76 | + _Pair(this.baseline, this.generated, this.title, this.namePattern); |
| 77 | + final String baseline; |
| 78 | + final String generated; |
| 79 | + final String title; |
| 80 | + final String namePattern; |
| 81 | +} |
| 82 | + |
| 83 | +class _Config { |
| 84 | + _Config({required this.outPath, required this.pairs, this.previewUrl}); |
| 85 | + final String outPath; |
| 86 | + final List<_Pair> pairs; |
| 87 | + final String? previewUrl; |
| 88 | + |
| 89 | + static _Config? parse(List<String> args) { |
| 90 | + String? out; |
| 91 | + String? previewUrl; |
| 92 | + final pairs = <_Pair>[]; |
| 93 | + |
| 94 | + for (var i = 0; i < args.length; i++) { |
| 95 | + switch (args[i]) { |
| 96 | + case '--out': |
| 97 | + out = args[++i]; |
| 98 | + case '--preview-url': |
| 99 | + previewUrl = args[++i]; |
| 100 | + case '--compare': |
| 101 | + if (i + 4 >= args.length) return null; |
| 102 | + pairs.add(_Pair(args[++i], args[++i], args[++i], args[++i])); |
| 103 | + } |
| 104 | + } |
| 105 | + if (out == null || pairs.isEmpty) return null; |
| 106 | + return _Config(outPath: out, pairs: pairs, previewUrl: previewUrl); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +const _usage = ''' |
| 111 | +用法: |
| 112 | + dart run .github/scripts/build_doc_diff_comment.dart \\ |
| 113 | + --out doc-diff-comment.md \\ |
| 114 | + [--preview-url <url>] \\ |
| 115 | + --compare <baseline> <generated> <标题> <文件名模式> ... |
| 116 | +'''; |
| 117 | + |
| 118 | +class _Changed { |
| 119 | + _Changed(this.path, this.diff, this.added, this.removed); |
| 120 | + final String path; |
| 121 | + final String diff; |
| 122 | + final int added; |
| 123 | + final int removed; |
| 124 | +} |
| 125 | + |
| 126 | +Future<List<_Changed>> _diffPair(_Pair pair) async { |
| 127 | + final baseDir = Directory(pair.baseline); |
| 128 | + final genDir = Directory(pair.generated); |
| 129 | + if (!baseDir.existsSync() || !genDir.existsSync()) { |
| 130 | + stderr.writeln('ERROR: 目录不存在'); |
| 131 | + exit(1); |
| 132 | + } |
| 133 | + |
| 134 | + final paths = <String>{}; |
| 135 | + for (final root in [pair.baseline, pair.generated]) { |
| 136 | + final prefix = '${Directory(root).absolute.path}${Platform.pathSeparator}'; |
| 137 | + await for (final entity in Directory(root).list(recursive: true)) { |
| 138 | + if (entity is! File) continue; |
| 139 | + final name = entity.path.split(Platform.pathSeparator).last; |
| 140 | + if (!_matchName(name, pair.namePattern)) continue; |
| 141 | + paths.add(entity.path.substring(prefix.length)); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + final result = <_Changed>[]; |
| 146 | + for (final rel in paths.toList()..sort()) { |
| 147 | + final a = File('${pair.baseline}${Platform.pathSeparator}$rel'); |
| 148 | + final b = File('${pair.generated}${Platform.pathSeparator}$rel'); |
| 149 | + if (a.existsSync() && b.existsSync() && await a.readAsString() == await b.readAsString()) { |
| 150 | + continue; |
| 151 | + } |
| 152 | + |
| 153 | + final proc = await Process.run('diff', [ |
| 154 | + '-u', '-U0', |
| 155 | + '--label', 'develop/$rel', |
| 156 | + '--label', 'pr/$rel', |
| 157 | + a.existsSync() ? a.path : '/dev/null', |
| 158 | + b.existsSync() ? b.path : '/dev/null', |
| 159 | + ]); |
| 160 | + final text = '${proc.stdout}${proc.stderr}'.trimRight(); |
| 161 | + var added = 0, removed = 0; |
| 162 | + for (final line in text.split('\n')) { |
| 163 | + if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) continue; |
| 164 | + if (line.startsWith('+')) added++; |
| 165 | + if (line.startsWith('-')) removed++; |
| 166 | + } |
| 167 | + result.add(_Changed(rel, text.isEmpty ? '(无 diff 输出)' : text, added, removed)); |
| 168 | + } |
| 169 | + return result; |
| 170 | +} |
| 171 | + |
| 172 | +bool _matchName(String filename, String pattern) { |
| 173 | + if (pattern.startsWith('*')) return filename.endsWith(pattern.substring(1)); |
| 174 | + return filename == pattern; |
| 175 | +} |
| 176 | + |
| 177 | +String _detailsBlock(_Changed f) { |
| 178 | + var body = f.diff; |
| 179 | + final lines = body.split('\n'); |
| 180 | + if (lines.length > _maxLinesPerFile) { |
| 181 | + body = '${lines.take(_maxLinesPerFile).join('\n')}\n\n... 已截断(共 ${lines.length} 行)'; |
| 182 | + } |
| 183 | + return ''' |
| 184 | +<details> |
| 185 | +<summary>${f.path} (+${f.added} −${f.removed})</summary> |
| 186 | +
|
| 187 | +```diff |
| 188 | +$body |
| 189 | +``` |
| 190 | +
|
| 191 | +</details> |
| 192 | +
|
| 193 | +'''; |
| 194 | +} |
| 195 | + |
| 196 | +String? _componentSlug(String rel) { |
| 197 | + final name = rel.split(Platform.pathSeparator).last; |
| 198 | + if (name.endsWith('_api.md')) { |
| 199 | + return name.substring(0, name.length - '_api.md'.length); |
| 200 | + } |
| 201 | + if (rel.endsWith('${Platform.pathSeparator}README.md')) { |
| 202 | + return rel.substring(0, rel.length - '${Platform.pathSeparator}README.md'.length); |
| 203 | + } |
| 204 | + return null; |
| 205 | +} |
0 commit comments