From 0587b3410b6be4195a9c7bc4549506c2423172b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Fern=C3=A1ndez?= Date: Tue, 17 Mar 2026 18:47:02 +0100 Subject: [PATCH 1/2] bootstrap mise ecosystem --- .github/ci-filters.yml | 3 + .github/issue-labeler.yml | 3 + .github/smoke-filters.yml | 3 + .github/smoke-matrix.json | 5 + .github/workflows/ci.yml | 1 + .github/workflows/images-branch.yml | 1 + .github/workflows/images-latest.yml | 1 + Dockerfile.updater-core | 2 +- Gemfile | 1 + Gemfile.lock | 8 + bin/docker-dev-shell | 6 + bin/dry-run.rb | 4 + common/lib/dependabot/config/file.rb | 1 + mise/.bundle/config | 1 + mise/.gitignore | 4 + mise/.rubocop.yml | 1 + mise/Dockerfile | 15 ++ mise/README.md | 18 ++ mise/dependabot-mise.gemspec | 35 +++ mise/lib/dependabot/mise.rb | 22 ++ mise/lib/dependabot/mise/file_fetcher.rb | 48 ++++ mise/lib/dependabot/mise/file_parser.rb | 85 +++++++ mise/lib/dependabot/mise/file_updater.rb | 88 +++++++ mise/lib/dependabot/mise/helpers.rb | 20 ++ mise/lib/dependabot/mise/metadata_finder.rb | 24 ++ mise/lib/dependabot/mise/requirement.rb | 52 ++++ mise/lib/dependabot/mise/update_checker.rb | 66 +++++ .../update_checker/latest_version_finder.rb | 74 ++++++ mise/lib/dependabot/mise/version.rb | 34 +++ mise/script/build | 5 + mise/script/ci-test | 6 + .../spec/dependabot/mise/file_fetcher_spec.rb | 54 ++++ mise/spec/dependabot/mise/file_parser_spec.rb | 136 +++++++++++ .../spec/dependabot/mise/file_updater_spec.rb | 230 ++++++++++++++++++ .../dependabot/mise/update_checker_spec.rb | 229 +++++++++++++++++ mise/spec/fixtures/mise_toml/no_tools.toml | 2 + mise/spec/fixtures/mise_toml/simple.toml | 4 + mise/spec/spec_helper.rb | 12 + omnibus/dependabot-omnibus.gemspec | 1 + omnibus/lib/dependabot/omnibus.rb | 1 + rakelib/support/helpers.rb | 1 + script/dependabot | 1 + updater/Gemfile | 1 + updater/Gemfile.lock | 8 + updater/lib/dependabot/setup.rb | 2 + 45 files changed, 1318 insertions(+), 1 deletion(-) create mode 100644 mise/.bundle/config create mode 100644 mise/.gitignore create mode 100644 mise/.rubocop.yml create mode 100644 mise/Dockerfile create mode 100644 mise/README.md create mode 100644 mise/dependabot-mise.gemspec create mode 100644 mise/lib/dependabot/mise.rb create mode 100644 mise/lib/dependabot/mise/file_fetcher.rb create mode 100644 mise/lib/dependabot/mise/file_parser.rb create mode 100644 mise/lib/dependabot/mise/file_updater.rb create mode 100644 mise/lib/dependabot/mise/helpers.rb create mode 100644 mise/lib/dependabot/mise/metadata_finder.rb create mode 100644 mise/lib/dependabot/mise/requirement.rb create mode 100644 mise/lib/dependabot/mise/update_checker.rb create mode 100644 mise/lib/dependabot/mise/update_checker/latest_version_finder.rb create mode 100644 mise/lib/dependabot/mise/version.rb create mode 100755 mise/script/build create mode 100755 mise/script/ci-test create mode 100644 mise/spec/dependabot/mise/file_fetcher_spec.rb create mode 100644 mise/spec/dependabot/mise/file_parser_spec.rb create mode 100644 mise/spec/dependabot/mise/file_updater_spec.rb create mode 100644 mise/spec/dependabot/mise/update_checker_spec.rb create mode 100644 mise/spec/fixtures/mise_toml/no_tools.toml create mode 100644 mise/spec/fixtures/mise_toml/simple.toml create mode 100644 mise/spec/spec_helper.rb diff --git a/.github/ci-filters.yml b/.github/ci-filters.yml index 5ec71d9c564..43873ea335c 100644 --- a/.github/ci-filters.yml +++ b/.github/ci-filters.yml @@ -71,6 +71,9 @@ julia: maven: - *shared - 'maven/**' +mise: + - *shared + - 'mise/**' nix: - *shared - 'nix/**' diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index 0933fdfc14c..15ceb4aead2 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -87,3 +87,6 @@ "L: nix": - '(nix)' + +"L: mise": + - '(mise)' diff --git a/.github/smoke-filters.yml b/.github/smoke-filters.yml index 12b83cf2d70..b5c7a3f32e5 100644 --- a/.github/smoke-filters.yml +++ b/.github/smoke-filters.yml @@ -47,6 +47,9 @@ hex: maven: - *common - 'maven/**' +mise: + - *common + - 'mise/**' nix: - *common - 'nix/**' diff --git a/.github/smoke-matrix.json b/.github/smoke-matrix.json index 40c57227dd2..5936c9d6c82 100644 --- a/.github/smoke-matrix.json +++ b/.github/smoke-matrix.json @@ -79,6 +79,11 @@ "test": "maven", "ecosystem": "maven" }, + { + "core": "mise", + "test": "mise", + "ecosystem": "mise" + }, { "core": "nix", "test": "nix", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20df061c9af..2b55affe776 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - { path: hex, name: hex, ecosystem: mix } - { path: julia, name: julia, ecosystem: julia } - { path: maven, name: maven, ecosystem: maven } + - { path: mise, name: mise, ecosystem: mise } - { path: nix, name: nix, ecosystem: nix } - { path: npm_and_yarn, name: npm_and_yarn, ecosystem: npm } - { path: nuget, name: nuget, ecosystem: nuget } diff --git a/.github/workflows/images-branch.yml b/.github/workflows/images-branch.yml index 6a388cb6b71..f382f50fb2c 100644 --- a/.github/workflows/images-branch.yml +++ b/.github/workflows/images-branch.yml @@ -81,6 +81,7 @@ jobs: - { name: hex, ecosystem: mix } - { name: julia, ecosystem: julia } - { name: maven, ecosystem: maven } + - { name: mise, ecosystem: mise } - { name: nix, ecosystem: nix } - { name: npm_and_yarn, ecosystem: npm } - { name: nuget, ecosystem: nuget } diff --git a/.github/workflows/images-latest.yml b/.github/workflows/images-latest.yml index 7b4e20c4d0e..fc3b26b2c8b 100644 --- a/.github/workflows/images-latest.yml +++ b/.github/workflows/images-latest.yml @@ -55,6 +55,7 @@ jobs: - { name: hex, ecosystem: mix } - { name: julia, ecosystem: julia } - { name: maven, ecosystem: maven } + - { name: mise, ecosystem: mise } - { name: nix, ecosystem: nix } - { name: npm_and_yarn, ecosystem: npm } - { name: nuget, ecosystem: nuget } diff --git a/Dockerfile.updater-core b/Dockerfile.updater-core index 06fe8f7c20a..fbfe250d9c2 100644 --- a/Dockerfile.updater-core +++ b/Dockerfile.updater-core @@ -162,7 +162,7 @@ COPY --chown=dependabot:dependabot updater/Gemfile updater/Gemfile.lock dependab COPY --chown=dependabot:dependabot --parents */.bundle */*.gemspec common/lib/dependabot.rb LICENSE omnibus $DEPENDABOT_HOME # This ARG must be updated when adding/removing ecosystems - it invalidates Docker layer cache -ARG ECOSYSTEM_LIST="bazel bun bundler cargo composer conda devcontainers docker docker_compose dotnet_sdk elm git_submodules github_actions go_modules gradle helm hex julia maven nix npm_and_yarn nuget opentofu pre_commit pub python rust_toolchain silent swift terraform uv vcpkg" +ARG ECOSYSTEM_LIST="bazel bun bundler cargo composer conda devcontainers docker docker_compose dotnet_sdk elm git_submodules github_actions go_modules gradle helm hex julia maven mise nix npm_and_yarn nuget opentofu pre_commit pub python rust_toolchain silent swift terraform uv vcpkg" # prevent having all the source in every ecosystem image RUN for ecosystem in $ECOSYSTEM_LIST; do \ mkdir -p $ecosystem/lib/dependabot; \ diff --git a/Gemfile b/Gemfile index 6ec83483863..9a380b36e0d 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "dependabot-helm", path: "helm" gem "dependabot-hex", path: "hex" gem "dependabot-julia", path: "julia" gem "dependabot-maven", path: "maven" +gem "dependabot-mise", path: "mise" gem "dependabot-npm_and_yarn", path: "npm_and_yarn" gem "dependabot-nuget", path: "nuget" gem "dependabot-opentofu", path: "opentofu" diff --git a/Gemfile.lock b/Gemfile.lock index d6a6c330bad..4bf076a3f62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -144,6 +144,12 @@ PATH dependabot-common (= 0.369.0) rexml (~> 3.4.1) +PATH + remote: mise + specs: + dependabot-mise (0.369.0) + dependabot-common (= 0.369.0) + PATH remote: npm_and_yarn specs: @@ -504,6 +510,7 @@ DEPENDENCIES dependabot-hex! dependabot-julia! dependabot-maven! + dependabot-mise! dependabot-npm_and_yarn! dependabot-nuget! dependabot-opentofu! @@ -572,6 +579,7 @@ CHECKSUMS dependabot-hex (0.369.0) dependabot-julia (0.369.0) dependabot-maven (0.369.0) + dependabot-mise (0.369.0) dependabot-npm_and_yarn (0.369.0) dependabot-nuget (0.369.0) dependabot-opentofu (0.369.0) diff --git a/bin/docker-dev-shell b/bin/docker-dev-shell index 7918955443e..954b110c3d7 100755 --- a/bin/docker-dev-shell +++ b/bin/docker-dev-shell @@ -240,6 +240,12 @@ docker run --rm -ti \ -v "$(pwd)/maven/lib:$CODE_DIR/maven/lib" \ -v "$(pwd)/maven/script:$CODE_DIR/maven/script" \ -v "$(pwd)/maven/spec:$CODE_DIR/maven/spec" \ + -v "$(pwd)/mise/.rubocop.yml:$CODE_DIR/mise/.rubocop.yml" \ + -v "$(pwd)/mise/dependabot-mise.gemspec:$CODE_DIR/mise/dependabot-mise.gemspec" \ + -v "$(pwd)/mise/helpers:$CODE_DIR/mise/helpers" \ + -v "$(pwd)/mise/lib:$CODE_DIR/mise/lib" \ + -v "$(pwd)/mise/script:$CODE_DIR/mise/script" \ + -v "$(pwd)/mise/spec:$CODE_DIR/mise/spec" \ -v "$(pwd)/npm_and_yarn/.rubocop.yml:$CODE_DIR/npm_and_yarn/.rubocop.yml" \ -v "$(pwd)/npm_and_yarn/dependabot-npm_and_yarn.gemspec:$CODE_DIR/npm_and_yarn/dependabot-npm_and_yarn.gemspec" \ -v "$(pwd)/npm_and_yarn/helpers:$CODE_DIR/npm_and_yarn/helpers" \ diff --git a/bin/dry-run.rb b/bin/dry-run.rb index 80296c394dd..c5ebf71448b 100755 --- a/bin/dry-run.rb +++ b/bin/dry-run.rb @@ -35,6 +35,7 @@ # - helm # - hex # - maven +# - mise # - npm_and_yarn # - nuget # - pip (includes pipenv) @@ -79,6 +80,7 @@ $LOAD_PATH << "./hex/lib" $LOAD_PATH << "./julia/lib" $LOAD_PATH << "./maven/lib" +$LOAD_PATH << "./mise/lib" $LOAD_PATH << "./nix/lib" $LOAD_PATH << "./npm_and_yarn/lib" $LOAD_PATH << "./nuget/lib" @@ -137,6 +139,7 @@ require "dependabot/hex" require "dependabot/julia" require "dependabot/maven" +require "dependabot/mise" require "dependabot/npm_and_yarn" require "dependabot/nuget" require "dependabot/pre_commit" @@ -380,6 +383,7 @@ helm hex maven + mise npm_and_yarn nuget pip diff --git a/common/lib/dependabot/config/file.rb b/common/lib/dependabot/config/file.rb index fe903ad31eb..3ff921d356b 100644 --- a/common/lib/dependabot/config/file.rb +++ b/common/lib/dependabot/config/file.rb @@ -78,6 +78,7 @@ def self.parse(config) "helm" => "helm", "julia" => "julia", "maven" => "maven", + "mise" => "mise", "mix" => "hex", "nix" => "nix", "npm" => "npm_and_yarn", diff --git a/mise/.bundle/config b/mise/.bundle/config new file mode 100644 index 00000000000..3faf5cfe5e6 --- /dev/null +++ b/mise/.bundle/config @@ -0,0 +1 @@ +BUNDLE_GEMFILE: "../dependabot-updater/Gemfile" diff --git a/mise/.gitignore b/mise/.gitignore new file mode 100644 index 00000000000..e8fae25a381 --- /dev/null +++ b/mise/.gitignore @@ -0,0 +1,4 @@ +/.bundle/* +!.bundle/config +/tmp +/dependabot-*.gem diff --git a/mise/.rubocop.yml b/mise/.rubocop.yml new file mode 100644 index 00000000000..fc2019d46a3 --- /dev/null +++ b/mise/.rubocop.yml @@ -0,0 +1 @@ +inherit_from: ../.rubocop.yml diff --git a/mise/Dockerfile b/mise/Dockerfile new file mode 100644 index 00000000000..8795fd8ed19 --- /dev/null +++ b/mise/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker.io/docker/dockerfile:1.20 +FROM ghcr.io/dependabot/dependabot-updater-core + +ARG MISE_VERSION=v2026.3.9 +RUN curl -fsSL https://github.com/jdx/mise/releases/download/${MISE_VERSION}/install.sh \ + | MISE_INSTALL_PATH=/usr/local/bin/mise sh + +USER dependabot + +# required by mise's npm backend +RUN MISE_YES=1 mise use -g node@lts + + +COPY --chown=dependabot:dependabot --parents mise common $DEPENDABOT_HOME/ +COPY --chown=dependabot:dependabot updater $DEPENDABOT_HOME/dependabot-updater diff --git a/mise/README.md b/mise/README.md new file mode 100644 index 00000000000..b452bf58056 --- /dev/null +++ b/mise/README.md @@ -0,0 +1,18 @@ +## `dependabot-mise` + +Mise support for [`dependabot-core`][core-repo]. + +### Running locally + +1. Start a development shell + + ``` + $ bin/docker-dev-shell mise + ``` + +2. Run tests + ``` + [dependabot-core-dev] ~ $ cd mise && rspec + ``` + +[core-repo]: https://github.com/dependabot/dependabot-core diff --git a/mise/dependabot-mise.gemspec b/mise/dependabot-mise.gemspec new file mode 100644 index 00000000000..6d20a666e7a --- /dev/null +++ b/mise/dependabot-mise.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + common_gemspec = + Bundler.load_gemspec_uncached("../common/dependabot-common.gemspec") + + spec.name = "dependabot-mise" + spec.summary = "Provides Dependabot support for mise" + spec.description = "Dependabot-mise provides support for bumping mise dependencies via Dependabot. " \ + "If you want support for multiple package managers, you probably want the meta-gem " \ + "dependabot-omnibus." + + spec.author = common_gemspec.author + spec.email = common_gemspec.email + spec.homepage = common_gemspec.homepage + spec.license = common_gemspec.license + + spec.metadata = { + "bug_tracker_uri" => common_gemspec.metadata["bug_tracker_uri"], + "changelog_uri" => common_gemspec.metadata["changelog_uri"] + } + + spec.version = common_gemspec.version + spec.required_ruby_version = common_gemspec.required_ruby_version + spec.required_rubygems_version = common_gemspec.required_ruby_version + + spec.require_path = "lib" + spec.files = Dir["lib/**/*"] + + spec.add_dependency "dependabot-common", Dependabot::VERSION + + common_gemspec.development_dependencies.each do |dep| + spec.add_development_dependency dep.name, *dep.requirement.as_list + end +end diff --git a/mise/lib/dependabot/mise.rb b/mise/lib/dependabot/mise.rb new file mode 100644 index 00000000000..b3c8c610fef --- /dev/null +++ b/mise/lib/dependabot/mise.rb @@ -0,0 +1,22 @@ +# typed: strong +# frozen_string_literal: true + +# These all need to be required so the various classes can be registered in a +# lookup table of package manager names to concrete classes. +require "dependabot/mise/version" +require "dependabot/mise/requirement" +require "dependabot/mise/metadata_finder" +require "dependabot/mise/file_fetcher" +require "dependabot/mise/file_parser" +require "dependabot/mise/update_checker" +require "dependabot/mise/file_updater" + +# 8B2252 is used as vp-c-brand-1 in mise's official website +require "dependabot/pull_request_creator/labeler" +Dependabot::PullRequestCreator::Labeler + .register_label_details("mise", name: "mise", colour: "8B2252") + +require "dependabot/dependency" +Dependabot::Dependency.register_production_check("mise", ->(_) { true }) + +Dependabot::Utils.register_version_class("mise", Dependabot::Mise::Version) diff --git a/mise/lib/dependabot/mise/file_fetcher.rb b/mise/lib/dependabot/mise/file_fetcher.rb new file mode 100644 index 00000000000..568ca19ef69 --- /dev/null +++ b/mise/lib/dependabot/mise/file_fetcher.rb @@ -0,0 +1,48 @@ +# typed: strong +# frozen_string_literal: true + +require "dependabot/file_fetchers" +require "dependabot/file_fetchers/base" + +module Dependabot + module Mise + class FileFetcher < Dependabot::FileFetchers::Base + extend T::Sig + + MANIFEST_FILE = T.let("mise.toml", String) + + # NOTE: mise also supports .mise.toml, .config/mise.toml, and mise/config.toml + # as alternative config file locations. These are not currently supported. + + sig { override.returns(String) } + def self.required_files_message + "Repo must contain a mise.toml file." + end + + sig { override.params(filenames: T::Array[String]).returns(T::Boolean) } + def self.required_files_in?(filenames) + filenames.include?(MANIFEST_FILE) + end + + sig { override.returns(T::Array[DependencyFile]) } + def fetch_files + # Implement beta feature flag check + unless allow_beta_ecosystems? + raise Dependabot::DependencyFileNotFound.new( + nil, + "Mise support is currently in beta. Set ALLOW_BETA_ECOSYSTEMS=true to enable it." + ) + end + + [fetch_file_from_host(MANIFEST_FILE)] + end + + sig { override.returns(T.nilable(T::Hash[Symbol, T.untyped])) } + def ecosystem_versions + nil + end + end + end +end + +Dependabot::FileFetchers.register("mise", Dependabot::Mise::FileFetcher) diff --git a/mise/lib/dependabot/mise/file_parser.rb b/mise/lib/dependabot/mise/file_parser.rb new file mode 100644 index 00000000000..47c09d722a8 --- /dev/null +++ b/mise/lib/dependabot/mise/file_parser.rb @@ -0,0 +1,85 @@ +# typed: strict +# frozen_string_literal: true + +require "dependabot/dependency" +require "dependabot/file_parsers" +require "dependabot/file_parsers/base" +require "dependabot/mise/file_fetcher" +require "dependabot/mise/helpers" +require "dependabot/mise/version" +require "dependabot/shared_helpers" +require "json" + +module Dependabot + module Mise + class FileParser < Dependabot::FileParsers::Base + extend T::Sig + include Dependabot::Mise::Helpers + + sig { override.returns(T::Array[Dependabot::Dependency]) } + def parse + Dependabot::SharedHelpers.in_a_temporary_directory do + write_manifest_files(dependency_files) + + raw = Dependabot::SharedHelpers.run_shell_command( + "mise ls --current --local --json", + stderr_to_stdout: false, + env: { "MISE_YES" => "1" } + ) + + JSON.parse(raw).filter_map do |tool_name, entries| + entry = Array(entries).first + next unless entry + + requested = entry["requested_version"] + next unless requested + # Skip fuzzy pins like "latest" or "lts" — they have no specific version + # to compare against and would break version comparison in the base class. + next unless Dependabot::Mise::Version.correct?(requested) + + # `version` is what mise resolved (used for version comparison). + # `requested_version` is what's written in mise.toml (used by the file updater). + resolved = entry["version"] || requested + + build_dependency(tool_name, resolved, requested) + end + end + rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e + Dependabot.logger.warn("mise ls failed: #{e.message}") + [] + rescue JSON::ParserError => e + Dependabot.logger.warn("mise ls returned invalid JSON: #{e.message}") + [] + end + + private + + sig do + params(name: String, version: String, requirement: String) + .returns(Dependabot::Dependency) + end + def build_dependency(name, version, requirement) + Dependabot::Dependency.new( + name: name, + version: version, + package_manager: "mise", + requirements: [{ + requirement: requirement, + file: Dependabot::Mise::FileFetcher::MANIFEST_FILE, + groups: [], + source: nil + }] + ) + end + + sig { override.void } + def check_required_files + return if get_original_file(Dependabot::Mise::FileFetcher::MANIFEST_FILE) + + raise "No #{Dependabot::Mise::FileFetcher::MANIFEST_FILE} file found!" + end + end + end +end + +Dependabot::FileParsers.register("mise", Dependabot::Mise::FileParser) diff --git a/mise/lib/dependabot/mise/file_updater.rb b/mise/lib/dependabot/mise/file_updater.rb new file mode 100644 index 00000000000..3afc057f5de --- /dev/null +++ b/mise/lib/dependabot/mise/file_updater.rb @@ -0,0 +1,88 @@ +# typed: strict +# frozen_string_literal: true + +require "dependabot/file_updaters" +require "dependabot/file_updaters/base" +require "dependabot/mise/file_fetcher" +require "dependabot/mise/helpers" + +module Dependabot + module Mise + class FileUpdater < Dependabot::FileUpdaters::Base + extend T::Sig + include Dependabot::Mise::Helpers + + sig { override.returns(T::Array[Dependabot::DependencyFile]) } + def updated_dependency_files + updated_files = [] + + mise_toml = dependency_files.find { |f| f.name == Dependabot::Mise::FileFetcher::MANIFEST_FILE } + return updated_files unless mise_toml + + new_content = updated_mise_toml_content(mise_toml.content.to_s) + updated_files << updated_file(file: mise_toml, content: new_content) if new_content != mise_toml.content + + updated_files + end + + private + + sig { params(content: String).returns(String) } + def updated_mise_toml_content(content) + dependencies.each_with_object(content.dup) do |dep, updated_content| + updated_content.replace(update_dependency(updated_content, dep)) + end + end + + sig { params(content: String, dep: Dependabot::Dependency).returns(String) } + def update_dependency(content, dep) + tool = Regexp.escape(dep.name) + old_version = Regexp.escape(requested_version_for(dep)) + new_version = new_version_string_for(dep) + + # Handles plain keys: erlang = "27.3.2" + # Handles quoted keys: "npm:@redocly/cli" = "2.19.1" + content = content.gsub( + /^("#{tool}"|#{tool})\s*=\s*"#{old_version}"/, + "\\1 = \"#{new_version}\"" + ) + + # Handles inline table: python = { version = "3.11.0", virtualenv = ".venv" } + content = content.gsub( + /^("#{tool}"|#{tool})(\s*=\s*\{.*?version\s*=\s*)"#{old_version}"/, + "\\1\\2\"#{new_version}\"" + ) + + # Handles table header: [tools.golang] + # version = "1.18" + content.gsub( + /(\[tools\.#{tool}\][^\[]*version\s*=\s*)"#{old_version}"/m, + "\\1\"#{new_version}\"" + ) + end + + sig { params(dep: Dependabot::Dependency).returns(String) } + def requested_version_for(dep) + T.must(dep.previous_requirements) + .filter_map { |r| r[:requirement] } + .first || dep.previous_version.to_s + end + + sig { params(dep: Dependabot::Dependency).returns(String) } + def new_version_string_for(dep) + dep.requirements + .filter_map { |r| r[:requirement] } + .first || dep.version.to_s + end + + sig { override.void } + def check_required_files + return if get_original_file(Dependabot::Mise::FileFetcher::MANIFEST_FILE) + + raise "No #{Dependabot::Mise::FileFetcher::MANIFEST_FILE} file found!" + end + end + end +end + +Dependabot::FileUpdaters.register("mise", Dependabot::Mise::FileUpdater) diff --git a/mise/lib/dependabot/mise/helpers.rb b/mise/lib/dependabot/mise/helpers.rb new file mode 100644 index 00000000000..d8fea8ff893 --- /dev/null +++ b/mise/lib/dependabot/mise/helpers.rb @@ -0,0 +1,20 @@ +# typed: strong +# frozen_string_literal: true + +module Dependabot + module Mise + module Helpers + extend T::Sig + + private + + # Writes all fetched dependency files to the current working directory. + # Used inside SharedHelpers.in_a_temporary_directory blocks before + # shelling out to mise CLI commands. + sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void } + def write_manifest_files(dependency_files) + dependency_files.each { |f| File.write(f.name, f.content) } + end + end + end +end diff --git a/mise/lib/dependabot/mise/metadata_finder.rb b/mise/lib/dependabot/mise/metadata_finder.rb new file mode 100644 index 00000000000..ca7f7928e34 --- /dev/null +++ b/mise/lib/dependabot/mise/metadata_finder.rb @@ -0,0 +1,24 @@ +# typed: strong +# frozen_string_literal: true + +require "dependabot/metadata_finders" +require "dependabot/metadata_finders/base" + +module Dependabot + module Mise + class MetadataFinder < Dependabot::MetadataFinders::Base + extend T::Sig + + private + + sig { override.returns(T.nilable(Dependabot::Source)) } + def look_up_source + # Mise tools can come from various sources (GitHub, npm, cargo, etc.) + # We don't attempt to look up sources automatically + nil + end + end + end +end + +Dependabot::MetadataFinders.register("mise", Dependabot::Mise::MetadataFinder) diff --git a/mise/lib/dependabot/mise/requirement.rb b/mise/lib/dependabot/mise/requirement.rb new file mode 100644 index 00000000000..dd69b84eb21 --- /dev/null +++ b/mise/lib/dependabot/mise/requirement.rb @@ -0,0 +1,52 @@ +# typed: strong +# frozen_string_literal: true + +require "dependabot/requirement" +require "dependabot/utils" +require "wildcard_matcher" + +module Dependabot + module Mise + class Requirement < Dependabot::Requirement + extend T::Sig + + WILDCARD_PATTERN = /\*/ + + sig do + override + .params(requirement_string: T.nilable(String)) + .returns(T::Array[Requirement]) + end + def self.requirements_array(requirement_string) + [new(requirement_string)] + end + + sig { params(requirements: T.nilable(String)).void } + def initialize(*requirements) + requirements = requirements.flatten.compact.flat_map { |r| r.split(",").map(&:strip) } + + @wildcard_patterns = T.let( + requirements.select { |r| r.match?(WILDCARD_PATTERN) }, + T::Array[String] + ) + + normal = requirements.reject { |r| r.match?(WILDCARD_PATTERN) } + # When all patterns are wildcards, pass a placeholder requirement so that + # Gem::Requirement doesn't fall back to a catch-all ">= 0". satisfied_by? + # only delegates to super when there are no wildcard patterns, so this + # placeholder is never evaluated for wildcard-only requirements. + super(normal.empty? ? ["!= 0"] : normal) + end + + sig { override.params(version: Gem::Version).returns(T::Boolean) } + def satisfied_by?(version) + version_string = version.to_s + result = @wildcard_patterns.any? { |p| WildcardMatcher.match?(p, version_string) } || + (@wildcard_patterns.empty? && super) + T.cast(result, T::Boolean) + end + end + end +end + +Dependabot::Utils.register_requirement_class("mise", Dependabot::Mise::Requirement) diff --git a/mise/lib/dependabot/mise/update_checker.rb b/mise/lib/dependabot/mise/update_checker.rb new file mode 100644 index 00000000000..01b63608a0f --- /dev/null +++ b/mise/lib/dependabot/mise/update_checker.rb @@ -0,0 +1,66 @@ +# typed: strong +# frozen_string_literal: true + +require "dependabot/update_checkers" +require "dependabot/update_checkers/base" + +module Dependabot + module Mise + class UpdateChecker < Dependabot::UpdateCheckers::Base + extend T::Sig + + sig { override.returns(T.nilable(T.any(String, Gem::Version))) } + def latest_version + @latest_version ||= T.let( + LatestVersionFinder.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + ignored_versions: ignored_versions, + raise_on_ignored: raise_on_ignored, + security_advisories: security_advisories, + cooldown_options: update_cooldown + ).latest_version, + T.nilable(T.any(String, Gem::Version)) + ) + end + + sig { override.returns(T.nilable(T.any(String, Gem::Version))) } + def latest_resolvable_version + latest_version + end + + sig { override.returns(T.nilable(String)) } + def latest_resolvable_version_with_no_unlock + # mise has no lockfile; the version pinned in mise.toml IS the resolved version. + # "No unlock" means staying at the current pin. + dependency.version + end + + sig { override.returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def updated_requirements + return dependency.requirements if latest_version.nil? + + dependency.requirements.map do |req| + req.merge(requirement: latest_version.to_s) + end + end + + private + + sig { override.returns(T::Boolean) } + def latest_version_resolvable_with_full_unlock? + false + end + + sig { override.returns(T::Array[Dependabot::Dependency]) } + def updated_dependencies_after_full_unlock + [] + end + end + end +end + +Dependabot::UpdateCheckers.register("mise", Dependabot::Mise::UpdateChecker) + +require_relative "update_checker/latest_version_finder" diff --git a/mise/lib/dependabot/mise/update_checker/latest_version_finder.rb b/mise/lib/dependabot/mise/update_checker/latest_version_finder.rb new file mode 100644 index 00000000000..8fff194a5a4 --- /dev/null +++ b/mise/lib/dependabot/mise/update_checker/latest_version_finder.rb @@ -0,0 +1,74 @@ +# typed: strict +# frozen_string_literal: true + +require "dependabot/package/package_latest_version_finder" +require "dependabot/mise/helpers" +require "dependabot/mise/requirement" +require "dependabot/mise/version" +require "dependabot/shared_helpers" +require "json" + +module Dependabot + module Mise + class UpdateChecker + class LatestVersionFinder < Dependabot::Package::PackageLatestVersionFinder + extend T::Sig + include Dependabot::Mise::Helpers + + # Not used — we bypass the parent's package_details/available_versions flow. + sig { override.returns(T.nilable(Dependabot::Package::PackageDetails)) } + def package_details; end + + # We override latest_version rather than relying on the parent's fetch_latest_release + # because the parent's filter_prerelease_versions would incorrectly discard mise version + # strings like "1.18.4-otp-27": Gem::Version treats non-numeric segments as pre-release. + sig { override.params(language_version: T.nilable(T.any(Dependabot::Version, String))).returns(T.nilable(Dependabot::Version)) } + def latest_version(language_version: nil) # rubocop:disable Lint/UnusedMethodArgument + releases = package_releases + return nil if releases.nil? || releases.empty? + + releases = filter_ignored_versions(releases) + releases = filter_by_cooldown(releases) + releases.max_by(&:version)&.version + end + + private + + sig { returns(T.nilable(T::Array[Dependabot::Package::PackageRelease])) } + def package_releases + @package_releases ||= T.let( + fetch_releases, + T.nilable(T::Array[Dependabot::Package::PackageRelease]) + ) + end + + sig { returns(T.nilable(T::Array[Dependabot::Package::PackageRelease])) } + def fetch_releases + Dependabot::SharedHelpers.in_a_temporary_directory do + write_manifest_files(dependency_files) + + raw = Dependabot::SharedHelpers.run_shell_command( + "mise ls-remote --json #{dependency.name}", + stderr_to_stdout: false, + env: { "MISE_YES" => "1" } + ) + + JSON.parse(raw).filter_map do |entry| + version = entry["version"] + next unless version + + released_at = entry["created_at"] ? Time.parse(entry["created_at"]) : nil + + Dependabot::Package::PackageRelease.new( + version: Dependabot::Mise::Version.new(version), + released_at: released_at + ) + end + end + rescue StandardError + nil + end + end + end + end +end diff --git a/mise/lib/dependabot/mise/version.rb b/mise/lib/dependabot/mise/version.rb new file mode 100644 index 00000000000..f646663f7ba --- /dev/null +++ b/mise/lib/dependabot/mise/version.rb @@ -0,0 +1,34 @@ +# typed: strong +# frozen_string_literal: true + +require "dependabot/version" +require "dependabot/utils" + +module Dependabot + module Mise + class Version < Dependabot::Version + extend T::Sig + + sig { override.params(version: T.nilable(T.any(String, Integer, ::Gem::Version))).void } + def initialize(version) + @version_string = T.let(version.to_s, String) + super + end + + # Preserve the original version string. Gem::Version may normalize version + # segments internally, which would break round-trip fidelity when the file + # updater writes the version back to mise.toml. + sig { returns(String) } + def to_s + @version_string + end + + sig { returns(String) } + def inspect + "#" + end + end + end +end + +Dependabot::Utils.register_version_class("mise", Dependabot::Mise::Version) diff --git a/mise/script/build b/mise/script/build new file mode 100755 index 00000000000..4a573ca4ffa --- /dev/null +++ b/mise/script/build @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e + +echo "No native helpers to build for mise" diff --git a/mise/script/ci-test b/mise/script/ci-test new file mode 100755 index 00000000000..a9cf3203e11 --- /dev/null +++ b/mise/script/ci-test @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +bundle install +bundle exec turbo_tests --verbose diff --git a/mise/spec/dependabot/mise/file_fetcher_spec.rb b/mise/spec/dependabot/mise/file_fetcher_spec.rb new file mode 100644 index 00000000000..833e62e89b5 --- /dev/null +++ b/mise/spec/dependabot/mise/file_fetcher_spec.rb @@ -0,0 +1,54 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/mise/file_fetcher" +require_common_spec "file_fetchers/shared_examples_for_file_fetchers" + +RSpec.describe Dependabot::Mise::FileFetcher do + let(:credentials) do + [{ + "type" => "git_source", + "host" => "github.com", + "username" => "x-access-token", + "password" => "token" + }] + end + let(:url) { github_url + "repos/example/repo/contents/" } + let(:github_url) { "https://api.github.com/" } + let(:directory) { "/" } + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "example/repo", + directory: directory + ) + end + let(:file_fetcher_instance) do + described_class.new( + source: source, + credentials: credentials, + repo_contents_path: nil + ) + end + + before { allow(file_fetcher_instance).to receive(:commit).and_return("sha") } + + it_behaves_like "a dependency file fetcher" + + describe ".required_files_in?" do + it "returns true when mise.toml is present" do + expect(described_class.required_files_in?(["mise.toml"])).to be(true) + end + + it "returns false when mise.toml is absent" do + expect(described_class.required_files_in?(["README.md"])).to be(false) + end + end + + describe ".required_files_message" do + it "returns a helpful message" do + expect(described_class.required_files_message).to eq("Repo must contain a mise.toml file.") + end + end +end diff --git a/mise/spec/dependabot/mise/file_parser_spec.rb b/mise/spec/dependabot/mise/file_parser_spec.rb new file mode 100644 index 00000000000..0f1d66e5374 --- /dev/null +++ b/mise/spec/dependabot/mise/file_parser_spec.rb @@ -0,0 +1,136 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency_file" +require "dependabot/mise/file_parser" +require_common_spec "file_parsers/shared_examples_for_file_parsers" + +RSpec.describe Dependabot::Mise::FileParser do + subject(:parser) do + described_class.new( + dependency_files: dependency_files, + source: source + ) + end + + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "example/mise-project", + directory: "/" + ) + end + + let(:dependency_files) { [mise_toml] } + + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: fixture("mise_toml/simple.toml") + ) + end + + it_behaves_like "a dependency file parser" + + describe "#parse" do + subject(:dependencies) { parser.parse } + + context "with simple exact versions" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .and_return(JSON.dump( + { + "erlang" => [{ "requested_version" => "27.3.2", "version" => "27.3.2" }], + "elixir" => [{ "requested_version" => "1.18.4-otp-27", "version" => "1.18.4-otp-27" }], + "helm" => [{ "requested_version" => "3.17.3", "version" => "3.17.3" }] + } + )) + end + + it "parses tool names correctly" do + expect(dependencies.map(&:name)).to contain_exactly("erlang", "elixir", "helm") + end + + it "parses versions correctly" do + versions = dependencies.to_h { |d| [d.name, d.version] } + expect(versions).to eq( + "erlang" => "27.3.2", + "elixir" => "1.18.4-otp-27", + "helm" => "3.17.3" + ) + end + end + + context "with no tools section" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: fixture("mise_toml/no_tools.toml") + ) + end + + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .and_return(JSON.dump({})) + end + + it "returns no dependencies" do + expect(dependencies).to be_empty + end + end + + context "when mise ls returns invalid JSON" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .and_return("{") + end + + it "returns no dependencies" do + expect(dependencies).to eq([]) + end + end + + context "with mixed version formats" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .and_return(JSON.dump( + { + "node" => [{ "requested_version" => "20", "version" => "20.20.2" }], + "erlang" => [{ "requested_version" => "27.3.2", "version" => "27.3.2" }], + "npm:@redocly/cli" => [{ "requested_version" => "2.19.1", "version" => "2.19.1" }], + "python" => [{ "requested_version" => "latest", "version" => "3.14.3" }], + "helm" => [{ "requested_version" => "3.17.3", "version" => "3.17.3" }], + "ruby" => [{ "requested_version" => "3.3.0", "version" => "3.3.0" }], + "go" => [{ "requested_version" => "1.18", "version" => "1.18" }] + } + )) + end + + it "returns only tools with parseable version strings" do + expect(dependencies.map(&:name)).to contain_exactly( + "erlang", + "helm", + "npm:@redocly/cli", + "node", + "ruby", + "go" + ) + end + + it "skips tools pinned to fuzzy aliases like 'latest'" do + expect(dependencies.map(&:name)).not_to include("python") + end + + it "uses the resolved version and keeps the partial pin as the requirement" do + node = dependencies.find { |d| d.name == "node" } + expect(node.version).to eq("20.20.2") + expect(node.requirements.first[:requirement]).to eq("20") + end + end + end +end diff --git a/mise/spec/dependabot/mise/file_updater_spec.rb b/mise/spec/dependabot/mise/file_updater_spec.rb new file mode 100644 index 00000000000..b2b6643649c --- /dev/null +++ b/mise/spec/dependabot/mise/file_updater_spec.rb @@ -0,0 +1,230 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/mise/file_updater" +require_common_spec "file_updaters/shared_examples_for_file_updaters" + +RSpec.describe Dependabot::Mise::FileUpdater do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + erlang = "27.3.2" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "erlang", + version: "28.0.0", + previous_version: "27.3.2", + package_manager: "mise", + requirements: [{ + requirement: "28.0.0", + file: "mise.toml", + groups: [], + source: nil + }], + previous_requirements: [{ + requirement: "27.3.2", + file: "mise.toml", + groups: [], + source: nil + }] + ) + end + + let(:updater) do + described_class.new( + dependency_files: [mise_toml], + dependencies: [dependency], + credentials: [] + ) + end + + it_behaves_like "a dependency file updater" + + describe "#updated_dependency_files" do + subject(:updated_files) { updater.updated_dependency_files } + + it "returns one updated file" do + expect(updated_files.length).to eq(1) + end + + it "updates the version in mise.toml" do + expect(updated_files.first.content).to include('erlang = "28.0.0"') + end + + it "does not include the old version" do + expect(updated_files.first.content).not_to include('erlang = "27.3.2"') + end + + context "with quoted key (npm scoped package)" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + "npm:@redocly/cli" = "2.19.1" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "npm:@redocly/cli", + version: "2.20.0", + previous_version: "2.19.1", + package_manager: "mise", + requirements: [{ requirement: "2.20.0", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "2.19.1", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the version in a quoted key entry" do + expect(updated_files.first.content).to include('"npm:@redocly/cli" = "2.20.0"') + end + end + + context "with inline table format" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + ruby = { version = "3.3.0", virtualenv = ".venv" } + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "ruby", + version: "3.4.0", + previous_version: "3.3.0", + package_manager: "mise", + requirements: [{ requirement: "3.4.0", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "3.3.0", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the version inside the inline table" do + expect(updated_files.first.content) + .to include('ruby = { version = "3.4.0", virtualenv = ".venv" }') + end + end + + context "with table header format" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools.golang] + version = "1.18" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "golang", + version: "1.22.0", + previous_version: "1.18", + package_manager: "mise", + requirements: [{ requirement: "1.22.0", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "1.18", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the version in a table header entry" do + expect(updated_files.first.content).to include("[tools.golang]\nversion = \"1.22.0\"") + end + end + + context "with a fuzzy version pin" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + node = "20" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "node", + version: "22", + previous_version: "20", + package_manager: "mise", + requirements: [{ requirement: "22", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "20", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the fuzzy version pin" do + expect(updated_files.first.content).to include('node = "22"') + end + end + + context "with inline table format where version is not first" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + python = { virtualenv = ".venv", version = "3.11.0" } + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "python", + version: "3.12.0", + previous_version: "3.11.0", + package_manager: "mise", + requirements: [{ requirement: "3.12.0", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "3.11.0", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the version inside the inline table" do + expect(updated_files.first.content) + .to include('python = { virtualenv = ".venv", version = "3.12.0" }') + end + end + + context "with table header format where version is not first" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools.golang] + env = "production" + version = "1.18" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "golang", + version: "1.22.0", + previous_version: "1.18", + package_manager: "mise", + requirements: [{ requirement: "1.22.0", file: "mise.toml", groups: [], source: nil }], + previous_requirements: [{ requirement: "1.18", file: "mise.toml", groups: [], source: nil }] + ) + end + + it "updates the version in the table header entry" do + expect(updated_files.first.content).to include("[tools.golang]\nenv = \"production\"\nversion = \"1.22.0\"") + end + end + end +end diff --git a/mise/spec/dependabot/mise/update_checker_spec.rb b/mise/spec/dependabot/mise/update_checker_spec.rb new file mode 100644 index 00000000000..9f8689e3701 --- /dev/null +++ b/mise/spec/dependabot/mise/update_checker_spec.rb @@ -0,0 +1,229 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/mise/update_checker" +require_common_spec "update_checkers/shared_examples_for_update_checkers" + +RSpec.describe Dependabot::Mise::UpdateChecker do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + erlang = "27.3.2" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "erlang", + version: "27.3.2", + package_manager: "mise", + requirements: [{ + requirement: "27.3.2", + file: "mise.toml", + groups: [], + source: nil + }] + ) + end + + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: [mise_toml], + credentials: [], + ignored_versions: [], + raise_on_ignored: false + ) + end + + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return(JSON.dump( + [ + { "version" => "27.3.2", "created_at" => "2026-01-13T20:06:42.23Z", "rolling" => false }, + { "version" => "28.4.1", "created_at" => "2026-03-12T19:32:23.78Z", "rolling" => false } + ] + )) + end + + it_behaves_like "an update checker" + + describe "#latest_version" do + it "returns the latest version from mise ls-remote" do + expect(checker.latest_version).to eq("28.4.1") + end + end + + describe "#latest_resolvable_version" do + it "returns the same as latest_version" do + expect(checker.latest_resolvable_version).to eq(checker.latest_version) + end + end + + describe "#updated_requirements" do + it "updates the requirement to the latest version" do + expect(checker.updated_requirements.first[:requirement]).to eq("28.4.1") + end + end + + context "when mise ls-remote returns empty" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return("[]") + end + + describe "#latest_version" do + it "returns nil" do + expect(checker.latest_version).to be_nil + end + end + + describe "#updated_requirements" do + it "returns the original requirements unchanged" do + expect(checker.updated_requirements).to eq(dependency.requirements) + end + end + end + + context "when mise ls-remote fails" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_raise(Dependabot::SharedHelpers::HelperSubprocessFailed.new( + message: "mise not found", + error_context: {} + )) + end + + describe "#latest_version" do + it "returns nil gracefully" do + expect(checker.latest_version).to be_nil + end + end + end + + context "when dependency has a fuzzy version pin" do + let(:dependency) do + Dependabot::Dependency.new( + name: "node", + version: "latest", + package_manager: "mise", + requirements: [{ + requirement: "latest", + file: "mise.toml", + groups: [], + source: nil + }] + ) + end + + describe "#latest_version" do + it "returns the latest available version" do + expect(checker.latest_version).to eq "28.4.1" + end + end + end + + context "with cooldown options" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: [mise_toml], + credentials: [], + ignored_versions: [], + raise_on_ignored: false, + update_cooldown: Dependabot::Package::ReleaseCooldownOptions.new( + default_days: 30 + ) + ) + end + + context "when latest version was released within the cooldown period" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return(JSON.dump( + [ + { "version" => "27.3.2", "created_at" => "2026-01-13T20:06:42.23Z", "rolling" => false }, + { "version" => "28.4.1", "created_at" => Time.now.utc.iso8601, "rolling" => false } + ] + )) + end + + it "returns the previous version instead of the latest" do + expect(checker.latest_version).to eq("27.3.2") + end + end + + context "when latest version was released outside the cooldown period" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return(JSON.dump( + [ + { "version" => "27.3.2", "created_at" => "2026-01-13T20:06:42.23Z", "rolling" => false }, + { "version" => "28.4.1", "created_at" => "2026-01-13T20:06:42.23Z", "rolling" => false } + ] + )) + end + + it "returns the latest version" do + expect(checker.latest_version.to_s).to eq("28.4.1") + end + end + + context "when created_at is missing" do + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return(JSON.dump( + [ + { "version" => "27.3.2", "rolling" => false }, + { "version" => "28.4.1", "rolling" => false } + ] + )) + end + + it "returns the latest version ignoring cooldown" do + expect(checker.latest_version.to_s).to eq("28.4.1") + end + end + + context "when versions include release candidates filtered via ignored_versions" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: [mise_toml], + credentials: [], + ignored_versions: ["*rc*"], + raise_on_ignored: false + ) + end + + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/mise ls-remote/, stderr_to_stdout: false, env: { "MISE_YES" => "1" }) + .and_return(JSON.dump( + [ + { "version" => "27.3.2", "created_at" => "2026-01-13T20:06:42.23Z", + "rolling" => false }, + { "version" => "28.0-rc1", "created_at" => "2026-03-01T00:00:00Z", + "rolling" => false }, + { "version" => "28.0-rc2", "created_at" => "2026-03-10T00:00:00Z", + "rolling" => false } + ] + )) + end + + it "does not suggest a release candidate as the latest version" do + expect(checker.latest_version.to_s).to eq("27.3.2") + end + end + end +end diff --git a/mise/spec/fixtures/mise_toml/no_tools.toml b/mise/spec/fixtures/mise_toml/no_tools.toml new file mode 100644 index 00000000000..bb53a366560 --- /dev/null +++ b/mise/spec/fixtures/mise_toml/no_tools.toml @@ -0,0 +1,2 @@ +[settings] +experimental = true \ No newline at end of file diff --git a/mise/spec/fixtures/mise_toml/simple.toml b/mise/spec/fixtures/mise_toml/simple.toml new file mode 100644 index 00000000000..98ab570386c --- /dev/null +++ b/mise/spec/fixtures/mise_toml/simple.toml @@ -0,0 +1,4 @@ +[tools] +erlang = "27.3.2" +elixir = "1.18.4-otp-27" +helm = "3.17.3" \ No newline at end of file diff --git a/mise/spec/spec_helper.rb b/mise/spec/spec_helper.rb new file mode 100644 index 00000000000..50d7c79d638 --- /dev/null +++ b/mise/spec/spec_helper.rb @@ -0,0 +1,12 @@ +# typed: true +# frozen_string_literal: true + +def common_dir + @common_dir ||= Gem::Specification.find_by_name("dependabot-common").gem_dir +end + +def require_common_spec(path) + require "#{common_dir}/spec/dependabot/#{path}" +end + +require "#{common_dir}/spec/spec_helper.rb" diff --git a/omnibus/dependabot-omnibus.gemspec b/omnibus/dependabot-omnibus.gemspec index 6f551e83d5e..fa8d339a9cc 100644 --- a/omnibus/dependabot-omnibus.gemspec +++ b/omnibus/dependabot-omnibus.gemspec @@ -46,6 +46,7 @@ Gem::Specification.new do |spec| spec.add_dependency "dependabot-hex", Dependabot::VERSION spec.add_dependency "dependabot-julia", Dependabot::VERSION spec.add_dependency "dependabot-maven", Dependabot::VERSION + spec.add_dependency "dependabot-mise", Dependabot::VERSION spec.add_dependency "dependabot-nix", Dependabot::VERSION spec.add_dependency "dependabot-npm_and_yarn", Dependabot::VERSION spec.add_dependency "dependabot-nuget", Dependabot::VERSION diff --git a/omnibus/lib/dependabot/omnibus.rb b/omnibus/lib/dependabot/omnibus.rb index 2361359867f..16242513e54 100644 --- a/omnibus/lib/dependabot/omnibus.rb +++ b/omnibus/lib/dependabot/omnibus.rb @@ -3,6 +3,7 @@ require "dependabot/bazel" require "dependabot/nix" +require "dependabot/mise" require "dependabot/pre_commit" require "dependabot/python" require "dependabot/terraform" diff --git a/rakelib/support/helpers.rb b/rakelib/support/helpers.rb index 9804bb95a47..a48b9555a85 100644 --- a/rakelib/support/helpers.rb +++ b/rakelib/support/helpers.rb @@ -40,6 +40,7 @@ class RakeHelpers hex/dependabot-hex.gemspec julia/dependabot-julia.gemspec maven/dependabot-maven.gemspec + mise/dependabot-mise.gemspec nix/dependabot-nix.gemspec npm_and_yarn/dependabot-npm_and_yarn.gemspec nuget/dependabot-nuget.gemspec diff --git a/script/dependabot b/script/dependabot index b2c3878763c..196da115193 100755 --- a/script/dependabot +++ b/script/dependabot @@ -5,6 +5,7 @@ touch .core-bash_history # allow bash history to persist across invocations dependabot \ -v "$(pwd)"/.core-bash_history:/home/dependabot/.bash_history \ -v "$(pwd)"/nix:/home/dependabot/nix \ + -v "$(pwd)"/mise:/home/dependabot/mise \ -v "$(pwd)"/updater/bin:/home/dependabot/dependabot-updater/bin \ -v "$(pwd)"/updater/lib:/home/dependabot/dependabot-updater/lib \ -v "$(pwd)"/bin:/home/dependabot/bin \ diff --git a/updater/Gemfile b/updater/Gemfile index 744a8eedb71..75dee689e7b 100644 --- a/updater/Gemfile +++ b/updater/Gemfile @@ -22,6 +22,7 @@ gem "dependabot-helm", path: "../helm" gem "dependabot-hex", path: "../hex" gem "dependabot-julia", path: "../julia" gem "dependabot-maven", path: "../maven" +gem "dependabot-mise", path: "../mise" gem "dependabot-nix", path: "../nix" gem "dependabot-npm_and_yarn", path: "../npm_and_yarn" gem "dependabot-nuget", path: "../nuget" diff --git a/updater/Gemfile.lock b/updater/Gemfile.lock index 2a0ebc79ed3..2b7b89f78d8 100644 --- a/updater/Gemfile.lock +++ b/updater/Gemfile.lock @@ -144,6 +144,12 @@ PATH dependabot-common (= 0.369.0) rexml (~> 3.4.1) +PATH + remote: ../mise + specs: + dependabot-mise (0.369.0) + dependabot-common (= 0.369.0) + PATH remote: ../nix specs: @@ -621,6 +627,7 @@ DEPENDENCIES dependabot-hex! dependabot-julia! dependabot-maven! + dependabot-mise! dependabot-nix! dependabot-npm_and_yarn! dependabot-nuget! @@ -710,6 +717,7 @@ CHECKSUMS dependabot-hex (0.369.0) dependabot-julia (0.369.0) dependabot-maven (0.369.0) + dependabot-mise (0.369.0) dependabot-nix (0.369.0) dependabot-npm_and_yarn (0.369.0) dependabot-nuget (0.369.0) diff --git a/updater/lib/dependabot/setup.rb b/updater/lib/dependabot/setup.rb index 1f19b7513de..11a218ff98a 100644 --- a/updater/lib/dependabot/setup.rb +++ b/updater/lib/dependabot/setup.rb @@ -45,6 +45,7 @@ hex| julia| maven| + mise| nix| npm_and_yarn| nuget| @@ -89,6 +90,7 @@ require "dependabot/hex" require "dependabot/julia" require "dependabot/maven" +require "dependabot/mise" require "dependabot/nix" require "dependabot/npm_and_yarn" require "dependabot/nuget" From 1835634e573a40a18bcfe24d0c4690177d8697a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Fern=C3=A1ndez?= Date: Thu, 26 Mar 2026 21:21:20 +0100 Subject: [PATCH 2/2] support multiple manifest files --- mise/lib/dependabot/mise/file_fetcher.rb | 36 ++- mise/lib/dependabot/mise/file_parser.rb | 112 ++++++-- mise/lib/dependabot/mise/file_updater.rb | 67 +++-- .../spec/dependabot/mise/file_fetcher_spec.rb | 71 +++++- mise/spec/dependabot/mise/file_parser_spec.rb | 240 +++++++++++++++++- .../spec/dependabot/mise/file_updater_spec.rb | 134 ++++++++++ 6 files changed, 600 insertions(+), 60 deletions(-) diff --git a/mise/lib/dependabot/mise/file_fetcher.rb b/mise/lib/dependabot/mise/file_fetcher.rb index 568ca19ef69..257ee56db83 100644 --- a/mise/lib/dependabot/mise/file_fetcher.rb +++ b/mise/lib/dependabot/mise/file_fetcher.rb @@ -1,4 +1,4 @@ -# typed: strong +# typed: strict # frozen_string_literal: true require "dependabot/file_fetchers" @@ -9,19 +9,23 @@ module Mise class FileFetcher < Dependabot::FileFetchers::Base extend T::Sig - MANIFEST_FILE = T.let("mise.toml", String) - - # NOTE: mise also supports .mise.toml, .config/mise.toml, and mise/config.toml - # as alternative config file locations. These are not currently supported. - sig { override.returns(String) } def self.required_files_message - "Repo must contain a mise.toml file." + "Repo must contain a mise configuration file " \ + "(mise.toml, .mise.toml, mise..toml, or .mise..toml)." end sig { override.params(filenames: T::Array[String]).returns(T::Boolean) } def self.required_files_in?(filenames) - filenames.include?(MANIFEST_FILE) + filenames.any? { |filename| mise_config_file?(filename) } + end + + sig { params(filename: String).returns(T::Boolean) } + def self.mise_config_file?(filename) + filename == "mise.toml" || + filename == ".mise.toml" || + filename.match?(/^mise\.[a-zA-Z0-9_-]+\.toml$/) || # mise..toml + filename.match?(/^\.mise\.[a-zA-Z0-9_-]+\.toml$/) # .mise..toml end sig { override.returns(T::Array[DependencyFile]) } @@ -34,7 +38,21 @@ def fetch_files ) end - [fetch_file_from_host(MANIFEST_FILE)] + # Fetch all mise config files that exist in the repo + fetched_files = repo_contents.filter_map do |file| + # Access properties directly - repo_contents items have name and type + next unless file.type == "file" + next unless self.class.mise_config_file?(file.name) + + fetch_file_from_host(file.name) + end + + return fetched_files unless fetched_files.empty? + + raise Dependabot::DependencyFileNotFound.new( + "mise.toml", + "No mise configuration file found" + ) end sig { override.returns(T.nilable(T::Hash[Symbol, T.untyped])) } diff --git a/mise/lib/dependabot/mise/file_parser.rb b/mise/lib/dependabot/mise/file_parser.rb index 47c09d722a8..14e6bd64be1 100644 --- a/mise/lib/dependabot/mise/file_parser.rb +++ b/mise/lib/dependabot/mise/file_parser.rb @@ -18,8 +18,36 @@ class FileParser < Dependabot::FileParsers::Base sig { override.returns(T::Array[Dependabot::Dependency]) } def parse + dependencies_by_name = {} + mise_files = dependency_files.select { |f| Dependabot::Mise::FileFetcher.mise_config_file?(f.name) } + + # Parse each mise config file in isolation to track which file each dependency comes from + mise_files.each do |mise_file| + parse_mise_file(mise_file, dependencies_by_name) + end + + dependencies_by_name.values + rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e + Dependabot.logger.warn("mise ls failed: #{e.message}") + [] + rescue JSON::ParserError => e + Dependabot.logger.warn("mise ls returned invalid JSON: #{e.message}") + [] + end + + private + + sig do + params( + mise_file: Dependabot::DependencyFile, + dependencies_by_name: T::Hash[String, Dependabot::Dependency] + ) + .void + end + def parse_mise_file(mise_file, dependencies_by_name) + # Parse this file in isolation by writing only this file to a temp directory Dependabot::SharedHelpers.in_a_temporary_directory do - write_manifest_files(dependency_files) + File.write(mise_file.name, mise_file.content) raw = Dependabot::SharedHelpers.run_shell_command( "mise ls --current --local --json", @@ -27,45 +55,90 @@ def parse env: { "MISE_YES" => "1" } ) - JSON.parse(raw).filter_map do |tool_name, entries| + JSON.parse(raw).each do |tool_name, entries| entry = Array(entries).first next unless entry requested = entry["requested_version"] next unless requested - # Skip fuzzy pins like "latest" or "lts" — they have no specific version - # to compare against and would break version comparison in the base class. + # Skip fuzzy pins like "latest" or "lts" next unless Dependabot::Mise::Version.correct?(requested) - # `version` is what mise resolved (used for version comparison). - # `requested_version` is what's written in mise.toml (used by the file updater). resolved = entry["version"] || requested - build_dependency(tool_name, resolved, requested) + # Add or update the dependency with this file's requirement + dependencies_by_name[tool_name] = if dependencies_by_name[tool_name] + # Tool already exists from another file, add this file's requirement + add_requirement_to_dependency( + T.must(dependencies_by_name[tool_name]), + mise_file.name, + requested, + resolved + ) + else + # New tool, create dependency + build_dependency( + tool_name, + resolved, + requested, + mise_file.name + ) + end end end - rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e - Dependabot.logger.warn("mise ls failed: #{e.message}") - [] - rescue JSON::ParserError => e - Dependabot.logger.warn("mise ls returned invalid JSON: #{e.message}") - [] end - private + sig do + params( + dependency: Dependabot::Dependency, + file_name: String, + requirement: String, + version: String + ) + .returns(Dependabot::Dependency) + end + def add_requirement_to_dependency(dependency, file_name, requirement, version) + # Check if we already have this file in requirements (shouldn't happen, but be safe) + return dependency if dependency.requirements.any? { |r| r[:file] == file_name } + + # Add the new requirement for this file + new_requirement = { + requirement: requirement, + file: file_name, + groups: [], + source: nil + } + + updated_requirements = dependency.requirements + [new_requirement] + + # Use the LOWEST version across all files + # This ensures Dependabot will suggest updates for files that are behind + # If we used the highest, files already on latest wouldn't trigger updates for outdated files + current_version = Dependabot::Mise::Version.new(dependency.version) + new_version = Dependabot::Mise::Version.new(version) + updated_version = new_version < current_version ? version : dependency.version + + # Create a new Dependency object with updated requirements and version + Dependabot::Dependency.new( + name: dependency.name, + version: updated_version, + package_manager: dependency.package_manager, + requirements: updated_requirements + ) + end sig do - params(name: String, version: String, requirement: String) + params(name: String, version: String, requirement: String, file_name: String) .returns(Dependabot::Dependency) end - def build_dependency(name, version, requirement) + def build_dependency(name, version, requirement, file_name) Dependabot::Dependency.new( name: name, version: version, package_manager: "mise", requirements: [{ requirement: requirement, - file: Dependabot::Mise::FileFetcher::MANIFEST_FILE, + file: file_name, groups: [], source: nil }] @@ -74,9 +147,10 @@ def build_dependency(name, version, requirement) sig { override.void } def check_required_files - return if get_original_file(Dependabot::Mise::FileFetcher::MANIFEST_FILE) + mise_files = dependency_files.select { |f| Dependabot::Mise::FileFetcher.mise_config_file?(f.name) } + return unless mise_files.empty? - raise "No #{Dependabot::Mise::FileFetcher::MANIFEST_FILE} file found!" + raise "No mise configuration file found!" end end end diff --git a/mise/lib/dependabot/mise/file_updater.rb b/mise/lib/dependabot/mise/file_updater.rb index 3afc057f5de..c21ad872a5d 100644 --- a/mise/lib/dependabot/mise/file_updater.rb +++ b/mise/lib/dependabot/mise/file_updater.rb @@ -16,29 +16,42 @@ class FileUpdater < Dependabot::FileUpdaters::Base def updated_dependency_files updated_files = [] - mise_toml = dependency_files.find { |f| f.name == Dependabot::Mise::FileFetcher::MANIFEST_FILE } - return updated_files unless mise_toml - - new_content = updated_mise_toml_content(mise_toml.content.to_s) - updated_files << updated_file(file: mise_toml, content: new_content) if new_content != mise_toml.content + # Get all unique files that need updates from dependency requirements + files_to_update = dependencies.flat_map do |dep| + dep.requirements.map { |r| r[:file] } + end.uniq + + # Update only the files that contain dependencies being updated + files_to_update.each do |file_name| + mise_file = dependency_files.find { |f| f.name == file_name } + next unless mise_file + + new_content = updated_mise_toml_content(mise_file.content.to_s, mise_file.name) + updated_files << updated_file(file: mise_file, content: new_content) if new_content != mise_file.content + end updated_files end private - sig { params(content: String).returns(String) } - def updated_mise_toml_content(content) - dependencies.each_with_object(content.dup) do |dep, updated_content| - updated_content.replace(update_dependency(updated_content, dep)) + sig { params(content: String, file_name: String).returns(String) } + def updated_mise_toml_content(content, file_name) + # Only update dependencies that have requirements in this specific file + deps_for_file = dependencies.select do |dep| + dep.requirements.any? { |r| r[:file] == file_name } + end + + deps_for_file.each_with_object(content.dup) do |dep, updated_content| + updated_content.replace(update_dependency(updated_content, dep, file_name)) end end - sig { params(content: String, dep: Dependabot::Dependency).returns(String) } - def update_dependency(content, dep) + sig { params(content: String, dep: Dependabot::Dependency, file_name: String).returns(String) } + def update_dependency(content, dep, file_name) tool = Regexp.escape(dep.name) - old_version = Regexp.escape(requested_version_for(dep)) - new_version = new_version_string_for(dep) + old_version = Regexp.escape(requested_version_for(dep, file_name)) + new_version = new_version_string_for(dep, file_name) # Handles plain keys: erlang = "27.3.2" # Handles quoted keys: "npm:@redocly/cli" = "2.19.1" @@ -61,25 +74,29 @@ def update_dependency(content, dep) ) end - sig { params(dep: Dependabot::Dependency).returns(String) } - def requested_version_for(dep) - T.must(dep.previous_requirements) - .filter_map { |r| r[:requirement] } - .first || dep.previous_version.to_s + sig { params(dep: Dependabot::Dependency, file_name: String).returns(String) } + def requested_version_for(dep, file_name) + # Get the requirement from the specific file being updated + requirement = T.must(dep.previous_requirements) + .find { |r| r[:file] == file_name } + + requirement&.fetch(:requirement) || dep.previous_version.to_s end - sig { params(dep: Dependabot::Dependency).returns(String) } - def new_version_string_for(dep) - dep.requirements - .filter_map { |r| r[:requirement] } - .first || dep.version.to_s + sig { params(dep: Dependabot::Dependency, file_name: String).returns(String) } + def new_version_string_for(dep, file_name) + # Get the new requirement for the specific file + requirement = dep.requirements.find { |r| r[:file] == file_name } + + requirement&.fetch(:requirement) || dep.version.to_s end sig { override.void } def check_required_files - return if get_original_file(Dependabot::Mise::FileFetcher::MANIFEST_FILE) + mise_files = dependency_files.select { |f| Dependabot::Mise::FileFetcher.mise_config_file?(f.name) } + return unless mise_files.empty? - raise "No #{Dependabot::Mise::FileFetcher::MANIFEST_FILE} file found!" + raise "No mise configuration file found!" end end end diff --git a/mise/spec/dependabot/mise/file_fetcher_spec.rb b/mise/spec/dependabot/mise/file_fetcher_spec.rb index 833e62e89b5..58fc1aa7a36 100644 --- a/mise/spec/dependabot/mise/file_fetcher_spec.rb +++ b/mise/spec/dependabot/mise/file_fetcher_spec.rb @@ -36,19 +36,84 @@ it_behaves_like "a dependency file fetcher" + describe ".mise_config_file?" do + it "returns true for mise.toml" do + expect(described_class.mise_config_file?("mise.toml")).to be(true) + end + + it "returns true for .mise.toml" do + expect(described_class.mise_config_file?(".mise.toml")).to be(true) + end + + it "returns true for environment-specific variants" do + expect(described_class.mise_config_file?("mise.production.toml")).to be(true) + expect(described_class.mise_config_file?("mise.dev.toml")).to be(true) + expect(described_class.mise_config_file?("mise.local.toml")).to be(true) + expect(described_class.mise_config_file?("mise.staging.toml")).to be(true) + expect(described_class.mise_config_file?("mise.test.toml")).to be(true) + end + + it "returns true for dotfile environment-specific variants" do + expect(described_class.mise_config_file?(".mise.production.toml")).to be(true) + expect(described_class.mise_config_file?(".mise.dev.toml")).to be(true) + expect(described_class.mise_config_file?(".mise.local.toml")).to be(true) + end + + it "returns true for environment names with hyphens and underscores" do + expect(described_class.mise_config_file?("mise.my-env.toml")).to be(true) + expect(described_class.mise_config_file?("mise.my_env.toml")).to be(true) + expect(described_class.mise_config_file?(".mise.test-env.toml")).to be(true) + end + + it "returns false for non-mise files" do + expect(described_class.mise_config_file?("README.md")).to be(false) + expect(described_class.mise_config_file?("package.json")).to be(false) + expect(described_class.mise_config_file?("mise.txt")).to be(false) + end + + it "returns false for files with invalid patterns" do + expect(described_class.mise_config_file?("mise..toml")).to be(false) + expect(described_class.mise_config_file?("mise.toml.bak")).to be(false) + expect(described_class.mise_config_file?("my-mise.toml")).to be(false) + end + + it "returns false for directory paths" do + expect(described_class.mise_config_file?(".config/mise.toml")).to be(false) + expect(described_class.mise_config_file?(".mise/config.toml")).to be(false) + end + end + describe ".required_files_in?" do it "returns true when mise.toml is present" do expect(described_class.required_files_in?(["mise.toml"])).to be(true) end - it "returns false when mise.toml is absent" do - expect(described_class.required_files_in?(["README.md"])).to be(false) + it "returns true when .mise.toml is present" do + expect(described_class.required_files_in?([".mise.toml"])).to be(true) + end + + it "returns true when environment-specific variant is present" do + expect(described_class.required_files_in?(["mise.production.toml"])).to be(true) + expect(described_class.required_files_in?([".mise.local.toml"])).to be(true) + end + + it "returns true when any mise config file is present" do + filenames = ["README.md", "package.json", "mise.dev.toml", "Gemfile"] + expect(described_class.required_files_in?(filenames)).to be(true) + end + + it "returns false when no mise config files are present" do + expect(described_class.required_files_in?(["README.md", "package.json"])).to be(false) end end describe ".required_files_message" do it "returns a helpful message" do - expect(described_class.required_files_message).to eq("Repo must contain a mise.toml file.") + expect(described_class.required_files_message) + .to eq( + "Repo must contain a mise configuration file " \ + "(mise.toml, .mise.toml, mise..toml, or .mise..toml)." + ) end end end diff --git a/mise/spec/dependabot/mise/file_parser_spec.rb b/mise/spec/dependabot/mise/file_parser_spec.rb index 0f1d66e5374..59791d3f3c6 100644 --- a/mise/spec/dependabot/mise/file_parser_spec.rb +++ b/mise/spec/dependabot/mise/file_parser_spec.rb @@ -38,8 +38,9 @@ context "with simple exact versions" do before do + allow(File).to receive(:write) allow(Dependabot::SharedHelpers).to receive(:run_shell_command) - .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) .and_return(JSON.dump( { "erlang" => [{ "requested_version" => "27.3.2", "version" => "27.3.2" }], @@ -72,8 +73,9 @@ end before do + allow(File).to receive(:write) allow(Dependabot::SharedHelpers).to receive(:run_shell_command) - .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) .and_return(JSON.dump({})) end @@ -84,8 +86,9 @@ context "when mise ls returns invalid JSON" do before do + allow(File).to receive(:write) allow(Dependabot::SharedHelpers).to receive(:run_shell_command) - .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) .and_return("{") end @@ -96,8 +99,9 @@ context "with mixed version formats" do before do + allow(File).to receive(:write) allow(Dependabot::SharedHelpers).to receive(:run_shell_command) - .with(/mise ls/, hash_including(stderr_to_stdout: false)) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) .and_return(JSON.dump( { "node" => [{ "requested_version" => "20", "version" => "20.20.2" }], @@ -132,5 +136,233 @@ expect(node.requirements.first[:requirement]).to eq("20") end end + + context "with multiple mise config files" do + let(:dependency_files) { [mise_toml, mise_production_toml] } + + let(:mise_production_toml) do + Dependabot::DependencyFile.new( + name: "mise.production.toml", + content: <<~TOML + [tools] + erlang = "28.0.0" + python = "3.11.0" + TOML + ) + end + + before do + # Allow File.write for any file + allow(File).to receive(:write) + + # Mock mise ls to return different results for each file + call_count = 0 + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) do + call_count += 1 + if call_count == 1 + # First call for mise.toml + JSON.dump( + "erlang" => [{ "requested_version" => "27.3.2", "version" => "27.3.2" }], + "elixir" => [{ "requested_version" => "1.18.4-otp-27", "version" => "1.18.4-otp-27" }], + "helm" => [{ "requested_version" => "3.17.3", "version" => "3.17.3" }] + ) + else + # Second call for mise.production.toml + JSON.dump( + "erlang" => [{ "requested_version" => "28.0.0", "version" => "28.0.0" }], + "python" => [{ "requested_version" => "3.11.0", "version" => "3.11.0" }] + ) + end + end + end + + it "creates one dependency per tool name" do + expect(dependencies.map(&:name)).to contain_exactly( + "erlang", + "elixir", + "helm", + "python" + ) + end + + it "tracks requirements from multiple files for the same tool" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.requirements.length).to eq(2) + + files = erlang.requirements.map { |r| r[:file] } + expect(files).to contain_exactly("mise.toml", "mise.production.toml") + end + + it "uses the lowest version across all files to detect updates needed" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.version).to eq("27.3.2") + end + + it "stores the correct requirement for each file" do + erlang = dependencies.find { |d| d.name == "erlang" } + + mise_toml_req = erlang.requirements.find { |r| r[:file] == "mise.toml" } + expect(mise_toml_req[:requirement]).to eq("27.3.2") + + production_req = erlang.requirements.find { |r| r[:file] == "mise.production.toml" } + expect(production_req[:requirement]).to eq("28.0.0") + end + + it "creates separate dependencies for tools that only exist in one file" do + elixir = dependencies.find { |d| d.name == "elixir" } + expect(elixir.requirements.length).to eq(1) + expect(elixir.requirements.first[:file]).to eq("mise.toml") + + python = dependencies.find { |d| d.name == "python" } + expect(python.requirements.length).to eq(1) + expect(python.requirements.first[:file]).to eq("mise.production.toml") + end + end + + context "with .mise.toml dotfile" do + let(:dependency_files) { [dotfile_mise_toml] } + + let(:dotfile_mise_toml) do + Dependabot::DependencyFile.new( + name: ".mise.toml", + content: <<~TOML + [tools] + node = "20.0.0" + TOML + ) + end + + before do + allow(File).to receive(:write) + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) + .and_return(JSON.dump( + "node" => [{ "requested_version" => "20.0.0", "version" => "20.0.0" }] + )) + end + + it "parses dotfile mise config" do + expect(dependencies.map(&:name)).to contain_exactly("node") + expect(dependencies.first.requirements.first[:file]).to eq(".mise.toml") + end + end + + context "with environment-specific config files" do + let(:dependency_files) { [mise_toml, mise_dev_toml, mise_local_toml] } + + let(:mise_dev_toml) do + Dependabot::DependencyFile.new( + name: "mise.dev.toml", + content: <<~TOML + [tools] + erlang = "27.0.0" + TOML + ) + end + + let(:mise_local_toml) do + Dependabot::DependencyFile.new( + name: ".mise.local.toml", + content: <<~TOML + [tools] + erlang = "26.0.0" + TOML + ) + end + + before do + # Allow File.write for any file + allow(File).to receive(:write) + + # Mock mise ls to return different results for each file + call_count = 0 + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) do + call_count += 1 + case call_count + when 1 + JSON.dump("erlang" => [{ "requested_version" => "27.3.2", "version" => "27.3.2" }]) + when 2 + JSON.dump("erlang" => [{ "requested_version" => "27.0.0", "version" => "27.0.0" }]) + when 3 + JSON.dump("erlang" => [{ "requested_version" => "26.0.0", "version" => "26.0.0" }]) + end + end + end + + it "tracks erlang across all three files" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.requirements.length).to eq(3) + + files = erlang.requirements.map { |r| r[:file] } + expect(files).to contain_exactly("mise.toml", "mise.dev.toml", ".mise.local.toml") + end + + it "uses the lowest version (26.0.0) to ensure updates are detected" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.version).to eq("26.0.0") + end + end + + context "when one file is already on latest version" do + let(:dependency_files) { [mise_toml, mise_production_toml] } + + let(:mise_production_toml) do + Dependabot::DependencyFile.new( + name: "mise.production.toml", + content: <<~TOML + [tools] + erlang = "27.0.0" + TOML + ) + end + + before do + # Allow File.write for any file + allow(File).to receive(:write) + + # Mock mise ls to return different results for each file + call_count = 0 + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with("mise ls --current --local --json", hash_including(stderr_to_stdout: false)) do + call_count += 1 + if call_count == 1 + # First call - mise.toml with latest version + JSON.dump( + "erlang" => [{ "requested_version" => "28.0.0", "version" => "28.0.0" }] + ) + else + # Second call - mise.production.toml with older version + JSON.dump( + "erlang" => [{ "requested_version" => "27.0.0", "version" => "27.0.0" }] + ) + end + end + end + + it "uses the lowest version to ensure update is still detected" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.version).to eq("27.0.0") + end + + it "tracks both file requirements" do + erlang = dependencies.find { |d| d.name == "erlang" } + expect(erlang.requirements.length).to eq(2) + + files = erlang.requirements.map { |r| r[:file] } + expect(files).to contain_exactly("mise.toml", "mise.production.toml") + end + + it "stores correct versions for each file" do + erlang = dependencies.find { |d| d.name == "erlang" } + + mise_toml_req = erlang.requirements.find { |r| r[:file] == "mise.toml" } + expect(mise_toml_req[:requirement]).to eq("28.0.0") + + production_req = erlang.requirements.find { |r| r[:file] == "mise.production.toml" } + expect(production_req[:requirement]).to eq("27.0.0") + end + end end end diff --git a/mise/spec/dependabot/mise/file_updater_spec.rb b/mise/spec/dependabot/mise/file_updater_spec.rb index b2b6643649c..ec5dc60c94e 100644 --- a/mise/spec/dependabot/mise/file_updater_spec.rb +++ b/mise/spec/dependabot/mise/file_updater_spec.rb @@ -226,5 +226,139 @@ expect(updated_files.first.content).to include("[tools.golang]\nenv = \"production\"\nversion = \"1.22.0\"") end end + + context "with dependency in multiple files" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + erlang = "27.3.2" + node = "20.0.0" + TOML + ) + end + + let(:mise_production_toml) do + Dependabot::DependencyFile.new( + name: "mise.production.toml", + content: <<~TOML + [tools] + erlang = "28.0.0" + python = "3.11.0" + TOML + ) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "erlang", + version: "28.5.0", + previous_version: "28.0.0", + package_manager: "mise", + requirements: [ + { requirement: "28.5.0", file: "mise.toml", groups: [], source: nil }, + { requirement: "28.5.0", file: "mise.production.toml", groups: [], source: nil } + ], + previous_requirements: [ + { requirement: "27.3.2", file: "mise.toml", groups: [], source: nil }, + { requirement: "28.0.0", file: "mise.production.toml", groups: [], source: nil } + ] + ) + end + + let(:updater) do + described_class.new( + dependency_files: [mise_toml, mise_production_toml], + dependencies: [dependency], + credentials: [] + ) + end + + it "updates both files" do + expect(updated_files.length).to eq(2) + end + + it "updates erlang in mise.toml from 27.3.2 to 28.5.0" do + mise_file = updated_files.find { |f| f.name == "mise.toml" } + expect(mise_file.content).to include('erlang = "28.5.0"') + expect(mise_file.content).not_to include('erlang = "27.3.2"') + end + + it "updates erlang in mise.production.toml from 28.0.0 to 28.5.0" do + production_file = updated_files.find { |f| f.name == "mise.production.toml" } + expect(production_file.content).to include('erlang = "28.5.0"') + expect(production_file.content).not_to include('erlang = "28.0.0"') + end + + it "does not modify other tools in mise.toml" do + mise_file = updated_files.find { |f| f.name == "mise.toml" } + expect(mise_file.content).to include('node = "20.0.0"') + end + + it "does not modify other tools in mise.production.toml" do + production_file = updated_files.find { |f| f.name == "mise.production.toml" } + expect(production_file.content).to include('python = "3.11.0"') + end + end + + context "with dependency only in one of multiple files" do + let(:mise_toml) do + Dependabot::DependencyFile.new( + name: "mise.toml", + content: <<~TOML + [tools] + erlang = "27.3.2" + node = "20.0.0" + TOML + ) + end + + let(:mise_production_toml) do + Dependabot::DependencyFile.new( + name: "mise.production.toml", + content: <<~TOML + [tools] + python = "3.11.0" + TOML + ) + end + + let(:node_dependency) do + Dependabot::Dependency.new( + name: "node", + version: "22.0.0", + previous_version: "20.0.0", + package_manager: "mise", + requirements: [ + { requirement: "22.0.0", file: "mise.toml", groups: [], source: nil } + ], + previous_requirements: [ + { requirement: "20.0.0", file: "mise.toml", groups: [], source: nil } + ] + ) + end + + let(:updater) do + described_class.new( + dependency_files: [mise_toml, mise_production_toml], + dependencies: [node_dependency], + credentials: [] + ) + end + + it "only updates the file containing the dependency" do + expect(updated_files.length).to eq(1) + expect(updated_files.first.name).to eq("mise.toml") + end + + it "updates node in mise.toml" do + expect(updated_files.first.content).to include('node = "22.0.0"') + end + + it "does not update mise.production.toml" do + expect(updated_files.map(&:name)).not_to include("mise.production.toml") + end + end end end