diff --git a/.github/ISSUE_TEMPLATE/ config.yml b/.github/ISSUE_TEMPLATE/ config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6b9372e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/.github/cspell.json b/.github/cspell.json new file mode 100644 index 0000000..388219a --- /dev/null +++ b/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "depgen" + ] +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 63b035c..1058225 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,11 +1,10 @@ version: 2 -enable-beta-ecosystems: true updates: - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: github-actions + directory: / schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/" + interval: monthly #keep this comment + - package-ecosystem: pub + directory: / schedule: - interval: "daily" + interval: monthly diff --git a/.github/workflows/dependabot_gen.yaml b/.github/workflows/dependabot_gen.yaml index df6c09f..9561cba 100644 --- a/.github/workflows/dependabot_gen.yaml +++ b/.github/workflows/dependabot_gen.yaml @@ -1,5 +1,9 @@ name: dependabot_gen +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request: paths: @@ -22,12 +26,23 @@ jobs: build: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + with: + coverage_excludes: '**/*.g.dart' + dart_sdk: 'stable' + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: | + **/*.md + .*/**/*.md + modified_files_only: false verify-version: runs-on: ubuntu-latest steps: - name: ๐Ÿ“š Git Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ๐ŸŽฏ Setup Dart uses: dart-lang/setup-dart@v1 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..16ce5ac --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + /// run dart executable" `dart run :depgen` + { + "name": "dependabot_gen create", + "request": "launch", + "type": "dart", + "program": "bin/depgen.dart", + "args": [ + "create", "--repoRoot", "../../temp/dependabot-core/" + ] + }, + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1ade73c --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "dart", + "command": "dart", + "cwd": "", + "args": [ + "run", + "build_runner", + "watch" + ], + "problemMatcher": [], + "label": "dart: dart run build_runner watch", + "detail": "" + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8fa470a..3b8c43b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2022 Renan +Copyright (c) 2024, Renan Araujo -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index ea1373c..977568a 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,35 @@ ![coverage][coverage_badge] [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] -[![License: MIT][license_badge]][license_link] +[![License: BSD-3][license_badge]][license_link] -Generated by the [Very Good CLI][very_good_cli_link] ๐Ÿค– -A Very Good Project created by Very Good CLI.. + +Keep your dependabot.yaml up to date. --- +## Getting Started ๐Ÿš€ + +Activate globally via: + +```sh +dart pub global activate dependabot_gen +``` + +Or locally via: + +```sh +dart pub global activate --source=path +``` + ## Usage + ```sh -# Sample command -$ depgen >> .github/dependabot.yaml +$ depgen create + + ``` ## Running Tests with coverage ๐Ÿงช @@ -41,8 +57,8 @@ $ open coverage/index.html --- [coverage_badge]: coverage_badge.svg -[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg -[license_link]: https://opensource.org/licenses/MIT +[license_badge]: https://img.shields.io/badge/license-BSD-blue.svg +[license_link]: https://opensource.org/license/bsd-3-clause/ [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis [very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index ff9c544..835ad37 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,11 @@ -include: package:very_good_analysis/analysis_options.3.1.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml linter: rules: - public_member_api_docs: false + public_member_api_docs: true + one_member_abstracts: false + + +analyzer: + exclude: + - "**/*.g.dart" + - "lib/src/version.dart" diff --git a/lib/dependabot_gen.dart b/lib/dependabot_gen.dart index d515c57..9d2aac4 100644 --- a/lib/dependabot_gen.dart +++ b/lib/dependabot_gen.dart @@ -1,11 +1,4 @@ -// Copyright (c) 2022, Very Good Ventures -// https://verygood.ventures -// -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file or at -// https://opensource.org/licenses/MIT. - -/// dependabot_gen, A Very Good Project created by Very Good CLI. +/// dependabot_gen, Keep your dependabot.yaml up to date /// /// ```sh /// # activate dependabot_gen @@ -13,5 +6,8 @@ /// /// # see usage /// depgen --help +/// +/// # if you dont have a dependabot.yaml file, run +/// dart pub global run dependabot_gen:depgen /// ``` library dependabot_gen; diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index e815a3e..3bb8745 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -1,83 +1,58 @@ -// Copyright (c) 2022, Very Good Ventures -// https://verygood.ventures -// -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file or at -// https://opensource.org/licenses/MIT. - -import 'dart:io'; - +import 'package:args/args.dart'; import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; +import 'package:dependabot_gen/src/commands/commands.dart'; +import 'package:dependabot_gen/src/version.dart'; import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as path; +import 'package:pub_updater/pub_updater.dart'; +/// The name of the executable. const executableName = 'depgen'; + +/// The name of the package. const packageName = 'dependabot_gen'; -const description = 'A Very Good Project created by Very Good CLI.'; -class DependabotGenCommandRunner extends CommandRunner { +/// The description of the package. +const description = 'Keep your dependabot.yaml up to date'; + +/// {@template dependabot_gen_command_runner} +/// A [CommandRunner] for the depgen CLI. +/// +/// ``` +/// $ depgen --version +/// ``` +/// {@endtemplate} +class DependabotGenCommandRunner extends CompletionCommandRunner { /// {@macro dependabot_gen_command_runner} DependabotGenCommandRunner({ Logger? logger, + PubUpdater? pubUpdater, }) : _logger = logger ?? Logger(), + _pubUpdater = pubUpdater ?? PubUpdater(), super(executableName, description) { - // Add root options and flags - argParser - ..addMultiOption('ignore') - ..addMultiOption( - 'ecosystems', - allowed: PackageEcosystem.values.map((e) => e.name), - ) - ..addFlag( - 'include-gh-actions', - abbr: 'g', - ) - ..addFlag( - 'verbose', - help: 'Noisy logging, including all shell commands executed.', - ); + argParser.addFlag( + 'version', + abbr: 'v', + negatable: false, + help: 'Print the current version.', + ); + + // Add sub commands + addCommand(CreateCommand(logger: _logger)); + addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater)); } + @override + void printUsage() => _logger.info(usage); + final Logger _logger; + final PubUpdater _pubUpdater; @override Future run(Iterable args) async { - final output = StringBuffer(''' -version: 2 -updates: -'''); - try { final topLevelResults = parse(args); - if (topLevelResults['verbose'] == true) { - _logger.level = Level.verbose; - } - final includeGhActions = topLevelResults['include-gh-actions'] as bool; - - if (includeGhActions) { - output.write(ConfigEntry.ghActions); - } - - final ignore = topLevelResults['ignore'] as List; - - final ecosystems = topLevelResults['ecosystems'] as Iterable; - for (final ecosystem in PackageEcosystem.values) { - if (ecosystems.isEmpty || (ecosystems.contains(ecosystem.name))) { - final pubItems = ecosystem.getEntries(_logger, ignore); - output.writeAll(pubItems.map((e) => e.toString())); - } - } - - _logger.write(output.toString()); - return ExitCode.success.code; - } on ProcessException catch (e, stackTrace) { - _logger - ..err('Things went south') - ..err(e.message) - ..err('$stackTrace') - ..info('') - ..info(usage); - return ExitCode.ioError.code; + return await runCommand(topLevelResults) ?? ExitCode.success.code; } on FormatException catch (e, stackTrace) { // On format errors, show the commands error message, root usage and // exit with an error code @@ -97,116 +72,48 @@ updates: return ExitCode.usage.code; } } -} -enum PackageEcosystem { - cargo('Cargo.toml'), - npm('package.json'), - pub( - 'pubspec.yaml', - ['./.tmp', './brick/__brick__', './.dart_tool'], - ), - composer('composer.json'); - - const PackageEcosystem( - this.indexFile, [ - this.defaultIgnore = const [], - ]); - - final String indexFile; - final Iterable defaultIgnore; - - Iterable getEntries( - Logger logger, [ - List ignore = const [], - ]) sync* { - final effectiveIgnore = [...ignore, ...defaultIgnore]; - final result = Process.runSync( - 'find', - [ - '.', - '-name', - indexFile, - ], - runInShell: true, - ); - if (result.exitCode != 0) { - throw ProcessException( - 'find', - [ - '.', - '-name', - indexFile, - ], - 'Things went south', - result.exitCode, - ); + @override + Future runCommand(ArgResults topLevelResults) async { + // Fast track completion command + if (topLevelResults.command?.name == 'completion') { + await super.runCommand(topLevelResults); + return ExitCode.success.code; } - final stdout = result.stdout as String; - final paths = stdout - .split('\n') - .where((element) => element.isNotEmpty) - .map(path.dirname); - pans: - for (final pubPath in paths) { - for (final parent in effectiveIgnore) { - if (path.isWithin(parent, pubPath) || path.equals(parent, pubPath)) { - continue pans; - } - } - - final convertedPath = - pubPath.replaceAll('./', '/').replaceAll(RegExp(r'^\.$'), '/'); - - yield ConfigEntry( - ecosystemName: name, - directory: convertedPath, - schedule: const ConfigSchedule(interval: 'daily'), - ); + // Run the command or show version + final int? exitCode; + if (topLevelResults['version'] == true) { + _logger.info(packageVersion); + exitCode = ExitCode.success.code; + } else { + exitCode = await super.runCommand(topLevelResults); } - } -} -class ConfigEntry { - const ConfigEntry({ - required this.ecosystemName, - required this.directory, - required this.schedule, - }); - - static const ghActions = ConfigEntry( - ecosystemName: 'gh-actions', - directory: '/', - schedule: ConfigSchedule(interval: 'daily'), - ); - - final String ecosystemName; - final String directory; - final ConfigSchedule schedule; + // Check for updates + if (topLevelResults.command?.name != UpdateCommand.commandName) { + await _checkForUpdates(); + } - @override - String toString() { - return ''' - - package-ecosystem: "${ecosystemName}" - directory: "$directory" -$schedule -'''; + return exitCode; } -} -class ConfigSchedule { - const ConfigSchedule({ - required this.interval, - }); - - final String interval; - - @override - String toString() { - return ''' - schedule: - interval: "daily" -'''; + /// Checks if the current version (set by the build runner on the + /// version.dart file) is the most recent one. If not, show a prompt to the + /// user. + Future _checkForUpdates() async { + try { + final latestVersion = await _pubUpdater.getLatestVersion(packageName); + final isUpToDate = packageVersion == latestVersion; + if (!isUpToDate) { + _logger + ..info('') + ..info( + ''' +${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} +Run ${lightCyan.wrap('$executableName update')} to update''', + ); + } + } catch (_) {} } } diff --git a/lib/src/commands/commands.dart b/lib/src/commands/commands.dart new file mode 100644 index 0000000..3db580b --- /dev/null +++ b/lib/src/commands/commands.dart @@ -0,0 +1,3 @@ +export 'create_command.dart'; +export 'mixins.dart'; +export 'update_command.dart'; diff --git a/lib/src/commands/create_command.dart b/lib/src/commands/create_command.dart new file mode 100644 index 0000000..b8cb9b2 --- /dev/null +++ b/lib/src/commands/create_command.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:dependabot_gen/src/commands/mixins.dart'; +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +/// {@template create_command} +/// +/// `depgen create` command which creates a new dependabot.yaml file. +/// {@endtemplate} +class CreateCommand extends DependabotGenCommand + with + EcosystemsOption, + LoggerLevelOption, + ScheduleOption, + TargetBranchOption, + LabelsOption, + MilestoneOption, + IgnorePathsOption, + RepositoryRootOption { + /// {@macro create_command} + CreateCommand({ + required super.logger, + }); + + @override + String get description => ''' +Create or update the dependabot.yaml file in a repository. +Will keep existing entries and add new ones for possibly uncovered packages. +'''; + + @override + String get name => 'create'; + + @override + Future run() async { + final ret = await super.run(); + if (ret != null) { + return ret; + } + + final repoRoot = await getRepositoryRoot(); + + final dependabotFile = DependabotFile.fromRepositoryRoot(repoRoot); + + logger.info( + 'Creating dependabot.yaml in ${dependabotFile.path}}', + ); + + final newEntries = ecosystems.fold( + [], + (previousValue, ecosystem) { + ecosystem + .findUpdateEntries( + repoRoot: repoRoot, + schedule: schedule, + targetBranch: targetBranch, + labels: labels, + milestone: milestone, + ignoreFinding: ignorePaths, + ) + .forEach(previousValue.add); + + return previousValue; + }, + ); + + final currentUpdates = dependabotFile.updates; + + for (final newEntry in newEntries) { + final entryExists = currentUpdates.firstWhereOrNull((element) { + return element.directory == newEntry.directory && + element.ecosystem == newEntry.ecosystem; + }) != + null; + + if (entryExists) { + logger.info( + ''' +Entry for ${newEntry.ecosystem} already exists for ${newEntry.directory}''', + ); + } else { + logger.success( + 'Added ${newEntry.ecosystem} entry for ${newEntry.directory}', + ); + dependabotFile.addUpdateEntry(newEntry); + } + } + + for (final entry in currentUpdates) { + var dir = entry.directory; + if (dir.startsWith('/')) { + dir = dir.substring(1); + } + final exists = Directory(p.join(repoRoot.path, dir)).existsSync(); + + if (exists) { + logger.info( + 'Preserved ${entry.ecosystem} entry for ${entry.directory}', + ); + continue; + } + + dependabotFile.removeUpdateEntry( + ecosystem: entry.ecosystem, + directory: entry.directory, + ); + logger.info( + yellow.wrap('Removed ${entry.ecosystem} entry for ${entry.directory}'), + ); + } + + dependabotFile.saveToFile(); + + logger.info('Finished creating dependabot.yaml in $repoRoot'); + + return ExitCode.success.code; + } +} diff --git a/lib/src/commands/mixins.dart b/lib/src/commands/mixins.dart new file mode 100644 index 0000000..18080be --- /dev/null +++ b/lib/src/commands/mixins.dart @@ -0,0 +1,298 @@ +import 'dart:async'; + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:dependabot_gen/src/package_finder/package_finder.dart'; +import 'package:git/git.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// {@template mixins_command} +/// A subclass of [Command] that allwos usages of mixins to add options. +/// {@endtemplate} +abstract class DependabotGenCommand extends Command { + /// {@macro mixins_command} + DependabotGenCommand({ + required Logger logger, + }) : _logger = logger { + addOptions(); + } + + /// Adds options to the command. + @mustCallSuper + @protected + void addOptions() {} + + final Logger _logger; + + /// The [Logger] for this command. + Logger get logger => _logger; + + @mustCallSuper + @override + Future run() async { + // test if git is instaleld in the PATH + final result = await Process.run('git', ['--version']); + + if (result.exitCode != 0) { + _logger.err( + 'Git is not installed or not in the PATH, make sure git available in ' + 'your PATH.', + ); + return ExitCode.unavailable.code; + } + + // if not, throw an error + + return null; + } +} + +/// Adds the `--silent` and `--verbose` options to the command. +/// +/// Get the log level with [logLevel]. +mixin LoggerLevelOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser + ..addFlag( + 'silent', + abbr: 'S', + help: 'Silences all output.', + ) + ..addFlag( + 'verbose', + abbr: 'V', + help: 'Verbose output.', + ); + } + + /// Gets the [Level] for the logger. + Level get logLevel { + final silent = argResults!['silent'] as bool; + final verbose = argResults!['verbose'] as bool; + + if (verbose && silent) { + throw UsageException( + 'Both --verbose and --silent were provided. ' + "Its like asking for a hot ice cube. Just doesn't work, does it?", + usage, + ); + } + + if (verbose) { + return Level.verbose; + } + if (silent) { + return Level.quiet; + } + + return Level.info; + } + + @mustCallSuper + @override + Future run() async { + final ret = await super.run(); + if (ret != null) { + return ret; + } + logger.level = logLevel; + return null; + } +} + +/// Adds the `--schedule-interval` option to the command. +/// +/// Get the [Schedule] with [schedule]. +mixin ScheduleOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addOption( + 'schedule-interval', + abbr: 'I', + allowed: ScheduleInterval.values.map((e) => e.name), + defaultsTo: ScheduleInterval.weekly.name, + help: 'The interval to check for updates on new update entries ' + '(does not affect existing ones).', + ); + } + + /// Gets the [Schedule] for the command. + Schedule get schedule { + final interval = argResults!['schedule-interval'] as String; + + final intervalSchedule = ScheduleInterval.values + .firstWhere((element) => element.name == interval); + + return Schedule( + interval: intervalSchedule, + ); + } +} + +/// Adds the `--target-branch` option to the command. +mixin TargetBranchOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addOption( + 'target-branch', + help: 'The target branch to create pull requests against.', + ); + } + + /// Gets the target branch for the command. + String? get targetBranch => argResults!['target-branch'] as String?; +} + +/// Adds the `--ignore-paths` option to the command. +mixin IgnorePathsOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addMultiOption( + 'ignore-paths', + abbr: 'i', + help: + 'Paths to ignore when searching for packages. Example: "__brick__/**"', + ); + } + + /// Gets the paths to ignore for the command. + Set? get ignorePaths { + final ignorePaths = argResults!['ignore-paths'] as List; + + if (ignorePaths.isEmpty) { + return null; + } + + return ignorePaths.toSet(); + } +} + +/// Adds the `--labels` option to the command. +mixin LabelsOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addMultiOption( + 'labels', + help: 'Labels to add to the pull requests.', + ); + } + + /// Gets the labels. + Set? get labels { + final labels = argResults!['labels'] as List; + + if (labels.isEmpty) { + return null; + } + + return labels.toSet(); + } +} + +/// Adds the `--milestone` option to the command. +mixin MilestoneOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addOption( + 'milestone', + help: 'The milestone to add to the pull requests. Must be a number.', + ); + } + + /// Gets the milestone. + int? get milestone { + final milestoneRaw = argResults!['milestone'] as String?; + + final milestone = int.tryParse(milestoneRaw ?? ''); + + return milestone; + } +} + +/// Adds the `--ecosystems` option to the command. +mixin EcosystemsOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addMultiOption( + 'ecosystems', + abbr: 'e', + allowed: PackageEcosystem.values.map((e) => e.name), + defaultsTo: PackageEcosystem.values.map((e) => e.name), + help: 'The package ecosystems to update in the dependabot.yaml file.', + ); + } + + /// Gets the ecosystems. + Set get ecosystems { + final ecosystems = argResults!['ecosystems'] as List; + + return ecosystems + .map( + (e) => PackageEcosystem.values.firstWhere( + (element) => element.name == e, + ), + ) + .toSet(); + } +} + +/// Adds the `--repo-root` option to the command. +mixin RepositoryRootOption on DependabotGenCommand { + @override + void addOptions() { + super.addOptions(); + argParser.addOption( + 'repo-root', + abbr: 'r', + help: ''' +Path to the repository root. If ommited, the command will search for the closest git repository root from the current working directory.''', + ); + } + + /// Gets the repository root. + Future getRepositoryRoot() async { + final path = argResults!['repo-root'] as String?; + + if (path == null) { + return _fetchRepositoryRoot(); + } + + return Directory(path); + } + + Future _fetchRepositoryRoot([ + String? path, + ]) async { + final current = p.absolute(path ?? Directory.current.path); + + final pr = await runGit( + ['rev-parse', '--git-dir'], + processWorkingDir: current, + ); + + final gitDirPath = (pr.stdout as String).trim(); + + if (p.basename(gitDirPath) != '.git') { + throw UsageException( + 'Could not find a git repository in the current directory.', + 'Run this command from a path within a git repository.', + ); + } + + final pp = p.dirname(p.absolute(gitDirPath)); + + return Directory(pp); + } +} diff --git a/lib/src/commands/update_command.dart b/lib/src/commands/update_command.dart new file mode 100644 index 0000000..0d286d0 --- /dev/null +++ b/lib/src/commands/update_command.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:dependabot_gen/src/command_runner.dart'; +import 'package:dependabot_gen/src/commands/mixins.dart'; +import 'package:dependabot_gen/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:pub_updater/pub_updater.dart'; + +/// {@template update_command} +/// A command which updates the CLI. +/// {@endtemplate} +class UpdateCommand extends DependabotGenCommand with LoggerLevelOption { + /// {@macro update_command} + UpdateCommand({ + required super.logger, + PubUpdater? pubUpdater, + }) : _pubUpdater = pubUpdater ?? PubUpdater(); + + final PubUpdater _pubUpdater; + + @override + String get description => 'Updates this CLI.'; + + /// The name of this command. + static const String commandName = 'update'; + + @override + String get name => commandName; + + @override + Future run() async { + final ret = await super.run(); + if (ret != null) { + return ret; + } + + final updateCheckProgress = logger.progress('Checking for updates'); + late final String latestVersion; + try { + latestVersion = await _pubUpdater.getLatestVersion(packageName); + } catch (error) { + updateCheckProgress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + updateCheckProgress.complete('Checked for updates'); + + final isUpToDate = packageVersion == latestVersion; + if (isUpToDate) { + logger.info('CLI is already at the latest version.'); + return ExitCode.success.code; + } + + final updateProgress = logger.progress('Updating to $latestVersion'); + + late final ProcessResult result; + try { + result = await _pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ); + } catch (error) { + updateProgress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + + if (result.exitCode != ExitCode.success.code) { + updateProgress.fail(); + logger.err('Error updating CLI: ${result.stderr}'); + return ExitCode.software.code; + } + + updateProgress.complete('Updated to $latestVersion'); + + return ExitCode.success.code; + } +} diff --git a/lib/src/create.dart b/lib/src/create.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/src/create.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/src/dependabot_yaml/dependabot_yaml.dart b/lib/src/dependabot_yaml/dependabot_yaml.dart new file mode 100644 index 0000000..f5c55f7 --- /dev/null +++ b/lib/src/dependabot_yaml/dependabot_yaml.dart @@ -0,0 +1,2 @@ +export 'file.dart'; +export 'spec.dart'; diff --git a/lib/src/dependabot_yaml/file.dart b/lib/src/dependabot_yaml/file.dart new file mode 100644 index 0000000..7c647c8 --- /dev/null +++ b/lib/src/dependabot_yaml/file.dart @@ -0,0 +1,124 @@ +import 'dart:io'; + +import 'package:checked_yaml/checked_yaml.dart'; +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml_edit/yaml_edit.dart'; +import 'package:yaml_writer/yaml_writer.dart'; + +/// Represents a dependabot.yaml file with its [path]. +class DependabotFile { + DependabotFile._( + this.path, + this._content, + this._editor, + ); + + /// Creates a new [DependabotFile] from the given [file]. + /// + /// If the file is empty, a default [DependabotSpec] will be created. + @visibleForTesting + factory DependabotFile.fromFile(File file) { + var contents = file.existsSync() ? file.readAsStringSync() : ''; + + DependabotSpec content; + if (contents.isEmpty) { + content = const DependabotSpec( + version: DependabotVersion.v2, + updates: [], + ); + contents = YAMLWriter().write(content); + } else { + content = checkedYamlDecode( + contents, + (m) => DependabotSpec.fromJson(m!), + sourceUrl: file.uri, + ); + } + + final editor = YamlEditor(contents); + + return DependabotFile._(file.path, content, editor); + } + + /// Retrieves the [DependabotFile] for the given [repositoryRoot]. + /// + /// If the file does not exist, it will be created. + factory DependabotFile.fromRepositoryRoot(Directory repositoryRoot) { + final filePath = p.join(repositoryRoot.path, '.github', 'dependabot.yml'); + final filePath2 = p.join(repositoryRoot.path, '.github', 'dependabot.yaml'); + var file = File(filePath); + + if (!file.existsSync()) { + file = File(filePath2); + } + + if (!file.existsSync()) { + file = File(filePath); + } + + return DependabotFile.fromFile(file); + } + + /// The path to the dependabot.yaml file. + final String path; + + /// The content of the dependabot.yaml file represented as a [DependabotSpec]. + DependabotSpec _content; + + final YamlEditor _editor; + + /// The current updates in the dependabot.yaml file. + Iterable get updates => _content.updates; + + /// Adds a new [UpdateEntry] to the dependabot.yaml file. + /// + /// Does not immediately save the changes to the file. + /// For that, call [saveToFile]. + void addUpdateEntry(UpdateEntry newEntry) { + _content = _content.copyWith( + updates: [ + ..._content.updates, + newEntry, + ], + ); + _editor.appendToList(['updates'], newEntry.toJson()); + } + + /// Removes an [UpdateEntry] from the dependabot.yaml file. + /// + /// Does not immediately save the changes to the file. + /// For that, call [saveToFile]. + void removeUpdateEntry({ + required String directory, + required String ecosystem, + }) { + final matchingEntries = [..._content.updates].indexed.where( + (e) => e.$2.directory == directory && e.$2.ecosystem == ecosystem, + ); + + if (matchingEntries.isEmpty) { + return; + } + + _content.updates.removeWhere( + (e) => e.directory == directory && e.ecosystem == ecosystem, + ); + + for (final (index, _) in matchingEntries) { + _editor.remove(['updates', index]); + } + } + + /// Saves the changes to the actual dependabot.yaml file. + void saveToFile() { + final file = File(path); + + if (!file.existsSync()) { + file.createSync(recursive: true); + } + + file.writeAsStringSync(_editor.toString()); + } +} diff --git a/lib/src/dependabot_yaml/spec.dart b/lib/src/dependabot_yaml/spec.dart new file mode 100644 index 0000000..18fb465 --- /dev/null +++ b/lib/src/dependabot_yaml/spec.dart @@ -0,0 +1,564 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'spec.g.dart'; + +/// {@template dependabot_spec} +/// A representation of a dependabot.yaml file content. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, +) +class DependabotSpec extends Equatable { + /// {@macro dependabot_spec} + const DependabotSpec({ + required this.version, + required this.updates, + this.enableBetaEcosystems, + this.ignore, + this.registries, + }); + + /// Creates a new [DependabotSpec] from a JSON map. + factory DependabotSpec.fromJson(Map json) => + _$DependabotSpecFromJson(json); + + /// Converts this object to a JSON map. + Map toJson() => _$DependabotSpecToJson(this); + + /// The version of the dependabot spec. + @JsonKey(defaultValue: DependabotVersion.v2) + final DependabotVersion version; + + /// Enable ecosystems that have beta-level support. + @JsonKey(disallowNullValue: true, name: 'enable-beta-ecosystems') + final bool? enableBetaEcosystems; + + /// Ignore certain dependencies or versions + @JsonKey(disallowNullValue: true, toJson: _ignoresToJson) + final List? ignore; + + /// A map of registries to their configuration. + @JsonKey(disallowNullValue: true) + final Map? registries; + + /// Element for each one package manager that you want GitHub Dependabot to + /// monitor for new versions. + @JsonKey(toJson: _updatesToJson) + final List updates; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DependabotSpec copyWith({ + required List updates, + }) { + return DependabotSpec( + version: version, + updates: updates, + enableBetaEcosystems: enableBetaEcosystems, + ignore: ignore, + registries: registries, + ); + } + + @override + List get props => [ + version, + updates, + enableBetaEcosystems, + ignore, + ]; +} + +/// The version of the dependabot spec. +enum DependabotVersion { + /// Version 2 of the dependabot spec. + @JsonValue(2) + v2, +} + +List _updatesToJson(List updates) { + return updates.map((e) => e.toJson()).toList(); +} + +/// {@template update_entry} +/// Element for each one package manager that GitHub Dependabot will +/// monitor for new versions. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, +) +class UpdateEntry extends Equatable { + /// {@macro update_entry} + const UpdateEntry({ + required this.directory, + required this.ecosystem, + required this.schedule, + this.allow, + this.assignees, + this.commitMessage, + this.groups, + this.ignore, + this.insecureExternalCodeExecution, + this.labels, + this.milestone, + this.openPullRequestsLimit, + this.pullRequestBranchName, + this.rebaseStrategy, + this.registries, + this.reviewers, + this.targetBranch, + this.vendor, + this.versioningStrategy, + }); + + /// Creates a new [UpdateEntry] from a JSON map. + factory UpdateEntry.fromJson(Map json) => + _$UpdateEntryFromJson(json); + + /// Converts this object to a JSON map. + Map toJson() => _$UpdateEntryToJson(this); + + /// The package manager to use. + @JsonKey(required: true, name: 'package-ecosystem') + final String ecosystem; + + /// The directory where the manifest file is located. + final String directory; + + /// Schedule for Dependabot to update dependencies. + final Schedule schedule; + + /// Customize which updates are allowed. + @JsonKey(disallowNullValue: true) + @_AllowedEntryConverter() + final List? allow; + + /// Assignees to set on pull requests. + @JsonKey(disallowNullValue: true) + final Set? assignees; + + /// Commit message preferences. + @JsonKey(disallowNullValue: true, name: 'commit-message') + final CommitMessage? commitMessage; + + /// Group updates for certain dependencies. + @JsonKey(disallowNullValue: true) + final Map? groups; + + /// Ignore certain dependencies or versions + @JsonKey(disallowNullValue: true, toJson: _ignoresToJson) + final List? ignore; + + /// Allow or deny code execution in manifest files + @JsonKey(disallowNullValue: true, name: 'insecure-external-code-execution') + final InsecureExternalCodeExecution? insecureExternalCodeExecution; + + /// Labels to set on pull requests. + @JsonKey(disallowNullValue: true) + final Set? labels; + + /// Milestone to set on pull requests. + @JsonKey(disallowNullValue: true) + final int? milestone; + + /// Limit the number of open pull requests. + @JsonKey(disallowNullValue: true, name: 'open-pull-requests-limit') + final int? openPullRequestsLimit; + + /// Change separator for pull request branch names. + @JsonKey(disallowNullValue: true, name: 'pull-request-branch-name') + final PullRequestBranchName? pullRequestBranchName; + + /// Rebase strategy to use. + @JsonKey(disallowNullValue: true, name: 'rebase-strategy') + final RebaseStrategy? rebaseStrategy; + + /// Private registries that Dependabot can access + @JsonKey(disallowNullValue: true) + final List? registries; + + /// Reviewers to request on pull requests. + @JsonKey(disallowNullValue: true) + final List? reviewers; + + /// The branch to create pull requests against. + @JsonKey(disallowNullValue: true, name: 'target-branch') + final String? targetBranch; + + /// Raise pull requests to update vendored dependencies that are checked in + /// to the repository + @JsonKey(disallowNullValue: true) + final bool? vendor; + + /// The strategy to use when updating versions. + @JsonKey(disallowNullValue: true, name: 'versioning-strategy') + final VersioningStrategy? versioningStrategy; + + @override + List get props => [ + ecosystem, + directory, + schedule, + allow, + assignees, + commitMessage, + groups, + ignore, + insecureExternalCodeExecution, + labels, + milestone, + openPullRequestsLimit, + pullRequestBranchName, + rebaseStrategy, + registries, + reviewers, + targetBranch, + vendor, + versioningStrategy, + ]; +} + +/// {@template schedule} +/// Schedule for Dependabot to update dependencies. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, +) +class Schedule extends Equatable { + /// {@macro schedule} + const Schedule({ + required this.interval, + this.day, + this.time, + this.timezone, + }); + + /// Creates a new [Schedule] from a JSON map. + factory Schedule.fromJson(Map json) => + _$ScheduleFromJson(json); + + /// Converts this object to a JSON map. + Map toJson() => _$ScheduleToJson(this); + + /// The interval to use. + @JsonKey(required: true) + final ScheduleInterval interval; + + /// Which day of the week to use. + @JsonKey(disallowNullValue: true) + final ScheduleDay? day; + + /// The time of day to use. + @JsonKey(disallowNullValue: true) + final String? time; + + /// The timezone to use. + @JsonKey(disallowNullValue: true) + final String? timezone; + + @override + List get props => [ + interval, + day, + time, + timezone, + ]; +} + +/// Defines the interval for a [Schedule]. +enum ScheduleInterval { + /// Check for updates daily. + daily, + + /// Check for updates weekly. + weekly, + + /// Check for updates monthly. + monthly, +} + +/// Defines the day of the week for a [Schedule]. +enum ScheduleDay { + /// Check for updates on Monday. + monday, + + /// Check for updates on Tuesday. + tuesday, + + /// Check for updates on Wednesday. + wednesday, + + /// Check for updates on Thursday. + thursday, + + /// Check for updates on Friday. + friday, + + /// Check for updates on Saturday. + saturday, + + /// Check for updates on Sunday. + sunday, +} + +/// Generic type for allowed entries to be used by [UpdateEntry.allow]. +sealed class AllowEntry extends Equatable { + const AllowEntry(); +} + +/// {@template allow_dependency} +/// Allow updates for a specific dependency. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, +) +class AllowDependency extends AllowEntry { + /// {@macro allow_dependency} + const AllowDependency({ + required this.name, + }); + + /// The name of the dependency to allow. + @JsonKey(required: true, name: 'dependency-name') + final String name; + + @override + List get props => [name]; +} + +/// {@template allow_dependency_type} +/// Allow updates for a specific dependency type. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, +) +class AllowDependencyType extends AllowEntry { + /// {@macro allow_dependency_type} + const AllowDependencyType({ + required this.dependencyType, + }); + + /// The type of the dependency to allow. + @JsonKey(required: true, name: 'dependency-type') + final String dependencyType; + + @override + List get props => [dependencyType]; +} + +class _AllowedEntryConverter + implements JsonConverter> { + const _AllowedEntryConverter(); + + @override + AllowEntry fromJson(Map json) { + if (json['dependency-name'] is String) { + return _$AllowDependencyFromJson(json); + } + if (json['dependency-type'] is String) { + return _$AllowDependencyTypeFromJson(json); + } + + throw CheckedFromJsonException( + json, + '', + 'AllowEntry', + 'Unknown type for "allow": $json', + badKey: true, + ); + } + + @override + Map toJson(AllowEntry object) { + return switch (object) { + final AllowDependency dep => _$AllowDependencyToJson(dep), + final AllowDependencyType depType => _$AllowDependencyTypeToJson(depType), + }; + } +} + +/// {@template commit_message} +/// Commit message preferences. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, +) +class CommitMessage extends Equatable { + /// {@macro commit_message} + const CommitMessage({ + required this.prefix, + required this.prefixDevelopment, + required this.include, + }); + + /// Creates a new [CommitMessage] from a JSON map. + factory CommitMessage.fromJson(Map json) => + _$CommitMessageFromJson(json); + + /// Converts this object to a JSON map. + Map toJson() => _$CommitMessageToJson(this); + + /// The prefix to use for commit messages. + @JsonKey(disallowNullValue: true) + final String? prefix; + + /// The prefix to use for commit messages for development dependencies. + @JsonKey(disallowNullValue: true, name: 'prefix-development') + final String? prefixDevelopment; + + /// The scope to use for commit messages. + @JsonKey(defaultValue: 'scope', disallowNullValue: true) + final String? include; + + @override + List get props => [ + prefix, + prefixDevelopment, + include, + ]; +} + +List? _ignoresToJson(List? ignore) { + return ignore?.map((e) => e.toJson()).toList(); +} + +/// {@template ignore} +/// Ignore certain dependencies or versions. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, +) +class Ignore extends Equatable { + /// {@macro ignore} + const Ignore({ + required this.dependencyName, + required this.versions, + required this.updateTypes, + }); + + /// Creates a new [Ignore] from a JSON map. + factory Ignore.fromJson(Map json) => _$IgnoreFromJson(json); + + /// Converts this object to a JSON map.` + Map toJson() => _$IgnoreToJson(this); + + /// The name of the dependency to ignore. + @JsonKey(required: true, name: 'dependency-name') + final String dependencyName; + + /// The versions of the dependency to ignore. + @JsonKey(disallowNullValue: true) + final List? versions; + + /// The types of updates to ignore. + @JsonKey(disallowNullValue: true, name: 'update-types') + final List? updateTypes; + + @override + List get props => [ + dependencyName, + versions, + updateTypes, + ]; +} + +/// Types of updates to ignore. +enum UpdateType { + /// Ignore major updates. + @JsonValue('version-update:semver-major') + major, + + /// Ignore minor updates. + @JsonValue('version-update:semver-minor') + minor, + + /// Ignore patch updates. + @JsonValue('version-update:semver-patch') + patch, +} + +/// {@template pull_request_branch_name} +/// Change separator for pull request branch names. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, +) +class PullRequestBranchName extends Equatable { + /// {@macro pull_request_branch_name} + const PullRequestBranchName({required this.separator}); + + /// Creates a new [PullRequestBranchName] from a JSON map. + factory PullRequestBranchName.fromJson(Map json) => + _$PullRequestBranchNameFromJson(json); + + /// Converts this object to a JSON map. + Map toJson() => _$PullRequestBranchNameToJson(this); + + /// The separator to use. + final String separator; + + @override + List get props => [ + separator, + ]; +} + +/// Rebase strategy to use. +enum RebaseStrategy { + /// Auto-rebase pull requests. + auto, + + /// Do not rebase pull requests automatically. + disabled, +} + +/// The strategy to use when updating versions. +enum VersioningStrategy { + /// Auto-detect the versioning strategy. + auto, + + /// Use the lockfile only. + increase, + + /// Increase the version if necessary. + @JsonValue('increase-if-necessary') + increaseIfNecessary, + + /// Use the lockfile only. + @JsonValue('lockfile-only') + lockfileOnly, + + /// Increase the version if necessary. + widen, +} + +/// Allow or deny code execution in manifest files. +enum InsecureExternalCodeExecution { + /// Allow code execution in manifest files. + @JsonValue('allow') + allow, + + /// Deny code execution in manifest files. + @JsonValue('deny') + deny, +} diff --git a/lib/src/dependabot_yaml/spec.g.dart b/lib/src/dependabot_yaml/spec.g.dart new file mode 100644 index 0000000..c241944 --- /dev/null +++ b/lib/src/dependabot_yaml/spec.g.dart @@ -0,0 +1,454 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spec.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DependabotSpec _$DependabotSpecFromJson(Map json) => $checkedCreate( + 'DependabotSpec', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'version', + 'enable-beta-ecosystems', + 'ignore', + 'registries', + 'updates' + ], + disallowNullValues: const [ + 'enable-beta-ecosystems', + 'ignore', + 'registries' + ], + ); + final val = DependabotSpec( + version: $checkedConvert( + 'version', + (v) => + $enumDecodeNullable(_$DependabotVersionEnumMap, v) ?? + DependabotVersion.v2), + updates: $checkedConvert( + 'updates', + (v) => (v as List) + .map((e) => UpdateEntry.fromJson(e as Map)) + .toList()), + enableBetaEcosystems: + $checkedConvert('enable-beta-ecosystems', (v) => v as bool?), + ignore: $checkedConvert( + 'ignore', + (v) => (v as List?) + ?.map((e) => Ignore.fromJson(e as Map)) + .toList()), + registries: $checkedConvert( + 'registries', + (v) => (v as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ); + return val; + }, + fieldKeyMap: const {'enableBetaEcosystems': 'enable-beta-ecosystems'}, + ); + +Map _$DependabotSpecToJson(DependabotSpec instance) { + final val = { + 'version': _$DependabotVersionEnumMap[instance.version]!, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('enable-beta-ecosystems', instance.enableBetaEcosystems); + writeNotNull('ignore', _ignoresToJson(instance.ignore)); + writeNotNull('registries', instance.registries); + val['updates'] = _updatesToJson(instance.updates); + return val; +} + +const _$DependabotVersionEnumMap = { + DependabotVersion.v2: 2, +}; + +UpdateEntry _$UpdateEntryFromJson(Map json) => $checkedCreate( + 'UpdateEntry', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'package-ecosystem', + 'directory', + 'schedule', + 'allow', + 'assignees', + 'commit-message', + 'groups', + 'ignore', + 'insecure-external-code-execution', + 'labels', + 'milestone', + 'open-pull-requests-limit', + 'pull-request-branch-name', + 'rebase-strategy', + 'registries', + 'reviewers', + 'target-branch', + 'vendor', + 'versioning-strategy' + ], + requiredKeys: const ['package-ecosystem'], + disallowNullValues: const [ + 'allow', + 'assignees', + 'commit-message', + 'groups', + 'ignore', + 'insecure-external-code-execution', + 'labels', + 'milestone', + 'open-pull-requests-limit', + 'pull-request-branch-name', + 'rebase-strategy', + 'registries', + 'reviewers', + 'target-branch', + 'vendor', + 'versioning-strategy' + ], + ); + final val = UpdateEntry( + directory: $checkedConvert('directory', (v) => v as String), + ecosystem: $checkedConvert('package-ecosystem', (v) => v as String), + schedule: + $checkedConvert('schedule', (v) => Schedule.fromJson(v as Map)), + allow: $checkedConvert( + 'allow', + (v) => (v as List?) + ?.map( + (e) => const _AllowedEntryConverter().fromJson(e as Map)) + .toList()), + assignees: $checkedConvert('assignees', + (v) => (v as List?)?.map((e) => e as String).toSet()), + commitMessage: $checkedConvert('commit-message', + (v) => v == null ? null : CommitMessage.fromJson(v as Map)), + groups: $checkedConvert( + 'groups', + (v) => (v as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ignore: $checkedConvert( + 'ignore', + (v) => (v as List?) + ?.map((e) => Ignore.fromJson(e as Map)) + .toList()), + insecureExternalCodeExecution: $checkedConvert( + 'insecure-external-code-execution', + (v) => $enumDecodeNullable( + _$InsecureExternalCodeExecutionEnumMap, v)), + labels: $checkedConvert('labels', + (v) => (v as List?)?.map((e) => e as String).toSet()), + milestone: $checkedConvert('milestone', (v) => v as int?), + openPullRequestsLimit: + $checkedConvert('open-pull-requests-limit', (v) => v as int?), + pullRequestBranchName: $checkedConvert( + 'pull-request-branch-name', + (v) => + v == null ? null : PullRequestBranchName.fromJson(v as Map)), + rebaseStrategy: $checkedConvert('rebase-strategy', + (v) => $enumDecodeNullable(_$RebaseStrategyEnumMap, v)), + registries: $checkedConvert('registries', + (v) => (v as List?)?.map((e) => e as String).toList()), + reviewers: $checkedConvert('reviewers', + (v) => (v as List?)?.map((e) => e as String).toList()), + targetBranch: $checkedConvert('target-branch', (v) => v as String?), + vendor: $checkedConvert('vendor', (v) => v as bool?), + versioningStrategy: $checkedConvert('versioning-strategy', + (v) => $enumDecodeNullable(_$VersioningStrategyEnumMap, v)), + ); + return val; + }, + fieldKeyMap: const { + 'ecosystem': 'package-ecosystem', + 'commitMessage': 'commit-message', + 'insecureExternalCodeExecution': 'insecure-external-code-execution', + 'openPullRequestsLimit': 'open-pull-requests-limit', + 'pullRequestBranchName': 'pull-request-branch-name', + 'rebaseStrategy': 'rebase-strategy', + 'targetBranch': 'target-branch', + 'versioningStrategy': 'versioning-strategy' + }, + ); + +Map _$UpdateEntryToJson(UpdateEntry instance) { + final val = { + 'package-ecosystem': instance.ecosystem, + 'directory': instance.directory, + 'schedule': instance.schedule.toJson(), + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('allow', + instance.allow?.map(const _AllowedEntryConverter().toJson).toList()); + writeNotNull('assignees', instance.assignees?.toList()); + writeNotNull('commit-message', instance.commitMessage?.toJson()); + writeNotNull('groups', instance.groups); + writeNotNull('ignore', _ignoresToJson(instance.ignore)); + writeNotNull( + 'insecure-external-code-execution', + _$InsecureExternalCodeExecutionEnumMap[ + instance.insecureExternalCodeExecution]); + writeNotNull('labels', instance.labels?.toList()); + writeNotNull('milestone', instance.milestone); + writeNotNull('open-pull-requests-limit', instance.openPullRequestsLimit); + writeNotNull( + 'pull-request-branch-name', instance.pullRequestBranchName?.toJson()); + writeNotNull( + 'rebase-strategy', _$RebaseStrategyEnumMap[instance.rebaseStrategy]); + writeNotNull('registries', instance.registries); + writeNotNull('reviewers', instance.reviewers); + writeNotNull('target-branch', instance.targetBranch); + writeNotNull('vendor', instance.vendor); + writeNotNull('versioning-strategy', + _$VersioningStrategyEnumMap[instance.versioningStrategy]); + return val; +} + +const _$InsecureExternalCodeExecutionEnumMap = { + InsecureExternalCodeExecution.allow: 'allow', + InsecureExternalCodeExecution.deny: 'deny', +}; + +const _$RebaseStrategyEnumMap = { + RebaseStrategy.auto: 'auto', + RebaseStrategy.disabled: 'disabled', +}; + +const _$VersioningStrategyEnumMap = { + VersioningStrategy.auto: 'auto', + VersioningStrategy.increase: 'increase', + VersioningStrategy.increaseIfNecessary: 'increase-if-necessary', + VersioningStrategy.lockfileOnly: 'lockfile-only', + VersioningStrategy.widen: 'widen', +}; + +Schedule _$ScheduleFromJson(Map json) => $checkedCreate( + 'Schedule', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['interval', 'day', 'time', 'timezone'], + requiredKeys: const ['interval'], + disallowNullValues: const ['day', 'time', 'timezone'], + ); + final val = Schedule( + interval: $checkedConvert( + 'interval', (v) => $enumDecode(_$ScheduleIntervalEnumMap, v)), + day: $checkedConvert( + 'day', (v) => $enumDecodeNullable(_$ScheduleDayEnumMap, v)), + time: $checkedConvert('time', (v) => v as String?), + timezone: $checkedConvert('timezone', (v) => v as String?), + ); + return val; + }, + ); + +Map _$ScheduleToJson(Schedule instance) { + final val = { + 'interval': _$ScheduleIntervalEnumMap[instance.interval]!, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('day', _$ScheduleDayEnumMap[instance.day]); + writeNotNull('time', instance.time); + writeNotNull('timezone', instance.timezone); + return val; +} + +const _$ScheduleIntervalEnumMap = { + ScheduleInterval.daily: 'daily', + ScheduleInterval.weekly: 'weekly', + ScheduleInterval.monthly: 'monthly', +}; + +const _$ScheduleDayEnumMap = { + ScheduleDay.monday: 'monday', + ScheduleDay.tuesday: 'tuesday', + ScheduleDay.wednesday: 'wednesday', + ScheduleDay.thursday: 'thursday', + ScheduleDay.friday: 'friday', + ScheduleDay.saturday: 'saturday', + ScheduleDay.sunday: 'sunday', +}; + +AllowDependency _$AllowDependencyFromJson(Map json) => $checkedCreate( + 'AllowDependency', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['dependency-name'], + requiredKeys: const ['dependency-name'], + ); + final val = AllowDependency( + name: $checkedConvert('dependency-name', (v) => v as String), + ); + return val; + }, + fieldKeyMap: const {'name': 'dependency-name'}, + ); + +Map _$AllowDependencyToJson(AllowDependency instance) => + { + 'dependency-name': instance.name, + }; + +AllowDependencyType _$AllowDependencyTypeFromJson(Map json) => $checkedCreate( + 'AllowDependencyType', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['dependency-type'], + requiredKeys: const ['dependency-type'], + ); + final val = AllowDependencyType( + dependencyType: + $checkedConvert('dependency-type', (v) => v as String), + ); + return val; + }, + fieldKeyMap: const {'dependencyType': 'dependency-type'}, + ); + +Map _$AllowDependencyTypeToJson( + AllowDependencyType instance) => + { + 'dependency-type': instance.dependencyType, + }; + +CommitMessage _$CommitMessageFromJson(Map json) => $checkedCreate( + 'CommitMessage', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['prefix', 'prefix-development', 'include'], + disallowNullValues: const ['prefix', 'prefix-development', 'include'], + ); + final val = CommitMessage( + prefix: $checkedConvert('prefix', (v) => v as String?), + prefixDevelopment: + $checkedConvert('prefix-development', (v) => v as String?), + include: $checkedConvert('include', (v) => v as String? ?? 'scope'), + ); + return val; + }, + fieldKeyMap: const {'prefixDevelopment': 'prefix-development'}, + ); + +Map _$CommitMessageToJson(CommitMessage instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('prefix', instance.prefix); + writeNotNull('prefix-development', instance.prefixDevelopment); + writeNotNull('include', instance.include); + return val; +} + +Ignore _$IgnoreFromJson(Map json) => $checkedCreate( + 'Ignore', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['dependency-name', 'versions', 'update-types'], + requiredKeys: const ['dependency-name'], + disallowNullValues: const ['versions', 'update-types'], + ); + final val = Ignore( + dependencyName: + $checkedConvert('dependency-name', (v) => v as String), + versions: $checkedConvert('versions', + (v) => (v as List?)?.map((e) => e as String).toList()), + updateTypes: $checkedConvert( + 'update-types', + (v) => (v as List?) + ?.map((e) => $enumDecode(_$UpdateTypeEnumMap, e)) + .toList()), + ); + return val; + }, + fieldKeyMap: const { + 'dependencyName': 'dependency-name', + 'updateTypes': 'update-types' + }, + ); + +Map _$IgnoreToJson(Ignore instance) { + final val = { + 'dependency-name': instance.dependencyName, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('versions', instance.versions); + writeNotNull('update-types', + instance.updateTypes?.map((e) => _$UpdateTypeEnumMap[e]!).toList()); + return val; +} + +const _$UpdateTypeEnumMap = { + UpdateType.major: 'version-update:semver-major', + UpdateType.minor: 'version-update:semver-minor', + UpdateType.patch: 'version-update:semver-patch', +}; + +PullRequestBranchName _$PullRequestBranchNameFromJson(Map json) => + $checkedCreate( + 'PullRequestBranchName', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['separator'], + ); + final val = PullRequestBranchName( + separator: $checkedConvert('separator', (v) => v as String), + ); + return val; + }, + ); + +Map _$PullRequestBranchNameToJson( + PullRequestBranchName instance) => + { + 'separator': instance.separator, + }; diff --git a/lib/src/package_finder/package_finder.dart b/lib/src/package_finder/package_finder.dart new file mode 100644 index 0000000..9458fb6 --- /dev/null +++ b/lib/src/package_finder/package_finder.dart @@ -0,0 +1,323 @@ +import 'dart:io'; + +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:meta/meta.dart'; + +import 'package:path/path.dart' as p; + +/// The package ecosystems supported by dependabot. +enum PackageEcosystem { + /// The GitHub Actions package ecosystem for GitHub Actions. + githubActions( + ecosystemName: 'github-actions', + _HeuristicPackageEcosystemFinder( + directory: '/', + repoHeuristics: _githubActionsHeuristics, + ), + ), + + /// The Docker package ecosystem. + docker( + _HeuristicPackageEcosystemFinder( + directory: '/', + repoHeuristics: _dockerHeuristics, + ), + ), + + /// The git submodule package ecosystem. + gitModules( + ecosystemName: 'git-submodule', + _HeuristicPackageEcosystemFinder( + directory: '/', + repoHeuristics: _gitmodulesHeuristics, + ), + ), + + /// The pub package ecosystem for Dart. + pub( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'pubspec.yaml', + }, + ), + ), + + /// The go.mod package ecosystem for Go. + gomod( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'go.mod', + }, + ), + ), + + /// The Maven package ecosystem for JVM languages. + maven( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'pom.xml', + }, + ), + ), + + /// The npm package ecosystem for JavaScript. + npm( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'package.json', + }, + ), + ), + + /// LOL + composer( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'composer.json', + }, + ), + ), + + /// The pip package ecosystem for Python. + pip( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'requirements.txt', + 'Pipfile', + 'pyproject.toml', + }, + ), + ), + + /// The bundler package ecosystem for Ruby. + bundler( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'Gemfile', + }, + ), + ), + + /// The cargo package ecosystem for Rust. + cargo( + _ManifestPackageEcosystemFinder( + indexFiles: { + 'Cargo.toml', + }, + ), + ), + + /// The nuget package ecosystem for .NET. + nuget( + _ManifestPackageEcosystemFinder( + indexFiles: { + '.nuspec', + '.csproj', + }, + ), + ), + + /// The hex package ecosystem for Elixir. + hex( + ecosystemName: 'mix', + _ManifestPackageEcosystemFinder( + indexFiles: { + 'mix.exs', + }, + ), + ), + ; + + const PackageEcosystem( + this._finder, { + this.ecosystemName, + }); + + /// The respective [_PackageEcosystemFinder] for this [PackageEcosystem]. + final _PackageEcosystemFinder _finder; + + /// The name of the package ecosystem if it's different from [name]. + final String? ecosystemName; + + /// Finds the packages that may have its dependencies updated by dependabot. + Iterable findUpdateEntries({ + required Directory repoRoot, + required Schedule schedule, + required String? targetBranch, + required Set? labels, + required int? milestone, + required Set? ignoreFinding, + }) => + _finder.findUpdateEntries( + ecosystem: ecosystemName ?? name, + repoRoot: repoRoot, + schedule: schedule, + targetBranch: targetBranch, + labels: labels, + milestone: milestone, + ignoreFinding: ignoreFinding, + ); +} + +bool _githubActionsHeuristics(Directory repoRoot) { + final workflows = Directory( + p.join(repoRoot.path, '.github', 'workflows'), + ); + return workflows.existsSync(); +} + +bool _dockerHeuristics(Directory repoRoot) { + final dockerfile = File( + p.join(repoRoot.path, 'Dockerfile'), + ); + return dockerfile.existsSync(); +} + +bool _gitmodulesHeuristics(Directory repoRoot) { + final gitModules = File( + p.join(repoRoot.path, '.gitmodules'), + ); + return gitModules.existsSync(); +} + +/// A class that encapsulates the logic to find packages that may have +/// its dependencies updated by dependabot. +/// +/// Its subclasses are responsible for finding the packages for a specific +/// package ecosystem (e.g. pub, npm, etc) according to the how +/// the package ecosystem is structured. +@immutable +abstract interface class _PackageEcosystemFinder { + /// Finds the packages that may have its dependencies updated by dependabot. + Iterable findUpdateEntries({ + required String ecosystem, + required Directory repoRoot, + required Schedule schedule, + required String? targetBranch, + required Set? labels, + required int? milestone, + required Set? ignoreFinding, + }); +} + +/// {@template heuristic_package_ecosystem_finder} +/// A [_PackageEcosystemFinder] that uses heuristics to find packages. +/// {@endtemplate} +class _HeuristicPackageEcosystemFinder implements _PackageEcosystemFinder { + /// {@macro heuristic_package_ecosystem_finder} + const _HeuristicPackageEcosystemFinder({ + required this.directory, + required this.repoHeuristics, + }); + + /// The directory where the package manifests are located. + final String directory; + + /// The heuristics to find the package manifests. + final bool Function(Directory repoRoot) repoHeuristics; + + @override + Iterable findUpdateEntries({ + required String ecosystem, + required Directory repoRoot, + required Schedule schedule, + required String? targetBranch, + required Set? labels, + required int? milestone, + required Set? ignoreFinding, + }) sync* { + if (repoHeuristics(repoRoot)) { + yield UpdateEntry( + directory: '/', + ecosystem: ecosystem, + schedule: schedule, + targetBranch: targetBranch, + labels: labels, + milestone: milestone, + ); + } + } +} + +/// {@template manifest_package_ecosystem_finder} +/// A [_PackageEcosystemFinder] that uses a list of index files to +/// find packages. +/// {@endtemplate} +class _ManifestPackageEcosystemFinder implements _PackageEcosystemFinder { + /// {@macro manifest_package_ecosystem_finder} + const _ManifestPackageEcosystemFinder({ + required this.indexFiles, + }); + + /// The index files used to find the package manifests. + final Set indexFiles; + + @override + Iterable findUpdateEntries({ + required String ecosystem, + required Directory repoRoot, + required Schedule schedule, + required String? targetBranch, + required Set? labels, + required int? milestone, + required Set? ignoreFinding, + }) sync* { + final paths = _findFilesRecursivelyOn( + directory: repoRoot, + withNames: indexFiles, + ).where((e) => e.isNotIgnored()).map((e) => e.path); + + outer: + for (final manifestPath in paths) { + if (ignoreFinding != null) { + for (final parent in ignoreFinding) { + if (p.isWithin(parent, manifestPath) || + p.equals(parent, manifestPath)) { + continue outer; + } + } + } + + final dirPath = p.relative( + p.dirname(manifestPath), + from: repoRoot.path, + ); + + // replace '.' and './' with '/' (only if it's at the beginning) + final convertedPath = dirPath.replaceFirst(RegExp(r'^\.\/?'), '/'); + + yield UpdateEntry( + directory: convertedPath, + ecosystem: ecosystem, + schedule: schedule, + targetBranch: targetBranch, + labels: labels, + milestone: milestone, + ); + } + } +} + +List _findFilesRecursivelyOn({ + required Directory directory, + required Set withNames, +}) { + final result = []; + for (final entity in directory.absolute.listSync(recursive: true)) { + if (entity is File && withNames.contains(p.basename(entity.path))) { + result.add(entity); + } + } + return result; +} + +extension on File { + bool isNotIgnored() { + final result = Process.runSync( + 'git', + 'check-ignore $path --quiet'.split(' '), + ); + + return result.exitCode != 0; + } +} diff --git a/lib/src/version.dart b/lib/src/version.dart new file mode 100644 index 0000000..5b6bfb4 --- /dev/null +++ b/lib/src/version.dart @@ -0,0 +1,2 @@ +// Generated code. Do not modify. +const packageVersion = '0.1.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 5cde2eb..7fd873d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,23 +1,35 @@ name: dependabot_gen -description: A Very Good Project created by Very Good CLI. -version: 0.0.1 +description: Generate a dependabot.yaml and keep it up to date. Dependabot Gen is a CLI used to disccover packages in a repository that can be canve its dependencies monitored by Dependabot. +version: 0.1.0 publish_to: none environment: - sdk: ">=2.18.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: - args: ^2.3.1 - mason_logger: ^0.2.0 - pub_updater: ^0.2.1 + args: ^2.4.1 + checked_yaml: ^2.0.3 + cli_completion: ^0.4.0 + collection: ^1.18.0 + equatable: ^2.0.5 + git: ^2.2.1 + json_annotation: ^4.8.1 + mason_logger: ^0.2.5 + meta: ^1.11.0 + path: ^1.9.0 + pub_updater: ^0.4.0 + yaml_edit: ^2.1.1 + yaml_writer: ^1.0.3 dev_dependencies: - build_runner: ^2.0.0 - build_verify: ^3.0.0 - build_version: ^2.0.0 - mocktail: ^0.3.0 - test: ^1.19.2 - very_good_analysis: ^3.1.0 + build_runner: ^2.4.7 + build_verify: ^3.1.0 + build_version: ^2.1.1 + json_serializable: ^6.7.1 + mocktail: ^1.0.2 + test: ^1.24.6 + very_good_analysis: ^5.1.0 executables: - depgen: \ No newline at end of file + depgen: + dependabot_gen: depgen diff --git a/test/ensure_build_test.dart b/test/ensure_build_test.dart new file mode 100644 index 0000000..4b619ed --- /dev/null +++ b/test/ensure_build_test.dart @@ -0,0 +1,9 @@ +@Tags(['version-verify']) +library ensure_build_test; + +import 'package:build_verify/build_verify.dart'; +import 'package:test/test.dart'; + +void main() { + test('ensure_build', expectBuildClean); +} diff --git a/test/fixtures/file/repo_dependabot_invalid/.github/dependabot.yml b/test/fixtures/file/repo_dependabot_invalid/.github/dependabot.yml new file mode 100644 index 0000000..eece72c --- /dev/null +++ b/test/fixtures/file/repo_dependabot_invalid/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 1 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly #keep this comment diff --git a/test/fixtures/file/repo_dependabot_yaml copy/.github/dependabot.yml b/test/fixtures/file/repo_dependabot_yaml copy/.github/dependabot.yml new file mode 100644 index 0000000..c57a8fe --- /dev/null +++ b/test/fixtures/file/repo_dependabot_yaml copy/.github/dependabot.yml @@ -0,0 +1,77 @@ +version: 2 +registries: + maven-github: + type: maven-repository + url: https://maven.pkg.github.com/octocat + username: octocat + password: '1234' + npm-npmjs: + type: npm-registry + url: https://registry.npmjs.org + username: octocat + password: '1234' +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly #keep this comment + - package-ecosystem: "npm" + directory: "/npm-package" + registries: + - npm-npmjs + schedule: + interval: "daily" + - package-ecosystem: pub + directory: / + schedule: + interval: monthly + day: monday + time: '10:00' + timezone: 'Europe/Amsterdam' + allow: + - dependency-name: 'flame_*' + - dependency-type: 'direct' + assignees: + - renancaraujo + commit-message: + prefix: 'chore(deps):' + prefix-development: 'chore(deps-dev):' + include: scope + labels: + - deps + milestone: 8 + open-pull-requests-limit: 3 + rebase-strategy: auto + pull-request-branch-name: + separator: '-' + reviewers: + - renancaraujo + target-branch: main + vendor: true + versioning-strategy: increase + ignore: + - dependency-name: 'flutter' + versions: + - '1.2.3' + update-types: + - 'version-update:semver-major' + - 'version-update:semver-minor' + - 'version-update:semver-patch' + insecure-external-code-execution: allow + # Create a group of dependencies to be updated together in one pull request + groups: + # Specify a name for the group, which will be used in pull request titles + # and branch names + dev-dependencies: + # Define patterns to include dependencies in the group (based on + # dependency name) + patterns: + - "rubocop" # A single dependency name + - "rspec*" # A wildcard string that matches multiple dependency names + - "*" # A wildcard that matches all dependencies in the package + # ecosystem. Note: using "*" may open a large pull request + # Define patterns to exclude dependencies from the group (based on + # dependency name) + exclude-patterns: + - "gc_ruboconfig" + - "gocardless-*" \ No newline at end of file diff --git a/test/fixtures/file/repo_dependabot_yaml/.github/dependabot.yaml b/test/fixtures/file/repo_dependabot_yaml/.github/dependabot.yaml new file mode 100644 index 0000000..2d196de --- /dev/null +++ b/test/fixtures/file/repo_dependabot_yaml/.github/dependabot.yaml @@ -0,0 +1,77 @@ +version: 2 +registries: + maven-github: + type: maven-repository + url: https://maven.pkg.github.com/octocat + username: octocat + password: '1234' + npm-npmjs: + type: npm-registry + url: https://registry.npmjs.org + username: octocat + password: '1234' +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly #keep this comment + - package-ecosystem: "npm" + directory: "/npm-package" + registries: + - npm-npmjs + schedule: + interval: "daily" + - package-ecosystem: pub + directory: / + schedule: + interval: monthly + day: monday + time: '10:00' + timezone: 'Europe/Amsterdam' + allow: + - dependency-name: 'flame_*' + - dependency-type: 'direct' + assignees: + - renancaraujo + commit-message: + prefix: 'chore(deps):' + prefix-development: 'chore(deps-dev):' + include: scope + labels: + - deps + milestone: 8 + open-pull-requests-limit: 3 + rebase-strategy: auto + pull-request-branch-name: + separator: '__' + reviewers: + - renancaraujo + target-branch: main + vendor: true + versioning-strategy: increase + ignore: + - dependency-name: 'flutter' + versions: + - '1.2.3' + update-types: + - 'version-update:semver-major' + - 'version-update:semver-minor' + - 'version-update:semver-patch' + insecure-external-code-execution: allow + # Create a group of dependencies to be updated together in one pull request + groups: + # Specify a name for the group, which will be used in pull request titles + # and branch names + dev-dependencies: + # Define patterns to include dependencies in the group (based on + # dependency name) + patterns: + - "rubocop" # A single dependency name + - "rspec*" # A wildcard string that matches multiple dependency names + - "*" # A wildcard that matches all dependencies in the package + # ecosystem. Note: using "*" may open a large pull request + # Define patterns to exclude dependencies from the group (based on + # dependency name) + exclude-patterns: + - "gc_ruboconfig" + - "gocardless-*" \ No newline at end of file diff --git a/test/fixtures/file/repo_dependabot_yml/.github/dependabot.yml b/test/fixtures/file/repo_dependabot_yml/.github/dependabot.yml new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/file/repo_no_dependabot/.gitkeep b/test/fixtures/file/repo_no_dependabot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/package_finder/ignored_packages/.gitignore b/test/fixtures/package_finder/ignored_packages/.gitignore new file mode 100644 index 0000000..c2e2061 --- /dev/null +++ b/test/fixtures/package_finder/ignored_packages/.gitignore @@ -0,0 +1,2 @@ + +ignore/ diff --git a/test/integration_tests/cases.txt b/test/integration_tests/cases.txt new file mode 100644 index 0000000..ae1ad57 --- /dev/null +++ b/test/integration_tests/cases.txt @@ -0,0 +1,11 @@ + +- Creates file in repository root inferred +- Creates file in repository root passed +- Do not create files when there is a yaml +- Do not create files when there is a yml +- Creates entries for correct packages +- Do not create netry for git ignored packages +- Removes entries for unknown packages +- keeps comments on root doc +- keeps comments on existing entries +- Creates entries with correct parameters diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart new file mode 100644 index 0000000..f96fe35 --- /dev/null +++ b/test/src/command_runner_test.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; +import 'package:dependabot_gen/src/command_runner.dart'; +import 'package:dependabot_gen/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pub_updater/pub_updater.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockPubUpdater extends Mock implements PubUpdater {} + +const latestVersion = '0.0.0'; + +final updatePrompt = ''' +${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} +Run ${lightCyan.wrap('$executableName update')} to update'''; + +void main() { + group('$DependabotGenCommandRunner', () { + late PubUpdater pubUpdater; + late Logger logger; + late DependabotGenCommandRunner commandRunner; + + setUp(() { + pubUpdater = _MockPubUpdater(); + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + + logger = _MockLogger(); + + commandRunner = DependabotGenCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + ); + }); + + test('shows update message when newer version exists', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(updatePrompt)).called(1); + }); + + test( + 'Does not show update message when the shell calls the ' + 'completion command', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + final result = await commandRunner.run(['completion']); + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.info(updatePrompt)); + }, + ); + + test('does not show update message when using update command', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when( + () => pubUpdater.isUpToDate( + packageName: any(named: 'packageName'), + currentVersion: any(named: 'currentVersion'), + ), + ).thenAnswer((_) async => true); + + final progress = _MockProgress(); + final progressLogs = []; + when(() => progress.complete(any())).thenAnswer((_) { + final message = _.positionalArguments.elementAt(0) as String?; + if (message != null) progressLogs.add(message); + }); + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.info(updatePrompt)); + }); + + test('can be instantiated without an explicit analytics/logger instance', + () { + final commandRunner = DependabotGenCommandRunner(); + expect(commandRunner, isNotNull); + expect(commandRunner, isA>()); + }); + + test('handles FormatException', () async { + const exception = FormatException('oops!'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info(commandRunner.usage)).called(1); + }); + + test('handles UsageException', () async { + final exception = UsageException('oops!', 'exception usage'); + var isFirstInvocation = true; + when(() => logger.info(any())).thenAnswer((_) { + if (isFirstInvocation) { + isFirstInvocation = false; + throw exception; + } + }); + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.usage.code)); + verify(() => logger.err(exception.message)).called(1); + verify(() => logger.info('exception usage')).called(1); + }); + + group('--version', () { + test('outputs current version', () async { + final result = await commandRunner.run(['--version']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(packageVersion)).called(1); + }); + }); + + group('--help', () { + test('calls logger info', () async { + final result = await commandRunner.run(['--help']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info(any())).called(1); + }); + }); + }); +} diff --git a/test/src/commands/update_command_test.dart b/test/src/commands/update_command_test.dart new file mode 100644 index 0000000..c7127b1 --- /dev/null +++ b/test/src/commands/update_command_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:dependabot_gen/src/command_runner.dart'; +import 'package:dependabot_gen/src/commands/commands.dart'; +import 'package:dependabot_gen/src/version.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pub_updater/pub_updater.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockPubUpdater extends Mock implements PubUpdater {} + +void main() { + const latestVersion = '0.0.0'; + + group('update', () { + late PubUpdater pubUpdater; + late Logger logger; + late DependabotGenCommandRunner commandRunner; + + setUp(() { + final progress = _MockProgress(); + final progressLogs = []; + pubUpdater = _MockPubUpdater(); + logger = _MockLogger(); + commandRunner = DependabotGenCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + ); + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + when( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when( + () => pubUpdater.isUpToDate( + packageName: any(named: 'packageName'), + currentVersion: any(named: 'currentVersion'), + ), + ).thenAnswer((_) async => true); + when(() => progress.complete(any())).thenAnswer((_) { + final message = _.positionalArguments.elementAt(0) as String?; + if (message != null) progressLogs.add(message); + }); + when(() => logger.progress(any())).thenReturn(progress); + }); + + test('can be instantiated without a pub updater', () { + final command = UpdateCommand(logger: logger); + expect(command, isNotNull); + }); + + test( + 'handles pub latest version query errors', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenThrow(Exception('oops')); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Exception: oops')); + verifyNever( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ); + }, + ); + + test( + 'handles pub update errors', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenThrow(Exception('oops')); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Exception: oops')); + verify( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).called(1); + }, + ); + + test('handles pub update process errors', () async { + const error = 'Oh no! Installing this is not possible right now!'; + + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer((_) async => ProcessResult(0, 1, null, error)); + + final result = await commandRunner.run(['update']); + + expect(result, equals(ExitCode.software.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.err('Error updating CLI: $error')); + verify( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).called(1); + }); + + test( + 'updates when newer version exists', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latestVersion); + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + when(() => logger.progress(any())).thenReturn(_MockProgress()); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.progress('Updating to $latestVersion')).called(1); + verify( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ), + ).called(1); + }, + ); + + test( + 'does not update when already on latest version', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + when(() => logger.progress(any())).thenReturn(_MockProgress()); + final result = await commandRunner.run(['update']); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info('CLI is already at the latest version.'), + ).called(1); + verifyNever(() => logger.progress('Updating to $latestVersion')); + verifyNever( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ); + }, + ); + }); +} diff --git a/test/src/dependabot_yaml/file_test.dart b/test/src/dependabot_yaml/file_test.dart new file mode 100644 index 0000000..e83a991 --- /dev/null +++ b/test/src/dependabot_yaml/file_test.dart @@ -0,0 +1,316 @@ +import 'dart:io'; + +import 'package:checked_yaml/checked_yaml.dart'; +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +const _kValidDependabotYaml = ''' +version: 2 +registries: + maven-github: + type: maven-repository + url: https://maven.pkg.github.com/octocat + username: octocat + password: '1234' + npm-npmjs: + type: npm-registry + url: https://registry.npmjs.org + username: octocat + password: '1234' +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly #keep this comment + - package-ecosystem: "npm" + directory: "/npm-package" + registries: + - npm-npmjs + schedule: + interval: "daily" + - package-ecosystem: pub + directory: / + schedule: + interval: monthly + day: monday + time: '10:00' + timezone: 'Europe/Amsterdam' + allow: + - dependency-name: 'flame_*' + - dependency-type: 'direct' + assignees: + - renancaraujo + commit-message: + prefix: 'chore(deps):' + prefix-development: 'chore(deps-dev):' + include: scope + labels: + - deps + milestone: 8 + open-pull-requests-limit: 3 + rebase-strategy: auto + pull-request-branch-name: + separator: '__' + reviewers: + - renancaraujo + target-branch: main + vendor: true + versioning-strategy: increase + ignore: + - dependency-name: 'flutter' + versions: + - '1.2.3' + update-types: + - 'version-update:semver-major' + - 'version-update:semver-minor' + - 'version-update:semver-patch' + insecure-external-code-execution: allow + # Create a group of dependencies to be updated together in one pull request + groups: + # Specify a name for the group, which will be used in pull request titles + # and branch names + dev-dependencies: + # Define patterns to include dependencies in the group (based on + # dependency name) + patterns: + - "rubocop" # A single dependency name + - "rspec*" # A wildcard string that matches multiple dependency names + - "*" # A wildcard that matches all dependencies in the package + # ecosystem. Note: using "*" may open a large pull request + # Define patterns to exclude dependencies from the group (based on + # dependency name) + exclude-patterns: + - "gc_ruboconfig" + - "gocardless-*" +'''; + +const _kInvalidDependabotYaml = ''' +version: 1 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly +'''; + +void main() { + group('$DependabotFile', () { + group('fromFile', () { + test('from a valid dependabot file', () { + final file = createFile(_kValidDependabotYaml); + final dependabotFile = DependabotFile.fromFile(file); + + expect(dependabotFile.path, file.path); + expect(dependabotFile.updates, hasLength(3)); + }); + + test('from an empty dependabot file', () { + final file = createFile(''); + final dependabotFile = DependabotFile.fromFile(file); + + expect(dependabotFile.path, file.path); + expect(dependabotFile.updates, hasLength(0)); + }); + + test('from an invalid dependabot file', () { + final file = createFile(_kInvalidDependabotYaml); + + expect( + () => DependabotFile.fromFile(file), + throwsA(isA()), + ); + }); + }); + + group('fromRepositoryRoot', () { + test('creates a new file when there is none', () { + final repoRoot = prepareFixture(['file', 'repo_no_dependabot']); + final dependabotFile = DependabotFile.fromRepositoryRoot(repoRoot); + + expect( + dependabotFile.path, + p.join( + repoRoot.path, + '.github', + 'dependabot.yml', + ), + ); + + expect(File(dependabotFile.path).existsSync(), false); + + expect(dependabotFile.updates, hasLength(0)); + + dependabotFile.saveToFile(); + + expect(File(dependabotFile.path).existsSync(), true); + expect(File(dependabotFile.path).readAsStringSync(), ''' +version: 2 +updates: [] +'''); + }); + + test('keeps when a yml exists', () { + final repoRoot = prepareFixture(['file', 'repo_dependabot_yml']); + + final dependabotFile = DependabotFile.fromRepositoryRoot(repoRoot); + + expect( + dependabotFile.path, + p.join( + repoRoot.path, + '.github', + 'dependabot.yml', + ), + ); + + expect(File(dependabotFile.path).existsSync(), true); + + expect(dependabotFile.updates, hasLength(0)); + + dependabotFile.saveToFile(); + + expect(File(dependabotFile.path).existsSync(), true); + expect(File(dependabotFile.path).readAsStringSync(), ''' +version: 2 +updates: [] +'''); + }); + + test('keeps when a yaml exists', () { + final repoRoot = prepareFixture(['file', 'repo_dependabot_yaml']); + + final dependabotFile = DependabotFile.fromRepositoryRoot(repoRoot); + + expect( + dependabotFile.path, + p.join( + repoRoot.path, + '.github', + 'dependabot.yaml', + ), + ); + + expect(File(dependabotFile.path).existsSync(), true); + + expect(dependabotFile.updates, hasLength(3)); + }); + + test('thowns when there is an invalid file there', () { + final repoRoot = prepareFixture(['file', 'repo_dependabot_invalid']); + + expect( + () => DependabotFile.fromRepositoryRoot(repoRoot), + throwsA(isA()), + ); + }); + }); + + group('editing', () { + test('adding and removing update entries', () { + final file = createFile(_kValidDependabotYaml); + final dependabotFile = DependabotFile.fromFile(file); + expect(dependabotFile.updates, hasLength(3)); + + dependabotFile.removeUpdateEntry( + ecosystem: 'npm', + directory: '/npm-package', + ); + + expect( + dependabotFile.updates, + [ + const UpdateEntry( + directory: '/', + ecosystem: 'github-actions', + schedule: Schedule( + interval: ScheduleInterval.monthly, + ), + ), + isA() + .having((p) => p.ecosystem, '', 'pub') + .having((p) => p.directory, '', '/'), + ], + ); + + dependabotFile.addUpdateEntry( + const UpdateEntry( + directory: '/rust_stuff', + ecosystem: 'cargo', + schedule: Schedule( + interval: ScheduleInterval.weekly, + ), + allow: [ + AllowDependencyType( + dependencyType: 'direct', + ), + ], + ), + ); + + expect( + dependabotFile.updates, + [ + const UpdateEntry( + directory: '/', + ecosystem: 'github-actions', + schedule: Schedule( + interval: ScheduleInterval.monthly, + ), + ), + isA() + .having((p) => p.ecosystem, '', 'pub') + .having((p) => p.directory, '', '/'), + const UpdateEntry( + directory: '/rust_stuff', + ecosystem: 'cargo', + schedule: Schedule( + interval: ScheduleInterval.weekly, + ), + allow: [ + AllowDependencyType( + dependencyType: 'direct', + ), + ], + ), + ], + ); + + dependabotFile + ..removeUpdateEntry(directory: '/', ecosystem: 'pub') + ..saveToFile(); + + expect( + File(dependabotFile.path).readAsStringSync(), + ''' +version: 2 +registries: + maven-github: + type: maven-repository + url: https://maven.pkg.github.com/octocat + username: octocat + password: '1234' + npm-npmjs: + type: npm-registry + url: https://registry.npmjs.org + username: octocat + password: '1234' +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly #keep this comment + - package-ecosystem: cargo + directory: /rust_stuff + schedule: + interval: weekly + allow: + - dependency-type: direct +''', + ); + }); + }); + }); +} diff --git a/test/src/dependabot_yaml/spec_test.dart b/test/src/dependabot_yaml/spec_test.dart new file mode 100644 index 0000000..f6e7dc2 --- /dev/null +++ b/test/src/dependabot_yaml/spec_test.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; + +import 'package:dependabot_gen/src/dependabot_yaml/dependabot_yaml.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:test/test.dart'; + +const _kCompleteJson = ''' +{ + "version": 2, + "enable-beta-ecosystems": true, + "registries": { + "npm-npmjs": { + "type": "npm-registry", + "url": "https://registry.npmjs.org", + "username": "octocat", + "password": "1234" + } + }, + "updates": [ + { + "package-ecosystem": "pub", + "directory": "/", + "schedule": { + "interval": "weekly", + "day": "monday", + "time": "06:00", + "timezone": "America/New_York" + }, + "allow": [ + { + "dependency-type": "direct" + }, + { + "dependency-name": "flame_*" + } + ], + "assignees": [ + "renancaraujo" + ], + "commit-message": { + "prefix": "chore(deps):", + "prefix-development": "chore(deps-dev):", + "include": "scope" + }, + "ignore": [ + { + "dependency-name": "flutter", + "versions": [ + "1.2.3" + ], + "update-types": [ + "version-update:semver-major", + "version-update:semver-minor", + "version-update:semver-patch" + ] + } + ], + "insecure-external-code-execution": "allow", + "labels": [ + "dependencies" + ], + "milestone": 4, + "open-pull-requests-limit": 6, + "pull-request-branch-name": { + "separator": "--" + }, + "rebase-strategy": "auto", + "reviewers": [ + "renancaraujo" + ], + "target-branch": "develop", + "vendor": true, + "versioning-strategy": "increase" + } + ] +}'''; + +const _kRequiredOnlyJson = ''' +{ + "version": 2, + "updates": [ + { + "package-ecosystem": "pub", + "directory": "/", + "schedule": { + "interval": "monthly" + } + } + ] +}'''; + +const _kInvalidAllowedJson = ''' +{ + "version": 2, + "updates": [ + { + "package-ecosystem": "pub", + "directory": "/", + "schedule": { + "interval": "monthly" + }, + "allow": [ + { + "dependency-type": "direct" + }, + { + "bananas": "what?*" + } + ] + } + ] +}'''; + +void main() { + group('$DependabotSpec', () { + group('to json', () { + test('all values', () { + const spec = DependabotSpec( + version: DependabotVersion.v2, + registries: { + 'npm-npmjs': { + 'type': 'npm-registry', + 'url': 'https://registry.npmjs.org', + 'username': 'octocat', + 'password': '1234', + }, + }, + updates: [ + UpdateEntry( + directory: '/', + ecosystem: 'pub', + schedule: Schedule( + interval: ScheduleInterval.weekly, + day: ScheduleDay.monday, + time: '06:00', + timezone: 'America/New_York', + ), + allow: [ + AllowDependencyType(dependencyType: 'direct'), + AllowDependency(name: 'flame_*'), + ], + assignees: {'renancaraujo'}, + commitMessage: CommitMessage( + prefix: 'chore(deps):', + prefixDevelopment: 'chore(deps-dev):', + include: 'scope', + ), + ignore: [ + Ignore( + dependencyName: 'flutter', + versions: ['1.2.3'], + updateTypes: [ + UpdateType.major, + UpdateType.minor, + UpdateType.patch, + ], + ), + ], + insecureExternalCodeExecution: + InsecureExternalCodeExecution.allow, + labels: {'dependencies'}, + milestone: 4, + openPullRequestsLimit: 6, + pullRequestBranchName: PullRequestBranchName(separator: '--'), + rebaseStrategy: RebaseStrategy.auto, + reviewers: ['renancaraujo'], + targetBranch: 'develop', + vendor: true, + versioningStrategy: VersioningStrategy.increase, + ), + ], + enableBetaEcosystems: true, + ); + + expect(spec, encodesTo(_kCompleteJson)); + }); + + test('required only', () { + const spec = DependabotSpec( + version: DependabotVersion.v2, + updates: [ + UpdateEntry( + directory: '/', + ecosystem: 'pub', + schedule: Schedule(interval: ScheduleInterval.monthly), + ), + ], + ); + + expect(spec, encodesTo(_kRequiredOnlyJson)); + }); + }); + + group('from json', () { + test('all values', () { + expect( + _kCompleteJson, + decodesTo( + const DependabotSpec( + version: DependabotVersion.v2, + updates: [ + UpdateEntry( + directory: '/', + ecosystem: 'pub', + schedule: Schedule( + interval: ScheduleInterval.weekly, + day: ScheduleDay.monday, + time: '06:00', + timezone: 'America/New_York', + ), + allow: [ + AllowDependencyType(dependencyType: 'direct'), + AllowDependency(name: 'flame_*'), + ], + assignees: {'renancaraujo'}, + commitMessage: CommitMessage( + prefix: 'chore(deps):', + prefixDevelopment: 'chore(deps-dev):', + include: 'scope', + ), + ignore: [ + Ignore( + dependencyName: 'flutter', + versions: ['1.2.3'], + updateTypes: [ + UpdateType.major, + UpdateType.minor, + UpdateType.patch, + ], + ), + ], + insecureExternalCodeExecution: + InsecureExternalCodeExecution.allow, + labels: {'dependencies'}, + milestone: 4, + openPullRequestsLimit: 6, + pullRequestBranchName: PullRequestBranchName(separator: '--'), + rebaseStrategy: RebaseStrategy.auto, + reviewers: ['renancaraujo'], + targetBranch: 'develop', + vendor: true, + versioningStrategy: VersioningStrategy.increase, + ), + ], + enableBetaEcosystems: true, + ), + ), + ); + }); + + test('required only', () { + expect( + _kRequiredOnlyJson, + decodesTo( + const DependabotSpec( + version: DependabotVersion.v2, + updates: [ + UpdateEntry( + directory: '/', + ecosystem: 'pub', + schedule: Schedule(interval: ScheduleInterval.monthly), + ), + ], + ), + ), + ); + }); + + test('invalid "allow" entry', () { + expect( + () => DependabotSpec.fromJson( + jsonDecode(_kInvalidAllowedJson) as Map, + ), + throwsA(isA()), + ); + }); + }); + }); +} + +Matcher encodesTo(String json) => ToJsonMatcher(json); + +class ToJsonMatcher extends CustomMatcher { + ToJsonMatcher(String json) : super('JsonMatcher', 'json', equals(json)); + + @override + Object? featureValueOf(dynamic actual) { + const encoder = JsonEncoder.withIndent(' '); + if (actual is DependabotSpec) { + return encoder.convert(actual.toJson()); + } + throw ArgumentError.value(actual, 'actual', 'must be a DependabotSpec'); + } +} + +Matcher decodesTo(DependabotSpec spec) => FromJsonMatcher(spec); + +class FromJsonMatcher extends CustomMatcher { + FromJsonMatcher(DependabotSpec spec) + : super('JsonMatcher', 'json', equals(spec)); + + @override + Object? featureValueOf(dynamic actual) { + if (actual is String) { + return DependabotSpec.fromJson( + jsonDecode(actual) as Map, + ); + } + throw ArgumentError.value(actual, 'actual', 'must be a string'); + } +} diff --git a/test/src/package_finder/package_finder_test.dart b/test/src/package_finder/package_finder_test.dart new file mode 100644 index 0000000..44e961b --- /dev/null +++ b/test/src/package_finder/package_finder_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; + +void main() { + group('', () { + test('', () { + expect(true, true); + }); + }); +} diff --git a/test/utils.dart b/test/utils.dart new file mode 100644 index 0000000..39b1cc2 --- /dev/null +++ b/test/utils.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +File createFile(String content, [String name = 'dependabot.yaml']) { + return File( + p.join(Directory.systemTemp.absolute.path, name), + )..writeAsStringSync(content); +} + +Directory prepareFixture(List fixturePath) { + final currentDir = Directory( + p.join('test', 'fixtures', p.joinAll(fixturePath)), + ); + assert( + currentDir.existsSync(), + 'Fixture does not exist: ${currentDir.absolute}', + ); + final sisDir = Directory.systemTemp.createTempSync(fixturePath.join('_')); + + /// recursively copy everything from current to sis + for (final entity in currentDir.listSync(recursive: true)) { + final relative = p.relative(entity.path, from: currentDir.path); + final destination = p.join(sisDir.path, relative); + if (entity is Directory) { + Directory(destination).createSync(recursive: true); + } else if (entity is File) { + File(destination).writeAsBytesSync(entity.readAsBytesSync()); + } else { + throw UnsupportedError('Unsupported entity: $entity'); + } + } + + return sisDir; +}