blob: ca5c24706113bda29717173952376e5f8e832e03 [file] [log] [blame]
// Copyright (c) 2021, 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.
/// This implements support for dependency-bot style automated upgrades.
/// It is still work in progress - do not rely on the current output.
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import '../command.dart';
import '../entrypoint.dart';
import '../exceptions.dart';
import '../io.dart';
import '../log.dart' as log;
import '../package.dart';
import '../package_name.dart';
import '../pubspec.dart';
import '../pubspec_utils.dart';
import '../solver.dart';
import '../system_cache.dart';
class DependencyServicesReportCommand extends PubCommand {
@override
String get name => 'report';
@override
String get description =>
'Output a machine-digestible report of the upgrade options for each dependency.';
@override
String get argumentsDescription => '[options]';
@override
bool get takesArguments => false;
DependencyServicesReportCommand() {
argParser.addOption('directory',
abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir');
}
@override
Future<void> runProtected() async {
final compatiblePubspec = stripDependencyOverrides(entrypoint.root.pubspec);
final breakingPubspec = stripVersionUpperBounds(compatiblePubspec);
final compatiblePackagesResult =
await _tryResolve(compatiblePubspec, cache);
final breakingPackagesResult = await _tryResolve(breakingPubspec, cache);
// The packages in the current lockfile or resolved from current pubspec.yaml.
late Map<String, PackageId> currentPackages;
if (fileExists(entrypoint.lockFilePath)) {
currentPackages =
Map<String, PackageId>.from(entrypoint.lockFile.packages);
} else {
final resolution = await _tryResolve(entrypoint.root.pubspec, cache) ??
(throw DataException('Failed to resolve pubspec'));
currentPackages =
Map<String, PackageId>.fromIterable(resolution, key: (e) => e.name);
}
currentPackages.remove(entrypoint.root.name);
final dependencies = <Object>[];
final result = <String, Object>{'dependencies': dependencies};
Future<List<Object>> _computeUpgradeSet(
Pubspec rootPubspec,
PackageId? package, {
required UpgradeType upgradeType,
}) async {
if (package == null) return [];
final lockFile = entrypoint.lockFile;
final pubspec = upgradeType == UpgradeType.multiBreaking
? stripVersionUpperBounds(rootPubspec)
: Pubspec(
rootPubspec.name,
dependencies: rootPubspec.dependencies.values,
devDependencies: rootPubspec.devDependencies.values,
sdkConstraints: rootPubspec.sdkConstraints,
);
final dependencySet = dependencySetOfPackage(pubspec, package);
if (dependencySet != null) {
// Force the version to be the new version.
dependencySet[package.name] =
package.toRange().withConstraint(package.toRange().constraint);
}
final resolution = await tryResolveVersions(
SolveType.GET,
cache,
Package.inMemory(pubspec),
lockFile: lockFile,
);
// TODO(sigurdm): improve error messages.
if (resolution == null) {
throw DataException('Failed resolving');
}
return [
...resolution.packages.where((r) {
if (r.name == rootPubspec.name) return false;
final originalVersion = currentPackages[r.name];
return originalVersion == null ||
r.version != originalVersion.version;
}).map((p) {
final depset = dependencySetOfPackage(rootPubspec, p);
final originalConstraint = depset?[p.name]?.constraint;
return {
'name': p.name,
'version': p.version.toString(),
'kind': _kindString(pubspec, p.name),
'constraint': originalConstraint == null
? null
: upgradeType == UpgradeType.compatible
? originalConstraint.toString()
: VersionConstraint.compatibleWith(p.version).toString(),
'previousVersion': currentPackages[p.name]?.version.toString(),
'previousConstraint': originalConstraint?.toString(),
};
}),
for (final oldPackageName in lockFile.packages.keys)
if (!resolution.packages
.any((newPackage) => newPackage.name == oldPackageName))
{
'name': oldPackageName,
'version': null,
'kind':
'transitive', // Only transitive constraints can be removed.
'constraint': null,
'previousVersion':
currentPackages[oldPackageName]?.version.toString(),
'previousConstraint': null,
},
];
}
for (final package in currentPackages.values) {
final compatibleVersion = compatiblePackagesResult
?.firstWhereOrNull((element) => element.name == package.name);
final multiBreakingVersion = breakingPackagesResult
?.firstWhereOrNull((element) => element.name == package.name);
final singleBreakingPubspec = Pubspec(
compatiblePubspec.name,
version: compatiblePubspec.version,
sdkConstraints: compatiblePubspec.sdkConstraints,
dependencies: compatiblePubspec.dependencies.values,
devDependencies: compatiblePubspec.devDependencies.values,
);
final dependencySet =
dependencySetOfPackage(singleBreakingPubspec, package);
final kind = _kindString(compatiblePubspec, package.name);
PackageId? singleBreakingVersion;
if (dependencySet != null) {
dependencySet[package.name] = package
.toRange()
.withConstraint(stripUpperBound(package.toRange().constraint));
final singleBreakingPackagesResult =
await _tryResolve(singleBreakingPubspec, cache);
singleBreakingVersion = singleBreakingPackagesResult
?.firstWhereOrNull((element) => element.name == package.name);
}
dependencies.add({
'name': package.name,
'version': package.version.toString(),
'kind': kind,
'latest': (await cache.getLatest(package))?.version.toString(),
'constraint':
_constraintOf(compatiblePubspec, package.name)?.toString(),
if (compatibleVersion != null)
'compatible': await _computeUpgradeSet(
compatiblePubspec, compatibleVersion,
upgradeType: UpgradeType.compatible),
'singleBreaking': kind != 'transitive' && singleBreakingVersion == null
? []
: await _computeUpgradeSet(compatiblePubspec, singleBreakingVersion,
upgradeType: UpgradeType.singleBreaking),
'multiBreaking': kind != 'transitive' && multiBreakingVersion != null
? await _computeUpgradeSet(compatiblePubspec, multiBreakingVersion,
upgradeType: UpgradeType.multiBreaking)
: [],
});
}
log.message(JsonEncoder.withIndent(' ').convert(result));
}
}
VersionConstraint? _constraintOf(Pubspec pubspec, String packageName) {
return (pubspec.dependencies[packageName] ??
pubspec.devDependencies[packageName])
?.constraint;
}
String _kindString(Pubspec pubspec, String packageName) {
return pubspec.dependencies.containsKey(packageName)
? 'direct'
: pubspec.devDependencies.containsKey(packageName)
? 'dev'
: 'transitive';
}
/// Try to solve [pubspec] return [PackageId]s in the resolution or `null` if no
/// resolution was found.
Future<List<PackageId>?> _tryResolve(Pubspec pubspec, SystemCache cache) async {
final solveResult = await tryResolveVersions(
SolveType.UPGRADE,
cache,
Package.inMemory(pubspec),
);
return solveResult?.packages;
}
class DependencyServicesListCommand extends PubCommand {
@override
String get name => 'list';
@override
String get description =>
'Output a machine digestible listing of all dependencies';
@override
bool get takesArguments => false;
DependencyServicesListCommand() {
argParser.addOption('directory',
abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir');
}
@override
Future<void> runProtected() async {
final pubspec = entrypoint.root.pubspec;
final currentPackages = fileExists(entrypoint.lockFilePath)
? entrypoint.lockFile.packages.values.toList()
: (await _tryResolve(pubspec, cache) ?? <PackageId>[]);
final dependencies = <Object>[];
final result = <String, Object>{'dependencies': dependencies};
for (final package in currentPackages) {
dependencies.add({
'name': package.name,
'version': package.version.toString(),
'kind': _kindString(pubspec, package.name),
'constraint': _constraintOf(pubspec, package.name).toString(),
});
}
log.message(JsonEncoder.withIndent(' ').convert(result));
}
}
enum UpgradeType {
/// Only upgrade pubspec.lock
compatible,
/// Unlock at most one dependency in pubspec.yaml
singleBreaking,
/// Unlock any dependencies in pubspec.yaml needed for getting the
/// latest resolvable version.
multiBreaking,
}
class DependencyServicesApplyCommand extends PubCommand {
@override
String get name => 'apply';
@override
String get description =>
'Output a machine digestible listing of all dependencies';
@override
bool get takesArguments => true;
DependencyServicesApplyCommand() {
argParser.addOption('directory',
abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir');
}
@override
Future<void> runProtected() async {
YamlEditor(readTextFile(entrypoint.pubspecPath));
final toApply = <_PackageVersion>[];
final input = json.decode(await utf8.decodeStream(stdin));
for (final change in input['dependencyChanges']) {
toApply.add(
_PackageVersion(
change['name'],
change['version'] != null ? Version.parse(change['version']) : null,
change['constraint'] != null
? VersionConstraint.parse(change['constraint'])
: null,
),
);
}
final pubspec = entrypoint.root.pubspec;
final pubspecEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
final lockFile = fileExists(entrypoint.lockFilePath)
? readTextFile(entrypoint.lockFilePath)
: null;
final lockFileYaml = lockFile == null ? null : loadYaml(lockFile);
final lockFileEditor = lockFile == null ? null : YamlEditor(lockFile);
for (final p in toApply) {
final targetPackage = p.name;
final targetVersion = p.version;
final targetConstraint = p.constraint;
if (targetConstraint != null) {
final section = pubspec.dependencies[targetPackage] != null
? 'dependencies'
: 'dev_dependencies';
pubspecEditor
.update([section, targetPackage], targetConstraint.toString());
} else if (targetVersion != null) {
final constraint = _constraintOf(pubspec, targetPackage);
if (constraint != null && !constraint.allows(targetVersion)) {
final section = pubspec.dependencies[targetPackage] != null
? 'dependencies'
: 'dev_dependencies';
pubspecEditor.update([section, targetPackage],
VersionConstraint.compatibleWith(targetVersion).toString());
}
}
if (targetVersion != null &&
lockFileEditor != null &&
lockFileYaml['packages'].containsKey(targetPackage)) {
lockFileEditor.update(
['packages', targetPackage, 'version'], targetVersion.toString());
}
}
if (pubspecEditor.edits.isNotEmpty) {
writeTextFile(entrypoint.pubspecPath, pubspecEditor.toString());
}
if (lockFileEditor != null && lockFileEditor.edits.isNotEmpty) {
writeTextFile(entrypoint.lockFilePath, lockFileEditor.toString());
}
await log.warningsOnlyUnlessTerminal(
() async {
// This will fail if the new configuration does not resolve.
await Entrypoint(directory, cache)
.acquireDependencies(SolveType.GET, analytics: null);
},
);
// Dummy message.
log.message(json.encode({'dependencies': []}));
}
}
class _PackageVersion {
String name;
Version? version;
VersionConstraint? constraint;
_PackageVersion(this.name, this.version, this.constraint);
}
Map<String, PackageRange>? dependencySetOfPackage(
Pubspec pubspec, PackageName package) {
return pubspec.dependencies.containsKey(package.name)
? pubspec.dependencies
: pubspec.devDependencies.containsKey(package.name)
? pubspec.devDependencies
: null;
}