Skip to content

Commit ae3ec55

Browse files
committed
Send changelog excerpt in package uploaded email.
1 parent 55ead99 commit ae3ec55

File tree

5 files changed

+328
-10
lines changed

5 files changed

+328
-10
lines changed

app/lib/package/backend.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:_pub_shared/data/account_api.dart' as account_api;
99
import 'package:_pub_shared/data/package_api.dart' as api;
1010
import 'package:_pub_shared/utils/sdk_version_cache.dart';
1111
import 'package:clock/clock.dart';
12+
import 'package:collection/collection.dart';
1213
import 'package:convert/convert.dart';
1314
import 'package:crypto/crypto.dart';
1415
import 'package:gcloud/service_scope.dart' as ss;
@@ -21,6 +22,8 @@ import 'package:pub_dev/package/tarball_storage.dart';
2122
import 'package:pub_dev/scorecard/backend.dart';
2223
import 'package:pub_dev/service/async_queue/async_queue.dart';
2324
import 'package:pub_dev/service/rate_limit/rate_limit.dart';
25+
import 'package:pub_dev/shared/changelog.dart';
26+
import 'package:pub_dev/shared/monitoring.dart';
2427
import 'package:pub_dev/shared/versions.dart';
2528
import 'package:pub_dev/task/backend.dart';
2629
import 'package:pub_package_reader/pub_package_reader.dart';
@@ -1168,6 +1171,15 @@ class PackageBackend {
11681171
.run()
11691172
.toList();
11701173

1174+
final changelogExcerpt = _createChangelogExcerpt(
1175+
versionKey: newVersion.qualifiedVersionKey,
1176+
changelogContent: entities.changelogAsset?.textContent,
1177+
);
1178+
if (changelogExcerpt != null) {
1179+
uploadMessages
1180+
.add('Excerpt of the changelog:\n```\n$changelogExcerpt\n```');
1181+
}
1182+
11711183
// Add the new package to the repository by storing the tarball and
11721184
// inserting metadata to datastore (which happens atomically).
11731185
final (pv, outgoingEmail) = await withRetryTransaction(db, (tx) async {
@@ -1315,6 +1327,42 @@ class PackageBackend {
13151327
return (pv, uploadMessages);
13161328
}
13171329

1330+
String? _createChangelogExcerpt({
1331+
required QualifiedVersionKey versionKey,
1332+
required String? changelogContent,
1333+
}) {
1334+
if (changelogContent == null) {
1335+
return null;
1336+
}
1337+
try {
1338+
final parsed = ChangelogParser().parseMarkdownText(changelogContent);
1339+
final version = parsed.releases
1340+
.firstWhereOrNull((r) => r.version == versionKey.version);
1341+
if (version == null) {
1342+
return null;
1343+
}
1344+
final text = version.content.asMarkdownText;
1345+
1346+
/// Limit the changelog to 10 lines, 75 characters each:
1347+
final lines = text.split('\n');
1348+
final excerpt = lines
1349+
// filter empty or decorative lines to maximalize usefulness
1350+
.where((line) =>
1351+
line.isNotEmpty &&
1352+
!line.startsWith('```') && // also removes the need to escape it
1353+
!line.startsWith('---'))
1354+
.take(10)
1355+
.map((line) =>
1356+
line.length < 76 ? line : '${line.substring(0, 70)}[...]')
1357+
.join('\n');
1358+
return excerpt;
1359+
} catch (e, st) {
1360+
_logger.pubNoticeShout('changelog-parse-error',
1361+
'Unable to parse changelog for $versionKey', e, st);
1362+
return null;
1363+
}
1364+
}
1365+
13181366
/// The post-upload tasks are not critical and could fail without any impact on
13191367
/// the uploaded package version. Important operations (e.g. email sending) are
13201368
/// retried periodically, others (e.g. triggering re-analysis of dependent
@@ -1903,6 +1951,9 @@ class _UploadEntities {
19031951
this.packageVersionInfo,
19041952
this.assets,
19051953
);
1954+
1955+
late final changelogAsset =
1956+
assets.firstWhereOrNull((e) => e.kind == AssetKind.changelog);
19061957
}
19071958

19081959
class DerivedPackageVersionEntities {

app/lib/shared/changelog.dart

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ library;
1919

2020
import 'package:collection/collection.dart';
2121
import 'package:html/dom.dart' as html;
22+
import 'package:html/dom_parsing.dart' as dom_parsing;
2223
import 'package:html/parser.dart' as html_parser;
24+
import 'package:markdown/markdown.dart' as m;
2325
import 'package:pub_semver/pub_semver.dart';
2426

2527
/// Represents the entire changelog, containing a list of releases.
@@ -101,6 +103,182 @@ class Content {
101103
if (_asNode != null) return _asNode!;
102104
return html_parser.parseFragment(_asText!);
103105
}();
106+
107+
late final asMarkdownText = () {
108+
final visitor = _MarkdownVisitor()..visit(asHtmlNode);
109+
return visitor.toString();
110+
}();
111+
}
112+
113+
class _MarkdownVisitor extends dom_parsing.TreeVisitor {
114+
final _result = StringBuffer();
115+
int _listDepth = 0;
116+
117+
void _write(String text) {
118+
_result.write(text);
119+
}
120+
121+
void _writeln([String? text]) {
122+
if (text != null) {
123+
_write(text);
124+
}
125+
_write('\n');
126+
}
127+
128+
void _visitChildrenInline(html.Element node) {
129+
for (var i = 0; i < node.nodes.length; i++) {
130+
final child = node.nodes[i];
131+
if (i > 0 && (node.nodes[i - 1].text?.endsWithWhitespace() ?? false)) {
132+
_result.write(' ');
133+
}
134+
visit(child);
135+
}
136+
}
137+
138+
@override
139+
void visitElement(html.Element node) {
140+
final localName = node.localName!;
141+
142+
switch (localName) {
143+
case 'h1':
144+
_write('# ');
145+
_visitChildrenInline(node);
146+
_writeln();
147+
_writeln();
148+
break;
149+
case 'h2':
150+
_write('## ');
151+
_visitChildrenInline(node);
152+
_writeln();
153+
_writeln();
154+
break;
155+
case 'h3':
156+
_write('### ');
157+
_visitChildrenInline(node);
158+
_writeln();
159+
_writeln();
160+
break;
161+
case 'h4':
162+
_write('#### ');
163+
_visitChildrenInline(node);
164+
_writeln();
165+
_writeln();
166+
break;
167+
case 'h5':
168+
_write('##### ');
169+
_visitChildrenInline(node);
170+
_writeln();
171+
_writeln();
172+
break;
173+
case 'h6':
174+
_write('###### ');
175+
_visitChildrenInline(node);
176+
_writeln();
177+
_writeln();
178+
break;
179+
case 'p':
180+
_visitChildrenInline(node);
181+
_writeln();
182+
_writeln();
183+
break;
184+
case 'br':
185+
_writeln();
186+
break;
187+
case 'strong':
188+
case 'b':
189+
_write('**');
190+
_visitChildrenInline(node);
191+
_write('**');
192+
break;
193+
case 'em':
194+
case 'i':
195+
_write('*');
196+
_visitChildrenInline(node);
197+
_write('*');
198+
break;
199+
case 'code':
200+
_write('`');
201+
_visitChildrenInline(node);
202+
_write('`');
203+
break;
204+
case 'pre':
205+
_writeln('```');
206+
visitChildren(node);
207+
_writeln('```');
208+
break;
209+
case 'blockquote':
210+
_write('>');
211+
_visitChildrenInline(node);
212+
_writeln();
213+
break;
214+
case 'a':
215+
final href = node.attributes['href'];
216+
if (href != null) {
217+
_write('[');
218+
_visitChildrenInline(node);
219+
_write(']($href)');
220+
} else {
221+
visitChildren(node);
222+
}
223+
break;
224+
case 'ul':
225+
_listDepth++;
226+
visitChildren(node);
227+
_listDepth--;
228+
if (_listDepth == 0) _writeln();
229+
break;
230+
case 'ol':
231+
_listDepth++;
232+
visitChildren(node);
233+
_listDepth--;
234+
if (_listDepth == 0) _writeln();
235+
break;
236+
case 'li':
237+
final parent = node.parent?.localName;
238+
final indent = ' ' * (_listDepth - 1);
239+
_write(indent);
240+
if (parent == 'ol') {
241+
_write('1. ');
242+
} else {
243+
_write('- ');
244+
}
245+
_visitChildrenInline(node);
246+
_writeln();
247+
break;
248+
case 'hr':
249+
_writeln('---');
250+
break;
251+
default:
252+
visitChildren(node);
253+
break;
254+
}
255+
}
256+
257+
@override
258+
void visitText(html.Text node) {
259+
_result.write(node.text.normalizeAndTrim());
260+
// if (_inlineDepth > 0 &&
261+
// (node.parent?.nodes.indexOf(this) ?? -1) !=
262+
// (node.parent?.nodes.length ?? 0) - 1 &&
263+
// node.text.endsWithWhitespace()) {
264+
// _out.write(' ');
265+
// }
266+
}
267+
268+
@override
269+
String toString() => _result.toString().trim();
270+
}
271+
272+
extension on String {
273+
String normalizeAndTrim() {
274+
return replaceAll(RegExp(r'\s+'), ' ').trim();
275+
}
276+
277+
bool endsWithWhitespace() {
278+
if (isEmpty) return false;
279+
final last = this[length - 1];
280+
return last == ' ' || last == '\n';
281+
}
104282
}
105283

106284
/// Parses the changelog with pre-configured options.
@@ -115,7 +293,16 @@ class ChangelogParser {
115293
}) : _strictLevels = strictLevels,
116294
_partOfLevelThreshold = partOfLevelThreshold;
117295

118-
/// Parses markdown nodes into a [Changelog] structure.
296+
/// Parses markdown text into a [Changelog] structure.
297+
Changelog parseMarkdownText(String input) {
298+
final nodes =
299+
m.Document(extensionSet: m.ExtensionSet.gitHubWeb).parse(input);
300+
final rawHtml = m.renderToHtml(nodes);
301+
final root = html_parser.parseFragment(rawHtml);
302+
return parseHtmlNodes(root.nodes);
303+
}
304+
305+
/// Parses HTML nodes into a [Changelog] structure.
119306
Changelog parseHtmlNodes(List<html.Node> input) {
120307
String? title;
121308
Content? description;

app/test/package/backend_test_utils.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ Future<T> withTempDirectory<T>(Future<T> Function(String temp) func) async {
1919
}
2020
}
2121

22-
Future<List<int>> packageArchiveBytes({required String pubspecContent}) async {
22+
Future<List<int>> packageArchiveBytes({
23+
required String pubspecContent,
24+
String? changelogContent,
25+
}) async {
2326
final builder = ArchiveBuilder();
2427
builder.addFile('README.md', foobarReadmeContent);
25-
builder.addFile('CHANGELOG.md', foobarChangelogContent);
28+
builder.addFile('CHANGELOG.md', changelogContent ?? foobarChangelogContent);
2629
builder.addFile('pubspec.yaml', pubspecContent);
2730
builder.addFile('LICENSE', 'BSD LICENSE 2.0');
2831
builder.addFile('lib/test_library.dart', 'hello() => print("hello");');

app/test/package/upload_test.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ void main() {
128128
expect(email.subject, 'Package uploaded: new_package 1.2.3');
129129
expect(email.bodyText,
130130
contains('https://pub.dev/packages/new_package/versions/1.2.3\n'));
131+
// No relevant changelog entry for this version.
132+
expect(email.bodyText, isNot(contains('Excerpt of the changelog')));
131133

132134
final audits = await auditBackend.listRecordsForPackageVersion(
133135
'new_package', '1.2.3');
@@ -1193,7 +1195,9 @@ void main() {
11931195
final p1 = await packageBackend.lookupPackage('oxygen');
11941196
expect(p1!.versionCount, 3);
11951197
final tarball = await packageArchiveBytes(
1196-
pubspecContent: generatePubspecYaml('oxygen', '3.0.0'));
1198+
pubspecContent: generatePubspecYaml('oxygen', '3.0.0'),
1199+
changelogContent:
1200+
'# Changelog\n\n## v3.0.0\n\nSome bug fixes:\n- one,\n- two\n\n');
11971201
final message = await createPubApiClient(authToken: adminClientToken)
11981202
.uploadPackageBytes(tarball);
11991203
expect(message.success.message, contains('Successfully uploaded'));
@@ -1210,6 +1214,15 @@ void main() {
12101214
expect(email.subject, 'Package uploaded: oxygen 3.0.0');
12111215
expect(email.bodyText,
12121216
contains('https://pub.dev/packages/oxygen/versions/3.0.0\n'));
1217+
expect(
1218+
email.bodyText,
1219+
contains('\n'
1220+
'Excerpt of the changelog:\n'
1221+
'```\n'
1222+
'Some bug fixes:\n'
1223+
'- one,\n'
1224+
'- two\n'
1225+
'```\n\n'));
12131226

12141227
await nameTracker.reloadFromDatastore();
12151228
final lastPublished =

0 commit comments

Comments
 (0)