dependabot-devbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 11b87d3ad83c9c545c69e83345c8173e7bd9de6873c1b2a789dd03b93998c563
4
+ data.tar.gz: 107abd1dd96105c923866884b0624878c3640a7f4fa82b9009ed7b4dc3e518f4
5
+ SHA512:
6
+ metadata.gz: f0f380423454eb53418a9eb2d45d60cfcb8a8d1162567d16904367c9f02156fdfbfc1c6c40f5dc2b067d896140cbe911e9ec0a8d94b99b63026b2b1adc2b267c
7
+ data.tar.gz: 7b0fa0c022e3bd8c38dd2442057fcf721c30b6fd3386b447d798d6e6f2935bc95f120effec80c8bd5897c6e3ebc6e1dbd38ce382bc4916249578630a84d48bcf
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/file_fetchers"
5
+ require "dependabot/file_parsers"
6
+ require "dependabot/update_checkers"
7
+ require "dependabot/file_updaters"
8
+ require "dependabot/pull_request_creator"
9
+ require "dependabot/devbox"
10
+
11
+ repo = ENV.fetch("GITHUB_REPOSITORY")
12
+ token = ENV["GITHUB_ACCESS_TOKEN"] || ENV.fetch("GITHUB_TOKEN")
13
+ directory = ENV.fetch("DIRECTORY_PATH", "/")
14
+ branch = ENV.fetch("BASE_BRANCH", nil)
15
+
16
+ credentials = [
17
+ {
18
+ "type" => "git_source",
19
+ "host" => "github.com",
20
+ "username" => "x-access-token",
21
+ "password" => token
22
+ }
23
+ ]
24
+
25
+ source = Dependabot::Source.new(
26
+ provider: "github",
27
+ repo: repo,
28
+ directory: directory,
29
+ branch: branch
30
+ )
31
+
32
+ location = directory == "/" ? repo : "#{repo} (#{directory})"
33
+ puts "Fetching devbox files from #{location}"
34
+
35
+ fetcher = Dependabot::FileFetchers.for_package_manager("devbox").new(
36
+ source: source,
37
+ credentials: credentials
38
+ )
39
+
40
+ files = fetcher.files
41
+ commit = fetcher.commit
42
+
43
+ parser = Dependabot::FileParsers.for_package_manager("devbox").new(
44
+ dependency_files: files,
45
+ source: source,
46
+ credentials: credentials
47
+ )
48
+
49
+ dependencies = parser.parse
50
+ puts "Found #{dependencies.length} devbox package(s)"
51
+
52
+ dependencies.each do |dep| # rubocop:disable Metrics/BlockLength
53
+ puts "Checking #{dep.name} (#{dep.version})..."
54
+
55
+ checker = Dependabot::UpdateCheckers.for_package_manager("devbox").new(
56
+ dependency: dep,
57
+ dependency_files: files,
58
+ credentials: credentials
59
+ )
60
+
61
+ if checker.up_to_date?
62
+ puts " up to date"
63
+ next
64
+ end
65
+
66
+ requirements_to_unlock = checker.requirements_unlocked_or_can_be? ? :own : :none
67
+
68
+ updated_deps =
69
+ begin
70
+ checker.updated_dependencies(requirements_to_unlock: requirements_to_unlock)
71
+ rescue Dependabot::AllVersionsIgnored
72
+ puts " all versions ignored"
73
+ next
74
+ end
75
+
76
+ updater = Dependabot::FileUpdaters.for_package_manager("devbox").new(
77
+ dependencies: updated_deps,
78
+ dependency_files: files,
79
+ credentials: credentials
80
+ )
81
+
82
+ updated_files = updater.updated_dependency_files
83
+
84
+ pr_creator = Dependabot::PullRequestCreator.new(
85
+ source: source,
86
+ base_commit: commit,
87
+ dependencies: updated_deps,
88
+ files: updated_files,
89
+ credentials: credentials,
90
+ label_language: true
91
+ )
92
+
93
+ pr = pr_creator.create
94
+ if pr
95
+ puts " PR created: #{pr.html_url}"
96
+ else
97
+ puts " PR already exists or no changes needed"
98
+ end
99
+ end
100
+
101
+ puts "Done."
@@ -0,0 +1,58 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/file_fetchers"
5
+ require "dependabot/file_fetchers/base"
6
+
7
+ module Dependabot
8
+ module Devbox
9
+ class FileFetcher < Dependabot::FileFetchers::Base
10
+ extend T::Sig
11
+
12
+ MANIFEST_FILENAME = T.let("devbox.json", String)
13
+ LOCKFILE_FILENAME = T.let("devbox.lock", String)
14
+
15
+ sig { override.returns(String) }
16
+ def self.required_files_message
17
+ "Repo must contain a devbox.json."
18
+ end
19
+
20
+ sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
21
+ def self.required_files_in?(filenames)
22
+ filenames.include?(MANIFEST_FILENAME)
23
+ end
24
+
25
+ sig { override.returns(T::Array[DependencyFile]) }
26
+ def fetch_files
27
+ fetched_files = [manifest_file]
28
+ fetched_files << T.must(lockfile) if lockfile
29
+ fetched_files
30
+ end
31
+
32
+ private
33
+
34
+ sig { returns(DependencyFile) }
35
+ def manifest_file
36
+ @manifest_file ||= T.let(
37
+ begin
38
+ file = fetch_file_if_present(MANIFEST_FILENAME)
39
+ raise Dependabot::DependencyFileNotFound.new(nil, self.class.required_files_message) unless file
40
+
41
+ file
42
+ end,
43
+ T.nilable(DependencyFile)
44
+ )
45
+ end
46
+
47
+ sig { returns(T.nilable(DependencyFile)) }
48
+ def lockfile
49
+ @lockfile ||= T.let(
50
+ fetch_file_if_present(LOCKFILE_FILENAME),
51
+ T.nilable(DependencyFile)
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ Dependabot::FileFetchers.register("devbox", Dependabot::Devbox::FileFetcher)
@@ -0,0 +1,119 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "dependabot/dependency"
6
+ require "dependabot/file_parsers"
7
+ require "dependabot/file_parsers/base"
8
+ require "dependabot/devbox/helpers"
9
+ require "dependabot/devbox/version"
10
+
11
+ module Dependabot
12
+ module Devbox
13
+ class FileParser < Dependabot::FileParsers::Base
14
+ extend T::Sig
15
+
16
+ ECOSYSTEM = T.let("devbox", String)
17
+ MANIFEST_FILENAME = T.let("devbox.json", String)
18
+ LOCKFILE_FILENAME = T.let("devbox.lock", String)
19
+ # An entry without an "@constraint" suffix tracks the newest release.
20
+ DEFAULT_CONSTRAINT = T.let("latest", String)
21
+ SOURCE_TYPE = T.let("nixhub", String)
22
+
23
+ sig { override.returns(T::Array[Dependabot::Dependency]) }
24
+ def parse
25
+ package_entries.filter_map do |entry|
26
+ next unless entry.is_a?(String)
27
+
28
+ name, constraint = split_package_entry(entry)
29
+ next if name.empty?
30
+
31
+ Dependabot::Dependency.new(
32
+ name: name,
33
+ version: resolved_versions[entry],
34
+ requirements: [{
35
+ requirement: constraint,
36
+ file: MANIFEST_FILENAME,
37
+ groups: [],
38
+ source: { type: SOURCE_TYPE }
39
+ }],
40
+ package_manager: ECOSYSTEM
41
+ )
42
+ end.sort_by(&:name)
43
+ end
44
+
45
+ private
46
+
47
+ sig { override.void }
48
+ def check_required_files
49
+ return if manifest
50
+
51
+ raise "No devbox.json found!"
52
+ end
53
+
54
+ sig { returns(T.nilable(DependencyFile)) }
55
+ def manifest
56
+ @manifest ||= T.let(
57
+ dependency_files.find { |f| File.basename(f.name) == MANIFEST_FILENAME },
58
+ T.nilable(DependencyFile)
59
+ )
60
+ end
61
+
62
+ sig { returns(T.nilable(DependencyFile)) }
63
+ def lockfile
64
+ @lockfile ||= T.let(
65
+ dependency_files.find { |f| File.basename(f.name) == LOCKFILE_FILENAME },
66
+ T.nilable(DependencyFile)
67
+ )
68
+ end
69
+
70
+ # The "packages" field in devbox.json is an array of `name@constraint`
71
+ # strings — the only form supported in beta. Any other shape (e.g. the
72
+ # object form) yields no dependencies.
73
+ sig { returns(T::Array[Object]) }
74
+ def package_entries
75
+ packages = Helpers.parse_json_or_jsonc(manifest&.content).fetch("packages", [])
76
+ return [] unless packages.is_a?(Array)
77
+
78
+ packages
79
+ end
80
+
81
+ # Splits a package entry on the LAST "@" so future scoped names
82
+ # (e.g. "@org/pkg@1.0") resolve correctly. An entry with no constraint
83
+ # (or a leading-"@" scoped name with none) defaults to "latest".
84
+ sig { params(entry: String).returns([String, String]) }
85
+ def split_package_entry(entry)
86
+ index = entry.rindex("@")
87
+ return [entry, DEFAULT_CONSTRAINT] if index.nil? || index.zero?
88
+
89
+ [T.must(entry[0...index]), T.must(entry[(index + 1)..])]
90
+ end
91
+
92
+ # Maps each manifest package entry (the full `name@constraint` string) to
93
+ # the resolved version recorded in devbox.lock. devbox.lock is strict JSON.
94
+ sig { returns(T::Hash[String, String]) }
95
+ def resolved_versions
96
+ @resolved_versions ||= T.let(parse_lockfile_versions, T.nilable(T::Hash[String, String]))
97
+ end
98
+
99
+ sig { returns(T::Hash[String, String]) }
100
+ def parse_lockfile_versions
101
+ content = lockfile&.content
102
+ return {} unless content
103
+
104
+ parsed = JSON.parse(content)
105
+ packages = parsed.is_a?(Hash) ? parsed.fetch("packages", {}) : {}
106
+ return {} unless packages.is_a?(Hash)
107
+
108
+ packages.each_with_object({}) do |(entry, data), versions|
109
+ version = data.is_a?(Hash) ? data["version"] : nil
110
+ versions[entry] = version if version.is_a?(String)
111
+ end
112
+ rescue JSON::ParserError
113
+ {}
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ Dependabot::FileParsers.register("devbox", Dependabot::Devbox::FileParser)
@@ -0,0 +1,131 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/dependency_file"
6
+ require "dependabot/errors"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/file_updaters"
9
+ require "dependabot/file_updaters/base"
10
+ require "dependabot/devbox/helpers"
11
+
12
+ module Dependabot
13
+ module Devbox
14
+ class FileUpdater < Dependabot::FileUpdaters::Base
15
+ extend T::Sig
16
+
17
+ MANIFEST_FILENAME = T.let("devbox.json", String)
18
+ LOCKFILE_FILENAME = T.let("devbox.lock", String)
19
+ LATEST = T.let("latest", String)
20
+
21
+ sig { override.returns(T::Array[Dependabot::DependencyFile]) }
22
+ def updated_dependency_files
23
+ updated_files = []
24
+
25
+ new_manifest_content = updated_manifest_content
26
+ if new_manifest_content != manifest.content
27
+ updated_files << updated_file(file: manifest, content: new_manifest_content)
28
+ end
29
+
30
+ updated_files << lockfile_dependency_file(regenerated_lockfile_content(new_manifest_content))
31
+
32
+ updated_files
33
+ end
34
+
35
+ private
36
+
37
+ sig { override.void }
38
+ def check_required_files
39
+ return if dependency_files.any? { |f| File.basename(f.name) == MANIFEST_FILENAME }
40
+
41
+ raise "No devbox.json found!"
42
+ end
43
+
44
+ sig { returns(Dependabot::DependencyFile) }
45
+ def manifest
46
+ @manifest ||= T.let(
47
+ T.must(dependency_files.find { |f| File.basename(f.name) == MANIFEST_FILENAME }),
48
+ T.nilable(Dependabot::DependencyFile)
49
+ )
50
+ end
51
+
52
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
53
+ def lockfile
54
+ @lockfile ||= T.let(
55
+ dependency_files.find { |f| File.basename(f.name) == LOCKFILE_FILENAME },
56
+ T.nilable(Dependabot::DependencyFile)
57
+ )
58
+ end
59
+
60
+ # Rewrites each changed `name@constraint` entry in the raw manifest text so
61
+ # surrounding comments/formatting survive. A `latest` constraint never
62
+ # changes, so those entries are left untouched (lockfile-only update).
63
+ sig { returns(String) }
64
+ def updated_manifest_content
65
+ content = T.must(manifest.content).dup
66
+
67
+ dependencies.each do |dep|
68
+ prev_reqs = (dep.previous_requirements || []).select { |r| r[:file] == manifest.name }
69
+ new_reqs = dep.requirements.select { |r| r[:file] == manifest.name }
70
+
71
+ prev_reqs.zip(new_reqs).each do |prev_req, new_req|
72
+ next unless new_req
73
+
74
+ old_constraint = prev_req[:requirement]
75
+ new_constraint = new_req[:requirement]
76
+ next if old_constraint.nil? || old_constraint == new_constraint
77
+
78
+ content = content.gsub(%("#{dep.name}@#{old_constraint}"), %("#{dep.name}@#{new_constraint}"))
79
+ end
80
+ end
81
+
82
+ content
83
+ end
84
+
85
+ # Regenerates devbox.lock by running `devbox update <pkg> --no-install`
86
+ # (metadata-only: resolves nixpkgs commits/hashes without downloading store
87
+ # paths) against the updated manifest in an isolated temp directory.
88
+ sig { params(manifest_content: String).returns(String) }
89
+ def regenerated_lockfile_content(manifest_content)
90
+ original = lockfile&.content
91
+
92
+ new_content =
93
+ begin
94
+ SharedHelpers.in_a_temporary_directory do |dir|
95
+ dir = dir.to_s
96
+ File.write(File.join(dir, MANIFEST_FILENAME), manifest_content)
97
+ File.write(File.join(dir, LOCKFILE_FILENAME), original) if original
98
+ dependencies.each do |dep|
99
+ Helpers.run_devbox_command("update", "--no-install", dep.name, dir: dir)
100
+ end
101
+ File.read(File.join(dir, LOCKFILE_FILENAME))
102
+ end
103
+ rescue SharedHelpers::HelperSubprocessFailed, Errno::ENOENT => e
104
+ raise Dependabot::DependencyFileNotResolvable, e.message
105
+ end
106
+
107
+ if original && new_content == original
108
+ raise Dependabot::DependencyFileContentNotChanged,
109
+ "devbox update did not change #{LOCKFILE_FILENAME}"
110
+ end
111
+
112
+ new_content
113
+ end
114
+
115
+ sig { params(content: String).returns(Dependabot::DependencyFile) }
116
+ def lockfile_dependency_file(content)
117
+ existing = lockfile
118
+ return updated_file(file: existing, content: content) if existing
119
+
120
+ Dependabot::DependencyFile.new(
121
+ name: LOCKFILE_FILENAME,
122
+ content: content,
123
+ directory: manifest.directory,
124
+ operation: Dependabot::DependencyFile::Operation::CREATE
125
+ )
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ Dependabot::FileUpdaters.register("devbox", Dependabot::Devbox::FileUpdater)
@@ -0,0 +1,67 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/shared_helpers"
8
+
9
+ module Dependabot
10
+ module Devbox
11
+ module Helpers
12
+ extend T::Sig
13
+
14
+ # Matches either a JSON string literal (with escapes), a line comment, a
15
+ # block comment, or a trailing comma. The alternation lets gsub preserve
16
+ # strings while stripping the JSONC-only constructs, so e.g. "//" inside a
17
+ # URL value is not mistaken for the start of a comment.
18
+ JSONC_TOKEN = T.let(
19
+ %r{
20
+ ("(?:\\.|[^"\\])*") # JSON string literal
21
+ | //[^\n]* # line comment
22
+ | /\*.*?\*/ # block comment
23
+ | ,(?=\s*[\}\]]) # trailing comma
24
+ }mx,
25
+ Regexp
26
+ )
27
+
28
+ sig { params(content: T.nilable(String)).returns(T::Hash[String, Object]) }
29
+ def self.parse_json_or_jsonc(content)
30
+ return {} unless content
31
+
32
+ cleaned = content.gsub(JSONC_TOKEN) { ::Regexp.last_match(1) || "" }
33
+
34
+ parsed = JSON.parse(cleaned)
35
+ # A devbox.json must be a JSON object. Guard here so a malformed manifest
36
+ # (e.g. a top-level array) surfaces as a clear parse error rather than an
37
+ # opaque sorbet-runtime type error at the call site.
38
+ raise JSON::ParserError, "Expected a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
39
+
40
+ parsed
41
+ end
42
+
43
+ # Wraps `devbox <args>` via Dependabot's standard subprocess helper, so
44
+ # failures surface as Dependabot::SharedHelpers::HelperSubprocessFailed
45
+ # (consistent with cargo / bun / npm_and_yarn). The Devbox/Nix caches are
46
+ # scoped to the working directory so concurrent jobs don't trample each
47
+ # other's state.
48
+ sig do
49
+ params(
50
+ args: String,
51
+ dir: String
52
+ ).returns(String)
53
+ end
54
+ def self.run_devbox_command(*args, dir:)
55
+ Dependabot::SharedHelpers.run_shell_command(
56
+ "devbox #{args.join(' ')}",
57
+ cwd: dir,
58
+ env: {
59
+ "DEVBOX_CACHE" => File.join(dir, ".devbox_cache"),
60
+ "XDG_CACHE_HOME" => File.join(dir, ".cache"),
61
+ "HOME" => dir
62
+ }
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,59 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "cgi"
5
+ require "json"
6
+ require "dependabot/metadata_finders"
7
+ require "dependabot/metadata_finders/base"
8
+ require "dependabot/registry_client"
9
+
10
+ module Dependabot
11
+ module Devbox
12
+ class MetadataFinder < Dependabot::MetadataFinders::Base
13
+ extend T::Sig
14
+
15
+ SEARCH_URL = T.let("https://search.devbox.sh/v1/search", String)
16
+
17
+ private
18
+
19
+ # nixpkgs packages expose a `homepage` in the Nixhub search response. When
20
+ # it points at a recognised git host (e.g. GitHub) we can surface changelog
21
+ # and release metadata; otherwise there is no usable source.
22
+ sig { override.returns(T.nilable(Dependabot::Source)) }
23
+ def look_up_source
24
+ homepage = nixhub_homepage
25
+ return nil unless homepage
26
+
27
+ Source.from_url(homepage)
28
+ end
29
+
30
+ sig { returns(T.nilable(String)) }
31
+ def nixhub_homepage
32
+ homepage = package_versions.filter_map { |v| v["homepage"] if v.is_a?(Hash) }.first
33
+ homepage.is_a?(String) && !homepage.empty? ? homepage : nil
34
+ rescue JSON::ParserError, Excon::Error::Timeout, Excon::Error::Socket
35
+ nil
36
+ end
37
+
38
+ # The versions list for the exact-name package in the Nixhub search
39
+ # response (search is fuzzy, so match the name precisely).
40
+ sig { returns(T::Array[Object]) }
41
+ def package_versions
42
+ response = Dependabot::RegistryClient.get(
43
+ url: "#{SEARCH_URL}?q=#{CGI.escape(dependency.name)}"
44
+ )
45
+ return [] unless response.status == 200
46
+
47
+ data = JSON.parse(response.body)
48
+ packages = data.is_a?(Hash) ? data["packages"] : nil
49
+ return [] unless packages.is_a?(Array)
50
+
51
+ package = packages.find { |pkg| pkg.is_a?(Hash) && pkg["name"] == dependency.name }
52
+ versions = package.is_a?(Hash) ? package["versions"] : nil
53
+ versions.is_a?(Array) ? versions : []
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ Dependabot::MetadataFinders.register("devbox", Dependabot::Devbox::MetadataFinder)
@@ -0,0 +1,86 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "cgi"
5
+ require "json"
6
+ require "time"
7
+ require "sorbet-runtime"
8
+ require "dependabot/registry_client"
9
+ require "dependabot/package/package_release"
10
+ require "dependabot/devbox/version"
11
+
12
+ module Dependabot
13
+ module Devbox
14
+ module Package
15
+ class PackageDetailsFetcher
16
+ extend T::Sig
17
+
18
+ SEARCH_URL = T.let("https://search.devbox.sh/v1/search", String)
19
+
20
+ sig { params(dependency: Dependabot::Dependency).void }
21
+ def initialize(dependency:)
22
+ @dependency = dependency
23
+ end
24
+
25
+ sig { returns(T::Array[Dependabot::Package::PackageRelease]) }
26
+ def available_versions
27
+ package = fetch_package
28
+ return [] unless package
29
+
30
+ versions = package["versions"]
31
+ return [] unless versions.is_a?(Array)
32
+
33
+ versions.filter_map do |version_data|
34
+ next unless version_data.is_a?(Hash)
35
+
36
+ version_str = version_data["version"]
37
+ next unless version_str.is_a?(String) && Devbox::Version.correct?(version_str)
38
+
39
+ Dependabot::Package::PackageRelease.new(
40
+ version: Devbox::Version.new(version_str),
41
+ released_at: release_time(version_data)
42
+ )
43
+ end
44
+ rescue JSON::ParserError, Excon::Error::Timeout, Excon::Error::Socket
45
+ []
46
+ end
47
+
48
+ private
49
+
50
+ sig { returns(Dependabot::Dependency) }
51
+ attr_reader :dependency
52
+
53
+ # Nixhub search is fuzzy and can return several packages; keep only the
54
+ # one whose name matches the dependency exactly.
55
+ sig { returns(T.nilable(T::Hash[String, Object])) }
56
+ def fetch_package
57
+ response = Dependabot::RegistryClient.get(
58
+ url: "#{SEARCH_URL}?q=#{CGI.escape(dependency.name)}"
59
+ )
60
+ return nil unless response.status == 200
61
+
62
+ data = JSON.parse(response.body)
63
+ packages = data.is_a?(Hash) ? data["packages"] : nil
64
+ return nil unless packages.is_a?(Array)
65
+
66
+ packages.find { |pkg| pkg.is_a?(Hash) && pkg["name"] == dependency.name }
67
+ end
68
+
69
+ # Approximates a version's release date with the earliest per-system
70
+ # `last_updated` (Unix epoch seconds), falling back to the top-level
71
+ # `last_updated`. `filter_by_cooldown` treats a nil result gracefully.
72
+ sig { params(version_data: T::Hash[String, Object]).returns(T.nilable(Time)) }
73
+ def release_time(version_data)
74
+ timestamps = []
75
+
76
+ systems = version_data["systems"]
77
+ timestamps.concat(systems.values.filter_map { |s| s["last_updated"] if s.is_a?(Hash) }) if systems.is_a?(Hash)
78
+ timestamps << version_data["last_updated"]
79
+
80
+ epoch = timestamps.grep(Integer).min
81
+ epoch ? Time.at(epoch).utc : nil
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,57 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/requirement"
6
+ require "dependabot/utils"
7
+ require "dependabot/devbox/version"
8
+
9
+ # Devbox constraints are nixpkgs version prefixes declared as `name@constraint`
10
+ # in devbox.json. We translate the prefix form into Gem::Requirement strings:
11
+ # latest -> ">= 0" (track the newest release)
12
+ # 3 -> "~> 3.0" (pin the major line)
13
+ # 3.10 -> "~> 3.10.0" (pin the minor line)
14
+ # 3.10.19 -> "= 3.10.19" (pin an exact version)
15
+
16
+ module Dependabot
17
+ module Devbox
18
+ class Requirement < Dependabot::Requirement
19
+ extend T::Sig
20
+
21
+ sig { override.params(requirement_string: T.nilable(String)).returns(T::Array[Requirement]) }
22
+ def self.requirements_array(requirement_string)
23
+ [new(requirement_string)]
24
+ end
25
+
26
+ sig { params(requirements: T.nilable(T.any(String, T::Array[String]))).void }
27
+ def initialize(*requirements)
28
+ constraints = requirements.flatten.compact.flat_map do |req_string|
29
+ req_string.strip.split(/\s*,\s*/).map do |part|
30
+ convert_devbox_constraint_to_ruby_constraint(part.strip)
31
+ end
32
+ end
33
+ constraints = [">= 0"] if constraints.empty?
34
+
35
+ super(constraints)
36
+ end
37
+
38
+ private
39
+
40
+ sig { params(constraint: String).returns(String) }
41
+ def convert_devbox_constraint_to_ruby_constraint(constraint)
42
+ return ">= 0" if constraint.empty? || constraint == Version::LATEST
43
+ # Already a Ruby/Gem requirement string (e.g. ">= 0" from base-class callers
44
+ # like UpdateChecker::Base#can_update? or ignored_versions entries).
45
+ return constraint if constraint.match?(/\A[><=~!]/)
46
+
47
+ segments = constraint.split(".")
48
+ case segments.length
49
+ when 1, 2 then "~> #{constraint}.0"
50
+ else "= #{constraint}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Dependabot::Utils.register_requirement_class("devbox", Dependabot::Devbox::Requirement)
@@ -0,0 +1,33 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/package/package_latest_version_finder"
6
+ require "dependabot/package/package_details"
7
+ require "dependabot/devbox/update_checker"
8
+ require "dependabot/devbox/package/package_details_fetcher"
9
+
10
+ module Dependabot
11
+ module Devbox
12
+ class UpdateChecker < Dependabot::UpdateCheckers::Base
13
+ class LatestVersionFinder < Dependabot::Package::PackageLatestVersionFinder
14
+ extend T::Sig
15
+
16
+ private
17
+
18
+ sig { override.returns(T.nilable(Dependabot::Package::PackageDetails)) }
19
+ def package_details
20
+ @package_details ||= T.let(
21
+ Dependabot::Package::PackageDetails.new(
22
+ dependency: dependency,
23
+ releases: Package::PackageDetailsFetcher.new(
24
+ dependency: dependency
25
+ ).available_versions
26
+ ),
27
+ T.nilable(Dependabot::Package::PackageDetails)
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/update_checkers"
5
+ require "dependabot/update_checkers/base"
6
+ require "dependabot/devbox/version"
7
+ require "dependabot/devbox/requirement"
8
+
9
+ module Dependabot
10
+ module Devbox
11
+ class UpdateChecker < Dependabot::UpdateCheckers::Base
12
+ extend T::Sig
13
+
14
+ require_relative "update_checker/latest_version_finder"
15
+
16
+ LATEST = T.let("latest", String)
17
+
18
+ sig { override.returns(T.nilable(T.any(String, Gem::Version))) }
19
+ def latest_version
20
+ latest_version_finder.latest_version
21
+ end
22
+
23
+ sig { override.returns(T.nilable(T.any(String, Gem::Version))) }
24
+ def latest_resolvable_version
25
+ latest_version
26
+ end
27
+
28
+ sig { override.returns(T.nilable(String)) }
29
+ def latest_resolvable_version_with_no_unlock
30
+ dependency.version
31
+ end
32
+
33
+ sig { override.returns(T::Array[Dependabot::DependencyRequirement]) }
34
+ def updated_requirements
35
+ latest = latest_version
36
+ return dependency.requirements unless latest
37
+
38
+ updated = dependency.requirements.map do |req|
39
+ req.merge(requirement: updated_constraint(req[:requirement], latest.to_s))
40
+ end
41
+ wrap_requirements(updated)
42
+ end
43
+
44
+ private
45
+
46
+ sig { override.returns(T::Boolean) }
47
+ def latest_version_resolvable_with_full_unlock?
48
+ false
49
+ end
50
+
51
+ sig { override.returns(T::Array[Dependabot::Dependency]) }
52
+ def updated_dependencies_after_full_unlock
53
+ []
54
+ end
55
+
56
+ sig { returns(LatestVersionFinder) }
57
+ def latest_version_finder
58
+ @latest_version_finder ||= T.let(
59
+ LatestVersionFinder.new(
60
+ dependency: dependency,
61
+ dependency_files: dependency_files,
62
+ credentials: credentials,
63
+ ignored_versions: ignored_versions,
64
+ security_advisories: security_advisories,
65
+ cooldown_options: update_cooldown
66
+ ),
67
+ T.nilable(LatestVersionFinder)
68
+ )
69
+ end
70
+
71
+ # Recomputes the `name@constraint` constraint for the target version,
72
+ # preserving the original constraint's precision:
73
+ # - "latest" stays "latest" (the lockfile alone advances)
74
+ # - a pinned-minor constraint ("3.10") keeps its two segments, so a patch
75
+ # bump leaves it unchanged and only a minor/major bump rewrites it
76
+ # - a pinned-exact constraint ("3.10.15") tracks every segment, so any
77
+ # bump rewrites it
78
+ sig { params(old_constraint: T.nilable(String), latest: String).returns(T.nilable(String)) }
79
+ def updated_constraint(old_constraint, latest)
80
+ return old_constraint if old_constraint.nil? || old_constraint == LATEST
81
+
82
+ segment_count = old_constraint.split(".").length
83
+ latest.split(".").first(segment_count).join(".")
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ Dependabot::UpdateCheckers.register("devbox", Dependabot::Devbox::UpdateChecker)
@@ -0,0 +1,64 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/version"
5
+ require "dependabot/utils"
6
+ require "sorbet-runtime"
7
+
8
+ # Devbox package versions are nixpkgs versions. Alongside standard numeric
9
+ # versions ("3.10.19") and short prefixes ("3", "3.10"), Devbox supports a
10
+ # "latest" sentinel that always resolves to the newest release, so it must
11
+ # sort above any concrete version.
12
+
13
+ module Dependabot
14
+ module Devbox
15
+ class Version < Dependabot::Version
16
+ extend T::Sig
17
+
18
+ LATEST = "latest"
19
+
20
+ # A value that sorts above any realistic nixpkgs version, used as the
21
+ # internal representation of the "latest" sentinel since Gem::Version
22
+ # cannot parse the word itself.
23
+ LATEST_SENTINEL = T.let("999999", String)
24
+
25
+ sig { override.params(version: VersionParameter).returns(T::Boolean) }
26
+ def self.correct?(version)
27
+ return false if version.nil?
28
+ return true if version.to_s.strip == LATEST
29
+
30
+ super
31
+ end
32
+
33
+ sig { override.params(version: VersionParameter).void }
34
+ def initialize(version)
35
+ @version_string = T.let(version.to_s.strip, String)
36
+ @latest = T.let(@version_string == LATEST, T::Boolean)
37
+
38
+ super(@latest ? LATEST_SENTINEL : version)
39
+ end
40
+
41
+ sig { override.params(version: VersionParameter).returns(Dependabot::Devbox::Version) }
42
+ def self.new(version)
43
+ T.cast(super, Dependabot::Devbox::Version)
44
+ end
45
+
46
+ sig { returns(T::Boolean) }
47
+ def latest?
48
+ @latest
49
+ end
50
+
51
+ sig { override.returns(String) }
52
+ def to_s
53
+ @version_string
54
+ end
55
+
56
+ sig { override.returns(String) }
57
+ def inspect
58
+ "#<#{self.class} #{@version_string}>"
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ Dependabot::Utils.register_version_class("devbox", Dependabot::Devbox::Version)
@@ -0,0 +1,41 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/utils"
5
+ require "dependabot/config/file"
6
+
7
+ # The published dependabot-common gem predates the devbox entry being added
8
+ # to PACKAGE_MANAGER_LOOKUP. Patch validate_package_manager! to allow "devbox"
9
+ # until the upstream PR merges and a new gem version is published.
10
+ module Dependabot
11
+ module Utils
12
+ class << self
13
+ private
14
+
15
+ def validate_package_manager!(package_manager)
16
+ return if package_manager == "devbox"
17
+ return if Config::File::REVERSE_PACKAGE_MANAGER_LOOKUP.key?(package_manager)
18
+ return if %w[dummy silent].include?(package_manager)
19
+
20
+ raise "Unsupported package_manager #{package_manager}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ require "dependabot/devbox/file_fetcher"
27
+ require "dependabot/devbox/file_parser"
28
+ require "dependabot/devbox/update_checker"
29
+ require "dependabot/devbox/file_updater"
30
+ require "dependabot/devbox/metadata_finder"
31
+ require "dependabot/devbox/package/package_details_fetcher"
32
+ require "dependabot/devbox/helpers"
33
+ require "dependabot/devbox/version"
34
+ require "dependabot/devbox/requirement"
35
+
36
+ require "dependabot/pull_request_creator/labeler"
37
+ Dependabot::PullRequestCreator::Labeler
38
+ .register_label_details("devbox", name: "devbox", colour: "5c4ee5")
39
+
40
+ require "dependabot/dependency"
41
+ Dependabot::Dependency.register_production_check("devbox", ->(_) { true })
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dependabot-devbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andoni A.
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dependabot-common
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.383'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.383'
27
+ description: Automatically update Devbox (devbox.json) package versions via Dependabot.
28
+ Standalone gem for use before official dependabot-core support lands.
29
+ email:
30
+ - andonialonsof@gmail.com
31
+ executables:
32
+ - dependabot-devbox-update
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - exe/dependabot-devbox-update
37
+ - lib/dependabot/devbox.rb
38
+ - lib/dependabot/devbox/file_fetcher.rb
39
+ - lib/dependabot/devbox/file_parser.rb
40
+ - lib/dependabot/devbox/file_updater.rb
41
+ - lib/dependabot/devbox/helpers.rb
42
+ - lib/dependabot/devbox/metadata_finder.rb
43
+ - lib/dependabot/devbox/package/package_details_fetcher.rb
44
+ - lib/dependabot/devbox/requirement.rb
45
+ - lib/dependabot/devbox/update_checker.rb
46
+ - lib/dependabot/devbox/update_checker/latest_version_finder.rb
47
+ - lib/dependabot/devbox/version.rb
48
+ homepage: https://github.com/andoniaf/dependabot-devbox
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ bug_tracker_uri: https://github.com/andoniaf/dependabot-devbox/issues
53
+ changelog_uri: https://github.com/andoniaf/dependabot-devbox/releases
54
+ source_code_uri: https://github.com/andoniaf/dependabot-devbox
55
+ rubygems_mfa_required: 'true'
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.3.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Dependabot support for Devbox
75
+ test_files: []