blob: 47baf97423d0afee0372c9456648f5856c3582dc [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:glob/glob.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 _publishBotTag2 = '### Package publish validation';
const String _licenseBotTag = '### License Headers';
const String _changelogBotTag = '### Changelog Entry';
const String _doNotSubmitBotTag = '### Do Not Submit';
const String _coverageBotTag = '### Coverage';
const String _breakingBotTag = '### Breaking changes';
const checkTypes = <String>[
'version',
'license',
'changelog',
'coverage',
'breaking',
'do-not-submit',
];
class Health {
final Directory directory;
final String commentPath;
Health(
this.directory,
this.check,
this.warnOn,
this.failOn,
this.coverageweb,
List<String> ignoredPackages,
List<String> ignoredLicense,
List<String> ignoredCoverage,
this.experiments,
this.github, {
Directory? base,
String? comment,
}) : ignoredPackages = ignoredPackages
.map((pattern) => Glob(pattern, recursive: true))
.toList(),
ignoredFilesForCoverage = ignoredCoverage
.map((pattern) => Glob(pattern, recursive: true))
.toList(),
ignoredFilesForLicense = ignoredLicense
.map((pattern) => Glob(pattern, recursive: true))
.toList(),
baseDirectory = base ?? Directory('../base_repo'),
commentPath = comment ??
path.join(
directory.path,
'output',
'comment.md',
);
final GithubApi github;
final String check;
final List<String> warnOn;
final List<String> failOn;
final bool coverageweb;
final List<Glob> ignoredPackages;
final List<Glob> ignoredFilesForLicense;
final List<Glob> ignoredFilesForCoverage;
final Directory baseDirectory;
final List<String> experiments;
Future<void> healthCheck() async {
// Do basic validation of our expected env var.
if (!expectEnv(github.repoSlug?.fullName, 'GITHUB_REPOSITORY')) return;
if (!expectEnv(github.issueNumber?.toString(), 'ISSUE_NUMBER')) return;
if (!expectEnv(github.sha, 'GITHUB_SHA')) return;
print('Start health check for the check $check with');
print(' warnOn: $warnOn');
print(' failOn: $failOn');
print(' coverageweb: $coverageweb');
print(' ignoredPackages: $ignoredPackages');
print(' ignoredForLicense: $ignoredFilesForLicense');
print(' ignoredForCoverage: $ignoredFilesForCoverage');
print(' baseDirectory: $baseDirectory');
print(' experiments: $experiments');
print('Checking for $check');
if (!github.prLabels.contains('skip-$check-check')) {
final firstResult = await checkFor(check)();
final HealthCheckResult finalResult;
if (warnOn.contains(check) && firstResult.severity == Severity.error) {
finalResult = firstResult.withSeverity(Severity.warning);
} else if (failOn.contains(check) &&
firstResult.severity == Severity.warning) {
finalResult = firstResult.withSeverity(Severity.error);
} else {
finalResult = firstResult;
}
await writeInComment(github, finalResult);
print('\n\n${finalResult.severity.name.toUpperCase()}: $check done.\n\n');
} else {
print('Skipping $check, as the skip tag is present.');
}
}
String tagFor(String checkType) => switch (checkType) {
'version' => _publishBotTag2,
'license' => _licenseBotTag,
'changelog' => _changelogBotTag,
'coverage' => _coverageBotTag,
'breaking' => _breakingBotTag,
'do-not-submit' => _doNotSubmitBotTag,
String() => throw ArgumentError('Invalid check type $checkType'),
};
Future<HealthCheckResult> Function() checkFor(String checkType) =>
switch (checkType) {
'version' => validateCheck,
'license' => licenseCheck,
'changelog' => changelogCheck,
'coverage' => coverageCheck,
'breaking' => breakingCheck,
'do-not-submit' => doNotSubmitCheck,
String() => throw ArgumentError('Invalid check type $checkType'),
};
Future<HealthCheckResult> validateCheck() async {
//TODO: Add Flutter support for PR health checks
var results =
await Firehose(directory, false).verify(github, ignoredPackages);
var markdownTable = '''
| Package | Version | Status |
| :--- | ---: | :--- |
${results.describeAsMarkdown(withTag: false)}
Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
''';
return HealthCheckResult(
'version',
results.severity,
markdownTable,
);
}
Future<HealthCheckResult> breakingCheck() async {
final filesInPR = await github.listFilesForPR(directory, ignoredPackages);
final changeForPackage = <Package, BreakingChange>{};
for (var package in packagesContaining(filesInPR, ignoredPackages)) {
print('Look for changes in $package with base $baseDirectory');
var relativePath =
path.relative(package.directory.path, from: directory.path);
var baseRelativePath = path.relative(
path.join(baseDirectory.path, relativePath),
from: directory.path);
var tempDirectory = Directory.systemTemp..createSync();
var reportPath = path.join(tempDirectory.path, 'report.json');
var runApiTool = Process.runSync(
'dart',
[
...['pub', 'global', 'run'],
'dart_apitool:main',
'diff',
...['--old', baseRelativePath],
...['--new', relativePath],
...['--report-format', 'json'],
...['--report-file-path', reportPath],
],
workingDirectory: directory.path,
);
print(runApiTool.stderr);
print(runApiTool.stdout);
var fullReportString = File(reportPath).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');
final versionMap = decoded['version'] as Map<String, dynamic>;
changeForPackage[package] = BreakingChange(
level: _breakingLevel(report),
oldVersion: Version.parse(versionMap['old'].toString()),
newVersion: Version.parse(versionMap['new'].toString()),
neededVersion: Version.parse(versionMap['needed'].toString()),
versionIsFine: versionMap['success'] as bool,
explanation: versionMap['explanation'].toString(),
);
}
return HealthCheckResult(
'breaking',
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')}
''',
);
}
BreakingLevel _breakingLevel(Map<String, dynamic> report) {
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;
}
return breakingLevel;
}
Future<HealthCheckResult> licenseCheck() async {
var files = await github.listFilesForPR(directory, ignoredPackages);
var allFilePaths = await getFilesWithoutLicenses(
directory,
[...ignoredFilesForLicense, ...ignoredPackages],
);
var groupedPaths = allFilePaths.groupListsBy((filePath) {
return files.any((f) => f.filename == filePath);
});
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',
changedFilesPaths.isNotEmpty ? Severity.error : Severity.success,
markdownResult,
);
}
Future<HealthCheckResult> changelogCheck() async {
var filePaths = await packagesWithoutChangelog(
github,
ignoredPackages,
directory,
);
final markdownResult = '''
| Package | Changed Files |
| :--- | :--- |
${filePaths.entries.map((e) => '| package:${e.key.name} | ${e.value.map((e) => e.filename).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',
filePaths.isNotEmpty ? Severity.error : Severity.success,
markdownResult,
);
}
Future<HealthCheckResult> doNotSubmitCheck() async {
final dns = 'DO_NOT${'_'}SUBMIT';
final body = await github.pullrequestBody();
final files = await github.listFilesForPR(directory, ignoredPackages);
print('Checking for DO_NOT${'_'}SUBMIT strings: $files');
final filesWithDNS = files
.where((file) =>
![FileStatus.removed, FileStatus.unchanged].contains(file.status))
.where((file) => File(file.pathInRepository)
.readAsStringSync()
.contains('DO_NOT${'_'}SUBMIT'))
.toList();
print('Found files with $dns: $filesWithDNS');
final bodyContainsDNS = body.contains(dns);
print('The body contains a $dns string: $bodyContainsDNS');
final markdownResult = '''
Body contains `$dns`: $bodyContainsDNS
| Files with `$dns` |
| :--- |
${filesWithDNS.map((e) => e.filename).map((e) => '|$e|').join('\n')}
''';
final hasDNS = filesWithDNS.isNotEmpty || bodyContainsDNS;
return HealthCheckResult(
'do-not-submit',
hasDNS ? Severity.error : Severity.success,
hasDNS ? markdownResult : null,
);
}
Future<HealthCheckResult> coverageCheck() async {
var coverage = await Coverage(
coverageweb,
ignoredFilesForCoverage,
ignoredPackages,
directory,
experiments,
).compareCoverages(github, directory);
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',
Severity.values[coverage.coveragePerFile.values
.map((change) => change.severity.index)
.fold(0, max)],
markdownResult,
);
}
Future<void> writeInComment(
GithubApi github, HealthCheckResult result) async {
final String markdownSummary;
if (result.markdown != null) {
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>
''';
markdownSummary = '${tagFor(result.name)} ${result.severity.emoji}\n\n$s';
} else {
markdownSummary = '';
}
github.appendStepSummary(markdownSummary);
var commentFile = File(commentPath);
print('Saving comment markdown to file ${commentFile.path}');
await commentFile.create(recursive: true);
await commentFile.writeAsString(markdownSummary);
if (result.severity == Severity.error && exitCode == 0) {
exitCode = 1;
}
}
List<Package> packagesContaining(
List<GitFile> filesInPR,
List<Glob> ignoredPackages,
) {
var files = filesInPR.where((element) => element.status.isRelevant);
return Repository(directory)
.locatePackages(ignoredPackages)
.where((package) => files.any((file) =>
path.isWithin(package.directory.path, file.pathInRepository)))
.toList();
}
}
enum BreakingLevel {
none('None'),
nonBreaking('Non-Breaking'),
breaking('Breaking');
final String name;
const BreakingLevel(this.name);
}
class HealthCheckResult {
final String name;
final Severity severity;
final String? markdown;
HealthCheckResult(this.name, this.severity, this.markdown);
HealthCheckResult withSeverity(Severity severity) => HealthCheckResult(
name,
severity,
markdown,
);
}
class BreakingChange {
final BreakingLevel level;
final Version oldVersion;
final Version newVersion;
final Version neededVersion;
final bool versionIsFine;
final String explanation;
BreakingChange({
required this.level,
required this.oldVersion,
required this.newVersion,
required this.neededVersion,
required this.versionIsFine,
required this.explanation,
});
String toMarkdownRow() => [
level.name,
oldVersion,
newVersion,
versionIsFine ? neededVersion : '**$neededVersion** <br> $explanation',
versionIsFine ? ':heavy_check_mark:' : ':warning:'
].map((e) => e.toString()).join('|');
}