Condense firehose package publishing table in PR validation (#445)
Condense the package publishing table posted by firehose during PR validation to reduce noise in monorepos.
* Only include packages affected by the PR, packages ready to publish, and packages with validation errors in the markdown table.
* Summarize unaffected WIP and already published packages below the table.
* Add unit and integration tests covering table filtering and summary generation.
diff --git a/pkgs/firehose/CHANGELOG.md b/pkgs/firehose/CHANGELOG.md
index 7faab47..3b0e0f9 100644
--- a/pkgs/firehose/CHANGELOG.md
+++ b/pkgs/firehose/CHANGELOG.md
@@ -1,5 +1,8 @@
## 0.13.2-wip
+- Condense PR package publishing table to show only packages affected by the PR,
+ packages ready to publish, and errors, summarizing unaffected WIP and published
+ packages.
- Remove 3rd-party Coveralls action and `upload_coverage` input from health workflows.
- Add `--tag-prefix` option to `firehose` and `tag-prefix` input to `publish.yaml` to allow configuring the release tag prefix (defaults to `'v'`).
- Run `pub get` before `dart format` in `groundskeeper`.
diff --git a/pkgs/firehose/lib/firehose.dart b/pkgs/firehose/lib/firehose.dart
index cd74f6f..965f8e3 100644
--- a/pkgs/firehose/lib/firehose.dart
+++ b/pkgs/firehose/lib/firehose.dart
@@ -110,11 +110,20 @@
final repo = Repository(directory);
final packages = repo.locatePackages(ignore: ignoredPackages);
+ final filesInPR = await allowFailure(
+ github.listFilesForPR(directory, ignoredPackages),
+ logError: print,
+ );
+ final relevantFiles = filesInPR?.where((f) => f.status.isRelevant).toList();
+
final pub = Pub();
final results = VerificationResults();
for (final package in packages) {
+ final isAffected = relevantFiles == null ||
+ relevantFiles.any((f) => f.isInPackage(package));
+
final repoTag = repo.calculateRepoTag(package, tagPrefix: tagPrefix);
print('');
@@ -126,6 +135,7 @@
final result = Result.fail(
package,
"no version specified (perhaps you need a' publish_to: none' entry?)",
+ isAffected: isAffected,
);
print(result);
results.addResult(result);
@@ -142,6 +152,7 @@
package,
'pubspec version ($pubspecVersion) and changelog ($changelogVersion) '
"don't agree",
+ isAffected: isAffected,
);
print(result);
results.addResult(result);
@@ -149,11 +160,19 @@
}
if (await pub.hasPublishedVersion(package.name, pubspecVersion)) {
- final result = Result.info(package, 'already published at pub.dev');
+ final result = Result.info(
+ package,
+ Result.alreadyPublishedMessage,
+ isAffected: isAffected,
+ );
print(result);
results.addResult(result);
} else if (package.pubspec.version!.wip) {
- final result = Result.info(package, 'WIP (no publish necessary)');
+ final result = Result.info(
+ package,
+ Result.wipMessage,
+ isAffected: isAffected,
+ );
print(result);
results.addResult(result);
} else {
@@ -172,17 +191,20 @@
'pub publish dry-run failed; add the `$_ignoreWarningsLabel` '
'label to ignore';
github.notice(message: message);
- results.addResult(Result.fail(package, message));
+ results.addResult(
+ Result.fail(package, message, isAffected: isAffected),
+ );
} else {
final result = Result.success(
package,
'**ready to publish**',
- repoTag,
- repo.calculateReleaseUri(
+ gitTag: repoTag,
+ publishReleaseUri: repo.calculateReleaseUri(
package,
github,
tagPrefix: tagPrefix,
),
+ isAffected: isAffected,
);
print(result);
results.addResult(result);
@@ -323,29 +345,69 @@
bool get hasError => results.any((r) => r.severity == Severity.error);
- String describeAsMarkdown({bool withTag = true}) => results.map((r) {
- final sev = r.severity == Severity.error ? '(error) ' : '';
- var tagColumn = '';
- if (withTag) {
- var tag = r.gitTag == null ? '' : '`${r.gitTag}`';
- final publishReleaseUri = r.publishReleaseUri;
- if (publishReleaseUri != null) {
- tag = '[$tag]($publishReleaseUri)';
- }
+ Iterable<Result> get visibleResults =>
+ results.where((r) => r.isVisibleInTable);
- tagColumn = ' | $tag';
+ Iterable<Result> get hiddenResults =>
+ results.where((r) => !r.isVisibleInTable);
+
+ String describeAsMarkdown({bool withTag = true}) {
+ final buffer = StringBuffer();
+ for (final r in visibleResults) {
+ final sev = r.severity == Severity.error ? '(error) ' : '';
+ var tagColumn = '';
+ if (withTag) {
+ var tag = r.gitTag == null ? '' : '`${r.gitTag}`';
+ final publishReleaseUri = r.publishReleaseUri;
+ if (publishReleaseUri != null) {
+ tag = '[$tag]($publishReleaseUri)';
}
- return '| package:${r.package.name} | ${r.package.version} | '
- '$sev${r.message}$tagColumn |';
- }).join('\n');
+
+ tagColumn = ' | $tag';
+ }
+ final pkgName = r.isAffected
+ ? '**package:${r.package.name}** ⭐'
+ : 'package:${r.package.name}';
+ buffer.writeln(
+ '| $pkgName | ${r.package.version} | '
+ '$sev${r.message}$tagColumn |',
+ );
+ }
+
+ final hidden = hiddenResults.toList();
+ final alreadyPublishedCount =
+ hidden.where((r) => r.message == Result.alreadyPublishedMessage).length;
+ final wipCount = hidden.where((r) => r.message == Result.wipMessage).length;
+
+ final summaryLines = <String>[];
+ if (alreadyPublishedCount > 0) {
+ summaryLines.add('* $alreadyPublishedCount already published.');
+ }
+ if (wipCount > 0) {
+ summaryLines.add('* $wipCount WIP (no publish necessary).');
+ }
+
+ if (summaryLines.isNotEmpty) {
+ if (visibleResults.isNotEmpty) {
+ buffer.writeln();
+ }
+ buffer.write(summaryLines.join('\n'));
+ }
+
+ return buffer.toString().trimRight();
+ }
}
class Result {
+ static const String alreadyPublishedMessage = 'already published at pub.dev';
+ static const String wipMessage = 'WIP (no publish necessary)';
+
final Severity severity;
final Package package;
final String message;
final String? gitTag;
final Uri? publishReleaseUri;
+ final bool isAffected;
Result(
this.severity,
@@ -353,21 +415,35 @@
this.message, [
this.gitTag,
this.publishReleaseUri,
+ this.isAffected = false,
]);
- factory Result.fail(Package package, String message) =>
- Result(Severity.error, package, message);
+ factory Result.fail(
+ Package package,
+ String message, {
+ bool isAffected = false,
+ }) =>
+ Result(Severity.error, package, message, null, null, isAffected);
- factory Result.info(Package package, String message) =>
- Result(Severity.info, package, message);
+ factory Result.info(
+ Package package,
+ String message, {
+ bool isAffected = false,
+ }) =>
+ Result(Severity.info, package, message, null, null, isAffected);
factory Result.success(
Package package,
- String message, [
+ String message, {
String? gitTag,
Uri? publishReleaseUri,
- ]) =>
- Result(Severity.success, package, message, gitTag, publishReleaseUri);
+ bool isAffected = false,
+ }) =>
+ Result(Severity.success, package, message, gitTag, publishReleaseUri,
+ isAffected);
+
+ bool get isVisibleInTable =>
+ isAffected || severity == Severity.success || severity == Severity.error;
@override
String toString() {
diff --git a/pkgs/firehose/test/firehose_test.dart b/pkgs/firehose/test/firehose_test.dart
new file mode 100644
index 0000000..0f553c6
--- /dev/null
+++ b/pkgs/firehose/test/firehose_test.dart
@@ -0,0 +1,143 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn('vm')
+library;
+
+import 'dart:io';
+
+import 'package:firehose/firehose.dart';
+import 'package:firehose/src/local_github_api.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('VerificationResults', () {
+ late Repository repo;
+ late Package pkgA;
+ late Package pkgB;
+ late Package pkgC;
+ late Package pkgD;
+ late Package pkgE;
+
+ setUp(() {
+ repo = Repository();
+ pkgA = Package(Directory('test_data/workspace_repo/pkg_1'), repo);
+ pkgB = Package(Directory('test_data/workspace_repo/pkg_2'), repo);
+ pkgC = Package(Directory('test_data/test_repo/pkgs/package1'), repo);
+ pkgD = Package(Directory('test_data/test_repo/pkgs/package2'), repo);
+ pkgE = Package(Directory('test_data/test_repo/pkgs/package3'), repo);
+ });
+
+ test('all packages affected are visible in table and highlighted', () {
+ final results = VerificationResults()
+ ..addResult(Result.info(pkgA, Result.wipMessage, isAffected: true))
+ ..addResult(Result.info(pkgB, Result.alreadyPublishedMessage,
+ isAffected: true));
+
+ expect(results.visibleResults.length, 2);
+ expect(results.hiddenResults, isEmpty);
+
+ final markdown = results.describeAsMarkdown(withTag: false);
+ expect(markdown, contains('| **package:pkg_1** ⭐ |'));
+ expect(markdown, contains('| **package:pkg_2** ⭐ |'));
+ expect(markdown, isNot(contains('already published.')));
+ expect(markdown, isNot(contains('WIP (no publish necessary).')));
+ });
+
+ test('unaffected ready-to-publish and error packages are not starred', () {
+ final results = VerificationResults()
+ ..addResult(
+ Result.success(pkgA, '**ready to publish**', isAffected: false))
+ ..addResult(Result.fail(pkgB, 'version mismatch', isAffected: false));
+
+ expect(results.visibleResults.length, 2);
+ expect(results.hiddenResults, isEmpty);
+
+ final markdown = results.describeAsMarkdown(withTag: false);
+ expect(markdown, contains('| package:pkg_1 |'));
+ expect(markdown, contains('| package:pkg_2 |'));
+ expect(markdown, isNot(contains('⭐')));
+ });
+
+ test('unaffected WIP and already-published packages are summarized', () {
+ final results = VerificationResults()
+ ..addResult(
+ Result.success(pkgA, '**ready to publish**', isAffected: false))
+ ..addResult(Result.info(pkgB, Result.wipMessage, isAffected: true))
+ ..addResult(Result.info(pkgC, Result.wipMessage, isAffected: false))
+ ..addResult(Result.info(pkgD, Result.wipMessage, isAffected: false))
+ ..addResult(Result.info(pkgE, Result.alreadyPublishedMessage,
+ isAffected: false));
+
+ expect(results.visibleResults.length, 2);
+ expect(results.hiddenResults.length, 3);
+
+ final markdown = results.describeAsMarkdown(withTag: false);
+ expect(markdown, contains('| package:pkg_1 |'));
+ expect(markdown, contains('| **package:pkg_2** ⭐ |'));
+ expect(markdown, isNot(contains('| package:package1 |')));
+ expect(markdown, isNot(contains('| package:package2 |')));
+ expect(markdown, isNot(contains('| package:package3 |')));
+
+ expect(markdown, contains('* 1 already published.'));
+ expect(markdown, contains('* 2 WIP (no publish necessary).'));
+ });
+
+ test('summary omitted when counts are zero', () {
+ final results = VerificationResults()
+ ..addResult(
+ Result.success(pkgA, '**ready to publish**', isAffected: true));
+
+ final markdown = results.describeAsMarkdown(withTag: false);
+ expect(markdown, contains('| **package:pkg_1** ⭐ |'));
+ expect(markdown, isNot(contains('already published.')));
+ expect(markdown, isNot(contains('WIP (no publish necessary).')));
+ });
+
+ test('only summary when no packages visible in table', () {
+ final results = VerificationResults()
+ ..addResult(Result.info(pkgA, Result.alreadyPublishedMessage,
+ isAffected: false))
+ ..addResult(Result.info(pkgB, Result.wipMessage, isAffected: false));
+
+ expect(results.visibleResults, isEmpty);
+ expect(results.hiddenResults.length, 2);
+
+ final markdown = results.describeAsMarkdown(withTag: false);
+ expect(
+ markdown,
+ equals('* 1 already published.\n* 1 WIP (no publish necessary).'),
+ );
+ });
+ });
+
+ group('Firehose.verify with LocalGithubApi', () {
+ test('filters packages based on PR file changes', () async {
+ final testDir = Directory('test_data/test_repo');
+ final firehose = Firehose(testDir, false, []);
+ final github = LocalGithubApi(
+ prLabels: [],
+ files: [
+ GitFile(
+ 'pkgs/package1/lib/package1.dart',
+ FileStatus.modified,
+ testDir,
+ ),
+ ],
+ );
+
+ final results = await firehose.verify(github);
+ final affectedResults =
+ results.results.where((r) => r.isAffected).toList();
+ final unaffectedResults =
+ results.results.where((r) => !r.isAffected).toList();
+
+ expect(affectedResults.map((r) => r.package.name), ['package1']);
+ expect(
+ unaffectedResults.map((r) => r.package.name),
+ containsAll(['package2', 'package3', 'package4', 'package5']),
+ );
+ });
+ });
+}