blob: bb6487a0a33c3ea495c001140f9d1fe7ac2117d6 [file] [log] [blame]
// Copyright (c) 2023, 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.
// ignore_for_file: always_declare_return_types
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:pub_semver/pub_semver.dart';
import '../../firehose.dart';
import '../github.dart';
import '../repo.dart';
import '../utils.dart';
import 'changelog.dart';
import 'coverage.dart';
import 'license.dart';
const String _botSuffix = '[bot]';
const String _githubActionsUser = 'github-actions[bot]';
const String _publishBotTag2 = '### Package publish validation';
const String _licenseBotTag = '### License Headers';
const String _changelogBotTag = '### Changelog Entry';
const String _coverageBotTag = '### Coverage';
const String _breakingBotTag = '### Breaking changes';
const String _prHealthTag = '## PR Health';
class Health {
final Directory directory;
Health(this.directory);
Future<void> healthCheck(List args, bool coverageweb) async {
var github = Github();
// Do basic validation of our expected env var.
if (!expectEnv(github.githubAuthToken, 'GITHUB_TOKEN')) return;
if (!expectEnv(github.repoSlug, 'GITHUB_REPOSITORY')) return;
if (!expectEnv(github.issueNumber, 'ISSUE_NUMBER')) return;
if (!expectEnv(github.sha, 'GITHUB_SHA')) return;
if ((github.actor ?? '').endsWith(_botSuffix)) {
print('Skipping package validation for ${github.actor} PRs.');
return;
}
print('Start health check for the checks $args');
var checks = [
if (args.contains('version') &&
!github.prLabels.contains('skip-validate-check'))
validateCheck,
if (args.contains('license') &&
!github.prLabels.contains('skip-license-check'))
licenseCheck,
if (args.contains('changelog') &&
!github.prLabels.contains('skip-changelog-check'))
changelogCheck,
if (args.contains('coverage') &&
!github.prLabels.contains('skip-coverage-check'))
(Github github) => coverageCheck(github, coverageweb),
if (args.contains('breaking') &&
!github.prLabels.contains('skip-breaking-check'))
breakingCheck,
];
var checked =
await Future.wait(checks.map((check) => check(github)).toList());
await writeInComment(github, checked);
github.close();
}
Future<HealthCheckResult> validateCheck(Github github) async {
//TODO: Add Flutter support for PR health checks
var results = await Firehose(directory, false).verify(github);
var markdownTable = '''
| Package | Version | Status |
| :--- | ---: | :--- |
${results.describeAsMarkdown(withTag: false)}
Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
''';
return HealthCheckResult(
'validate',
_publishBotTag2,
results.severity,
markdownTable,
);
}
Future<HealthCheckResult> breakingCheck(Github github) async {
final repo = Repository();
final packages = repo.locatePackages();
var changeForPackage = <Package, BreakingChange>{};
var baseDirectory = Directory('../base_repo');
for (var package in packages) {
var currentPath =
path.relative(package.directory.path, from: Directory.current.path);
var basePackage = path.relative(
path.join(baseDirectory.absolute.path, currentPath),
from: currentPath,
);
print('Look for changes in $currentPath with base $basePackage');
var runApiTool = Process.runSync(
'dart',
[
...['pub', 'global', 'run'],
'dart_apitool:main',
'diff',
...['--old', basePackage],
...['--new', '.'],
...['--report-format', 'json'],
...['--report-file-path', 'report.json'],
],
workingDirectory: currentPath,
);
print(runApiTool.stderr);
print(runApiTool.stdout);
final reportFile = File(path.join(currentPath, 'report.json'));
var fullReportString = reportFile.readAsStringSync();
var decoded = jsonDecode(fullReportString) as Map<String, dynamic>;
var report = decoded['report'] as Map<String, dynamic>;
var formattedChanges = const JsonEncoder.withIndent(' ').convert(report);
print('Breaking change report:\n$formattedChanges');
BreakingLevel breakingLevel;
if ((report['noChangesDetected'] as bool?) ?? false) {
breakingLevel = BreakingLevel.none;
} else {
if ((report['breakingChanges'] as Map? ?? {}).isNotEmpty) {
breakingLevel = BreakingLevel.breaking;
} else if ((report['nonBreakingChanges'] as Map? ?? {}).isNotEmpty) {
breakingLevel = BreakingLevel.nonBreaking;
} else {
breakingLevel = BreakingLevel.none;
}
}
var oldPackage = Package(
Directory(path.join(baseDirectory.path, currentPath)),
package.repository,
);
changeForPackage[package] = BreakingChange(
level: breakingLevel,
oldVersion: oldPackage.version!,
newVersion: package.version!,
);
}
return HealthCheckResult(
'breaking',
_breakingBotTag,
changeForPackage.values.any((element) => !element.versionIsFine)
? Severity.warning
: Severity.info,
'''
| Package | Change | Current Version | New Version | Needed Version | Looking good? |
| :--- | :--- | ---: | ---: | ---: | ---: |
${changeForPackage.entries.map((e) => '|${e.key.name}|${e.value.toMarkdownRow()}|').join('\n')}
''',
);
}
Future<HealthCheckResult> licenseCheck(Github github) async {
var files = await github.listFilesForPR();
var allFilePaths = await getFilesWithoutLicenses(Directory.current);
var groupedPaths = allFilePaths
.groupListsBy((path) => files.any((f) => f.relativePath == path));
var unchangedFilesPaths = groupedPaths[false] ?? [];
var unchangedMarkdown = '''
<details>
<summary>
Unrelated files missing license headers
</summary>
| Files |
| :--- |
${unchangedFilesPaths.map((e) => '|$e|').join('\n')}
</details>
''';
var changedFilesPaths = groupedPaths[true] ?? [];
var markdownResult = '''
```
$license
```
| Files |
| :--- |
${changedFilesPaths.isNotEmpty ? changedFilesPaths.map((e) => '|$e|').join('\n') : '| _no missing headers_ |'}
All source files should start with a [license header](https://github.com/dart-lang/ecosystem/wiki/License-Header).
${unchangedFilesPaths.isNotEmpty ? unchangedMarkdown : ''}
''';
return HealthCheckResult(
'license',
_licenseBotTag,
changedFilesPaths.isNotEmpty ? Severity.error : Severity.success,
markdownResult,
);
}
Future<HealthCheckResult> changelogCheck(Github github) async {
var filePaths = await packagesWithoutChangelog(github);
final markdownResult = '''
| Package | Changed Files |
| :--- | :--- |
${filePaths.entries.map((e) => '| package:${e.key.name} | ${e.value.map((e) => e.relativePath).join('<br />')} |').join('\n')}
Changes to files need to be [accounted for](https://github.com/dart-lang/ecosystem/wiki/Changelog) in their respective changelogs.
''';
return HealthCheckResult(
'changelog',
_changelogBotTag,
filePaths.isNotEmpty ? Severity.error : Severity.success,
markdownResult,
);
}
Future<HealthCheckResult> coverageCheck(
Github github,
bool coverageWeb,
) async {
var coverage = await Coverage(coverageWeb).compareCoverages(github);
var markdownResult = '''
| File | Coverage |
| :--- | :--- |
${coverage.coveragePerFile.entries.map((e) => '|${e.key}| ${e.value.toMarkdown()} |').join('\n')}
This check for [test coverage](https://github.com/dart-lang/ecosystem/wiki/Test-Coverage) is informational (issues shown here will not fail the PR).
''';
return HealthCheckResult(
'coverage',
_coverageBotTag,
Severity.values[coverage.coveragePerFile.values
.map((change) => change.severity.index)
.fold(0, max)],
markdownResult,
);
}
Future<void> writeInComment(
Github github,
List<HealthCheckResult> results,
) async {
var commentText = results.map((result) {
var markdown = result.markdown;
var isWorseThanInfo = result.severity.index >= Severity.warning.index;
var s = '''
<details${isWorseThanInfo ? ' open' : ''}>
<summary>
Details
</summary>
$markdown
${isWorseThanInfo ? 'This check can be disabled by tagging the PR with `skip-${result.name}-check`' : ''}
</details>
''';
return '${result.tag} ${result.severity.emoji}\n\n$s';
}).join('\n');
var summary = '$_prHealthTag\n\n$commentText';
github.appendStepSummary(summary);
var repoSlug = github.repoSlug!;
var issueNumber = github.issueNumber!;
var existingCommentId = await allowFailure(
github.findCommentId(
repoSlug,
issueNumber,
user: _githubActionsUser,
searchTerm: _prHealthTag,
),
logError: print,
);
if (existingCommentId != null) {
var idFile = File('./output/commentId');
print('''
Saving existing comment id $existingCommentId to file ${idFile.path}''');
await idFile.create(recursive: true);
await idFile.writeAsString(existingCommentId.toString());
}
var commentFile = File('./output/comment.md');
print('Saving comment markdown to file ${commentFile.path}');
await commentFile.create(recursive: true);
await commentFile.writeAsString(summary);
if (results.any((result) => result.severity == Severity.error) &&
exitCode == 0) {
exitCode = 1;
}
}
}
Version getNewVersion(BreakingLevel level, Version oldVersion) {
return switch (level) {
BreakingLevel.none => oldVersion,
BreakingLevel.nonBreaking => oldVersion.nextMinor,
BreakingLevel.breaking => oldVersion.nextBreaking,
};
}
enum BreakingLevel {
none('None'),
nonBreaking('Non-Breaking'),
breaking('Breaking');
final String name;
const BreakingLevel(this.name);
}
class HealthCheckResult {
final String name;
final String tag;
final Severity severity;
final String markdown;
HealthCheckResult(this.name, this.tag, this.severity, this.markdown);
}
class BreakingChange {
final BreakingLevel level;
final Version oldVersion;
final Version newVersion;
BreakingChange({
required this.level,
required this.oldVersion,
required this.newVersion,
});
Version get suggestedNewVersion => getNewVersion(level, oldVersion);
bool get versionIsFine => newVersion == suggestedNewVersion;
String toMarkdownRow() => [
level.name,
oldVersion,
newVersion,
versionIsFine ? suggestedNewVersion : '**$suggestedNewVersion**',
versionIsFine ? ':heavy_check_mark:' : ':warning:'
].map((e) => e.toString()).join('|');
}